diff --git a/.github/ISSUE_TEMPLATE/Standard.md b/.github/ISSUE_TEMPLATE/Standard.md
index 663c6004a534..fa50d48b341b 100644
--- a/.github/ISSUE_TEMPLATE/Standard.md
+++ b/.github/ISSUE_TEMPLATE/Standard.md
@@ -10,6 +10,7 @@ ___
**Version Number:**
**Reproducible in staging?:**
**Reproducible in production?:**
+**If this was caught on HybridApp, is this reproducible on New Expensify Standalone?:**
**If this was caught during regression testing, add the test name, ID and link from TestRail:**
**Email or phone of affected tester (no customers):**
**Logs:** https://stackoverflow.com/c/expensify/questions/4856
@@ -34,9 +35,11 @@ Can the user still use Expensify without this being fixed? Have you informed the
Check off any platforms that are affected by this issue
--->
Which of our officially supported platforms is this issue occurring on?
-- [ ] Android: Native
+- [ ] Android: Standalone
+- [ ] Android: HybridApp
- [ ] Android: mWeb Chrome
-- [ ] iOS: Native
+- [ ] iOS: Standalone
+- [ ] iOS: HybridApp
- [ ] iOS: mWeb Safari
- [ ] MacOS: Chrome / Safari
- [ ] MacOS: Desktop
diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml
index 183993a7bc86..d8e706d467ba 100644
--- a/.github/workflows/e2ePerformanceTests.yml
+++ b/.github/workflows/e2ePerformanceTests.yml
@@ -46,7 +46,7 @@ jobs:
uses: ./.github/actions/javascript/getArtifactInfo
with:
GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_COMMIT_TOKEN }}
- ARTIFACT_NAME: baseline-${{ steps.getMostRecentRelease.outputs.VERSION }}-android-artifact-apk
+ ARTIFACT_NAME: baseline-${{ steps.getMostRecentRelease.outputs.VERSION }}android-artifact-apk
- name: Skip build if there's already an existing artifact for the baseline
if: ${{ fromJSON(steps.checkForExistingArtifact.outputs.ARTIFACT_FOUND) }}
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 183a1783a7e9..87e4d8107433 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -110,8 +110,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1009004800
- versionName "9.0.48-0"
+ versionCode 1009004902
+ versionName "9.0.49-2"
// Supported language variants must be declared here to avoid from being removed during the compilation.
// This also helps us to not include unnecessary language variants in the APK.
resConfigs "en", "es"
diff --git a/desktop/package-lock.json b/desktop/package-lock.json
index 90ac50d1dcbd..75cb080f1349 100644
--- a/desktop/package-lock.json
+++ b/desktop/package-lock.json
@@ -9,7 +9,7 @@
"dependencies": {
"electron-context-menu": "^2.3.0",
"electron-log": "^4.4.8",
- "electron-updater": "^6.3.6",
+ "electron-updater": "^6.3.8",
"mime-types": "^2.1.35",
"node-machine-id": "^1.1.12"
},
@@ -59,9 +59,9 @@
}
},
"node_modules/builder-util-runtime": {
- "version": "9.2.7",
- "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.7.tgz",
- "integrity": "sha512-0qw2vcbA66LW/ImxZSy0vKQr9OqrpFXxtLyITBla7CdLlgz9fZkVAhKBi8EmNYIplL9j3zizB3mcgWnwVC6Fmg==",
+ "version": "9.2.9",
+ "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.9.tgz",
+ "integrity": "sha512-DWeHdrRFVvNnVyD4+vMztRpXegOGaQHodsAjyhstTbUNBIjebxM1ahxokQL+T1v8vpW8SY7aJ5is/zILH82lAw==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4",
@@ -156,12 +156,12 @@
"integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA=="
},
"node_modules/electron-updater": {
- "version": "6.3.6",
- "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.6.tgz",
- "integrity": "sha512-SciFV8nt04rwFFlW8Ph7NkoXVgISMJ9i7aAmovFmp3xFd6GUPBKpLKJNGPy10/R0hJKe/9fDkuVQ75LEZEQ+Ng==",
+ "version": "6.3.8",
+ "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.8.tgz",
+ "integrity": "sha512-OFsA2vyuOZgsqq4EW6tgW8X8e521ybDmQyIYLqss7HdXev+Ak90YatzpIECOBJXpmro5YDq4yZ2xFsKXqPt1DQ==",
"license": "MIT",
"dependencies": {
- "builder-util-runtime": "9.2.7",
+ "builder-util-runtime": "9.2.9",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"lazy-val": "^1.0.5",
@@ -469,9 +469,9 @@
"integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="
},
"builder-util-runtime": {
- "version": "9.2.7",
- "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.7.tgz",
- "integrity": "sha512-0qw2vcbA66LW/ImxZSy0vKQr9OqrpFXxtLyITBla7CdLlgz9fZkVAhKBi8EmNYIplL9j3zizB3mcgWnwVC6Fmg==",
+ "version": "9.2.9",
+ "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.9.tgz",
+ "integrity": "sha512-DWeHdrRFVvNnVyD4+vMztRpXegOGaQHodsAjyhstTbUNBIjebxM1ahxokQL+T1v8vpW8SY7aJ5is/zILH82lAw==",
"requires": {
"debug": "^4.3.4",
"sax": "^1.2.4"
@@ -538,11 +538,11 @@
"integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA=="
},
"electron-updater": {
- "version": "6.3.6",
- "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.6.tgz",
- "integrity": "sha512-SciFV8nt04rwFFlW8Ph7NkoXVgISMJ9i7aAmovFmp3xFd6GUPBKpLKJNGPy10/R0hJKe/9fDkuVQ75LEZEQ+Ng==",
+ "version": "6.3.8",
+ "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.8.tgz",
+ "integrity": "sha512-OFsA2vyuOZgsqq4EW6tgW8X8e521ybDmQyIYLqss7HdXev+Ak90YatzpIECOBJXpmro5YDq4yZ2xFsKXqPt1DQ==",
"requires": {
- "builder-util-runtime": "9.2.7",
+ "builder-util-runtime": "9.2.9",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"lazy-val": "^1.0.5",
diff --git a/desktop/package.json b/desktop/package.json
index 2392c98434c1..326d6f24f740 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -6,7 +6,7 @@
"dependencies": {
"electron-context-menu": "^2.3.0",
"electron-log": "^4.4.8",
- "electron-updater": "^6.3.6",
+ "electron-updater": "^6.3.8",
"mime-types": "^2.1.35",
"node-machine-id": "^1.1.12"
},
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account.md
index 1ad70117ed5c..49aaa480e2dc 100644
--- a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account.md
+++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account.md
@@ -8,7 +8,7 @@ You can choose to connect either a business deposit-only account that only recei
| Business deposit-only account | Verified business account |
|---------------------------------------------------|------------------------------------------------------|
-| ✔ Receive reimbursements for invoices | ✔ Reimburse expenses via direct bank transfer |
+| ✔ Receive payments for invoices | ✔ Reimburse expenses via direct bank transfer |
| | ✔ Pay bills |
| | ✔ Issue Expensify Cards |
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md
similarity index 95%
rename from docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills.md
rename to docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md
index 465f6742eaea..aff11c059d81 100644
--- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills.md
+++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md
@@ -1,26 +1,26 @@
---
-title: Pay Bills
+title: Create and Pay Bills
description: Expensify bill management and payment methods.
---
Streamline your operations by receiving and paying vendor or supplier bills directly in Expensify. Vendors can send bills even if they don't have an Expensify account, and you can manage payments seamlessly.
-## Receive Bills in Expensify
+# Receive Bills in Expensify
You can receive bills in three ways:
- Directly from Vendors: Provide your Expensify billing email to vendors.
- Forwarding Emails: Forward bills received in your email to Expensify.
- Manual Upload: For physical bills, create a Bill in Expensify from the Reports page.
-## Bill Pay Workflow
+# Bill Pay Workflow
1. When a vendor or supplier sends a bill to Expensify, the document is automatically SmartScanned, and a Bill is created. This Bill is managed by the primary domain contact, who can view it on the Reports page within their default group policy.
2. Once the Bill is ready for processing, it follows the established approval workflow. As each person approves it, the Bill appears in the next approver’s Inbox. The final approver will pay the Bill using one of the available payment methods.
3. During this process, the Bill is coded with the appropriate GL codes from your connected accounting software. After completing the approval workflow, the Bill can be exported back to your accounting system.
-## Payment Methods
+# Payment Methods
There are multiple ways to pay Bills in Expensify. Let’s go over each method below.
-### ACH bank-to-bank transfer
+## ACH bank-to-bank transfer
To use this payment method, you must have a [business bank account connected to your Expensify account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account).
@@ -32,7 +32,7 @@ To use this payment method, you must have a [business bank account connected to
**Fees:** None
-### Credit or Debit Card
+## Credit or Debit Card
This option is available to all US and International customers receiving a bill from a US vendor with a US business bank account.
**To pay with a credit or debit card:**
@@ -43,13 +43,13 @@ This option is available to all US and International customers receiving a bill
**Fees:** 2.9% of the total amount paid.
-### Venmo
+## Venmo
If both you and the vendor must have Venmo connected to Expensify, you can pay the bill by following the steps outlined [here](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments#setting-up-third-party-payments).
**Fees:** Venmo charges a 3% sender’s fee.
-### Pay outside of Expensify
+## Pay outside of Expensify
If you are unable to pay using one of the above methods, you can still mark the Bill as paid. This will update its status to indicate that the payment was made outside Expensify.
**To mark a Bill as paid outside of Expensify:**
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-an-Invoice.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-an-Invoice.md
new file mode 100644
index 000000000000..84fafc949527
--- /dev/null
+++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-an-Invoice.md
@@ -0,0 +1,65 @@
+---
+title: Pay an Invoice
+description: A guide to different methods of paying an invoice
+---
+
+
+
+There are multiple ways to pay Invoices in Expensify. Let’s go over each method below.
+
+# How to Pay Invoices
+
+1. Sign in to your [Expensify web account](www.expensify.com).
+2. Click on the Invoice you’d like to pay to see the details.
+3. Click on the **Pay** button.
+4. Follow the prompts to pay through one of the following methods.
+
+### ACH bank-to-bank transfer
+
+To use this payment method, you must have a [business bank account connected to your Expensify account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account).
+
+**To pay with an ACH bank-to-bank transfer:**
+1. Sign in to your [Expensify web account](www.expensify.com).
+2. Go to the **Home** or **Reports** page and locate the Invoice that needs to be paid.
+3. Click the **Pay** button to be redirected to the Invoice.
+4. Choose the ACH option from the drop-down list.
+
+**Fees:** None
+
+### Credit or Debit Card
+This option is available to all US and International customers receiving an invoice from a US vendor with a US business bank account.
+
+**To pay with a credit or debit card:**
+1. Sign in to your [Expensify web account](www.expensify.com).
+2. Click on the Invoice you’d like to pay to see the details.
+3. Click the **Pay** button.
+4. Enter your credit card or debit card details.
+
+**Fees:** 2.9% credit card payment fee.
+
+### Venmo
+If both you and the vendor must have Venmo connected to Expensify, you can pay the Invoice by following the steps outlined [here](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments#setting-up-third-party-payments).
+
+**Fees:** Venmo and Paypal.me charges may apply.
+
+
+### Pay outside of Expensify
+If you are unable to pay using one of the above methods, you can still mark the Invoice as paid. This will update its status to indicate that the payment was made outside Expensify.
+
+**To mark an Invoice as paid outside of Expensify:**
+1. Sign in to your [Expensify web account](www.expensify.com).
+2. Click on the Invoice you’d like to pay to see the details.
+3. Click the Pay button.
+4. Choose **I’ll do it manually**.
+
+**Fees:** None.
+
+{% include faq-begin.md %}
+
+## What’s the difference between an Invoice and an Expense Report in Expensify?
+An invoice is an expense submitted to a client or contractor for payment. An expense report is an expense/group of expenses submitted to an employer for reimbursement.
+
+## What’s the difference between a Bill and an Invoice in Expensify?
+A Bill is an amount owed to a payee (usually a vendor or supplier) and is usually created from a vendor invoice. An Invoice is a receivable and indicates an amount owed to you by someone else.
+
+{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/domains/SAML-SSO.md b/docs/articles/expensify-classic/domains/SAML-SSO.md
index e4b27b238e46..a6032afe8d24 100644
--- a/docs/articles/expensify-classic/domains/SAML-SSO.md
+++ b/docs/articles/expensify-classic/domains/SAML-SSO.md
@@ -2,18 +2,20 @@
title: Managing Single Sign-On (SSO) and User Authentication in Expensify
description: Learn how to effectively manage Single Sign-On (SSO) and user authentication in Expensify alongside your preferred SSO provider. Our comprehensive guide covers SSO setup, domain verification, and specific instructions for popular providers like AWS, Okta, and Microsoft Azure. Streamline user access and enhance security with Expensify's SAML-based SSO integration.
---
-# Overview
-This article provides a comprehensive guide on managing Single Sign-On (SSO) and user authentication in Expensify alongside your preferred SSO provider. Expensify uses SAML to enable and manage SSO between Expensify and your SSO provider.
-# How to Use SSO in Expensify
-Before setting up Single Sign-On with Expensify you will need to make sure your domain has been verified. Once the domain is verified, you can access the SSO settings by navigating to Settings > Domains > [Domain Name] > SAML.
-On this page, you can:
+# Using SSO in Expensify
+Before setting up Single Sign-On with Expensify you will need to make sure the [domain is verified](https://help.expensify.com/articles/expensify-classic/domains/Claim-And-Verify-A-Domain#step-2-verify-domain-ownership).
+
+Once the domain is verified, you can access the SSO settings by navigating to Settings > Domains > [Domain Name] > SAML.
+
+## The Domains page
+**On this page, you can:**
- Get Expensify's Service Provider MetaData. You will need to give this to your identity provider.
- Enter your Identity Provider MetaData. Please contact your SAML SSO provider if you are unsure how to get this.
- Choose whether you want to make SAML SSO required for login. If you choose this option, members will only be able to log in to Expensify via SAML SSO.
-Instructions for setting up Expensify for specific SSO providers can be found below. If you do not see your provider listed below, please contact them and request instructions.
+
+**Below are instructions for setting up Expensify for specific SSO providers:**
- [Amazon Web Services (AWS SSO)](https://static.global.sso.amazonaws.com/app-202a715cb67cddd9/instructions/index.htm)
-- [Bitium](https://support.bitium.com/administration/saml-expensify/)
- [Google SAML](https://support.google.com/a/answer/7371682) (for GSuite, not Google SSO)
- [Microsoft Azure Active Directory](https://azure.microsoft.com/en-us/documentation/articles/active-directory-saas-expensify-tutorial/)
- [Okta](https://saml-doc.okta.com/SAML_Docs/How-to-Configure-SAML-2.0-for-Expensify.html)
@@ -22,22 +24,39 @@ Instructions for setting up Expensify for specific SSO providers can be found be
- [SAASPASS](https://saaspass.com/saaspass/expensify-two-factor-authentication-2fa-single-sign-on-sso-saml.html)
- Microsoft Active Directory Federation Services (see instructions in the FAQ section below)
-When SSO is enabled, employees will be prompted to sign in through Single Sign-On when using their company email (private domain email) and also a public email (e.g. gmail.com) linked as a secondary login.
+If your provider is not listed, please contact them and request instructions.
+
+When SSO is enabled, employees will be prompted to sign in through Single Sign-On when using their company email (private domain email) and also a public email (e.g. gmail.com) linked as a [Secondary Login](https://help.expensify.com/articles/expensify-classic/settings/Change-or-add-email-address).
+
+{% include faq-begin.md %}
+
+## What should I do if I’m getting an error when trying to set up SSO?
+You can double-check your configuration data for errors using samltool.com. If you’re still having issues, you can contact your Account Manager or Concierge for assistance.
+
+## What is the EntityID for Expensify?
+The entityID for Expensify is https://expensify.com. Remember not to copy and paste any extra slashes or spaces. If you've enabled the Multi-Domain support (see below) then your entityID will be https://expensify.com/mydomainname.com.
+
+## Can you have multiple domains with only one entity ID?
+Yes. Please send a message to the Concierge or your account manager, and we will enable the use of the same entity ID with multiple domains.
## How can I update the Microsoft Azure SSO Certificate?
-Expensify's SAML configuration doesn't support multiple active certificates. This means that if you create the new certification ahead of time without first removing the old one, the respective IdP will include two unique x509 certificates instead of one and the connection will break. Should you need to access Expensify, switching back to the old certificate will continue to allow access while that certificate is still valid.
+Expensify's SAML configuration doesn't support multiple active certificates. This means that if you create the new certification ahead of time without first removing the old one, the respective IDP will include two unique x509 certificates instead of one, and the connection will break. Should you need to access Expensify, switching back to the old certificate will continue to allow access while that certificate is still valid.
-To transfer from one Microsoft Azure certificate to another, please follow the below steps:
-1. In Azure Directory , create your new certificate.
+**To transfer from one Microsoft Azure certificate to another, please follow the below steps:**
+1. In Azure Directory, create your new certificate.
2. In Azure Director, remove the old, expiring certificate.
-3. In Azure Directory, activate the remaining certificate, and get a new IdP for Expensify from it.
-4. In Expensify, replace the previous IdP with the new IdP.
-5. Log in via SSO. If login continues to fails, write into Concierge for assistance.
+3. In Azure Directory, activate the remaining certificate and get a new IDP for Expensify from it.
+4. In Expensify, replace the previous IDP with the new IDP.
+5. Log in via SSO. If login continues to fail, write to Concierge for assistance.
-## How can I enable deactivating users with the Okta SSO integration?
-Companies using Okta can deactivate users in Expensify using the Okta SCIM API. This means that when a user is deactivated in Okta their access to Expensify will expire and they will be logged out of both the web and mobile apps. Deactivating a user through Okta will not close their account in Expensify, if you are offboarding this employee, you will still want to close the account. You will need have a verified domain and SAML fully setup before completing setting up the deactivation feature.
+## How can I enable "deactivating users" with the Okta SSO integration?
+Companies using Okta can deactivate users in Expensify using the Okta SCIM API:
+- When a user is deactivated in Okta, their access to Expensify expires, and they are logged out of both the web and mobile apps.
+- Deactivating a user through Okta will not close their account in Expensify
+- If you are offboarding this employee, you will still want to close the account.
+- A verified domain and a complete SAML setup are required before you can configure the deactivation feature.
-To enable deactivating users in Okta, follow these steps:
+**To enable deactivating users in Okta, follow these steps:**
1. In Expensify, head to *Settings > Domains > _[Domain Name]_ > SAML*
2. Ensure that the toggle is set to Enabled for *SAML Login* and *Required for login*
3. In Okta, go to *Admin > Applications > Add Application*
@@ -49,18 +68,18 @@ To enable deactivating users in Okta, follow these steps:
9. Then, go to *Directory > Profile Editor > Okta user > Profile*
10. Click the information bubble to the right of the *First name* and *Last name* attributes
11. Uncheck *Yes* under *Attribute required* field and press *Save Attribute*.
-12. Email concierge@expensify.com providing your domain and request that Okta SCIM be enabled. You will receive a response when this step has been completed.
+12. Email concierge@expensify.com, providing your domain, and request that Okta SCIM be enabled. You will receive a response when this step has been completed.
13. In Expensify, go to *Domains > _[Domain Name]_ > SAML > Show Token* and copy the Okta SCIM Token you received.
14. In Okta, go to *Admin > Applications > Expensify > Provisioning > API Integration > Configure API Integration*
-15. Select Enable API Integration and paste the Okta SCIM Token in API Token field and then click Save.
-15. Go to To App, click Edit Provisioning Users, select Enable Deactivate Users and then Save. (You may also need to set up the Expensify Attribute Mappings if you have not previously in steps 9-11).
+15. Select Enable API Integration, paste the Okta SCIM Token in the API Token field, and then click Save.
+15. Go to To App, click Edit Provisioning Users, select Enable Deactivate Users, and then Save. (You may also need to set up the Expensify Attribute Mappings if you have not previously in steps 9-11).
Successful activation of this function will be indicated by the green Push User Deactivation icon being enabled at the top of the app page.
-## How can I set up SAML authentication with Microsoft ADFS?
-Before getting started, you will need to have a verified domain and Control plan in order to set up SSO with Microsoft ADFS.
+## How do I set up the SAML authentication with Microsoft ADFS?
+Before getting started, you will need a verified domain and Control plan to set up SSO with Microsoft ADFS.
-To enable SSO with Microsoft ADFS follow these steps:
+**To enable SSO with Microsoft ADFS follow these steps:**
1. Open the ADFS management console, and click the *Add Relying Party Trust* link on the right.
2. Check the option to *Import data about the relying party from a file*, then click the *Browse* button. You will input the XML file of Expensify’s metadata which can be found on the Expensify SAML setup page.
3. The metadata file will provide the critical information that ADFS needs to set up the trust. In ADFS, give it a name, and click Next.
@@ -69,22 +88,10 @@ To enable SSO with Microsoft ADFS follow these steps:
6. The new trust is now created. Highlight the trust, then click *Edit claim rules* on the right.
7. Click *Add a Rule*.
8. The default option should be *Send LDAP Attributes as Claims*. Click Next.
-9. Depending upon how your Active Directory is set up, you may or may not have a useful email address associated with each user, or you may have a policy to use the UPN as the user attribute for authentication. If so, using the UPN user attribute may be appropriate for you. If not, you can use the emailaddress attribute.
+9. Depending upon how your Active Directory is set up, you may or may not have a useful email address associated with each user, or you may have a policy to use the UPN as the user attribute for authentication. If so, using the UPN user attribute may be appropriate for you. If not, you can use the email address attribute.
10. Give the rule a name like *Get email address from AD*. Choose Active Directory as the attribute store from the dropdown list. Choose your source user attribute to pass to Expensify that has users’ email address info in it, usually either *E-Mail-Address* or *User-Principal-Name*. Select the outgoing claim type as “E-Mail Address”. Click OK.
11. Add another rule; this time, we want to *Transform an Incoming Claim*. Click Next.
12. Name the rule *Send email address*. The Incoming claim type should be *E-Mail Address*. The outgoing claim type should be *Name ID*, and the outgoing name ID format should be *Email*. Click OK.
13. You should now have two claim rules.
-Assuming you’ve also set up Expensify SAML configuration with your metadata, SAML logins on Expensify.com should now work. For reference, ADFS’ default metadata path is: https://yourservicename.yourdomainname.com/FederationMetadata/2007-06/FederationMetadata.xml.
-
-{% include faq-begin.md %}
-## What should I do if I’m getting an error when trying to set up SSO?
-You can double check your configuration data for errors using samltool.com. If you’re still having issues, you can reach out to your Account Manager or contact Concierge for assistance.
-
-## What is the EntityID for Expensify?
-The entityID for Expensify is https://expensify.com. Remember not to copy and paste any extra slashes or spaces. If you've enabled the Multi-Domain support (see below) then your entityID will be https://expensify.com/mydomainname.com.
-
-## Can you have multiple domains with only one entityID?
-Yes. Please send a message to Concierge or your account manager and we will enable the ability to use the same entityID with multiple domains.
-
{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/expenses/Add-Invoices-in-Bulk.md b/docs/articles/expensify-classic/expenses/Add-Invoices-in-Bulk.md
new file mode 100644
index 000000000000..6257c1e6d84d
--- /dev/null
+++ b/docs/articles/expensify-classic/expenses/Add-Invoices-in-Bulk.md
@@ -0,0 +1,48 @@
+---
+title: Bulk Upload Invoices
+description: How to Bulk Upload Invoices
+---
+
+Expensify offers importing multiple invoices (bulk import) via CSV to save you from manually creating individual invoices.
+
+## Uploading Invoices into Expensify
+
+1. Click the **Reports** tab.
+2. Click the **New Report** drop-down.
+3. Select **Bulk Import Invoices**.
+4. Click the sample CSV link to download your custom CSV template to your browser or computer.
+5. Add the invoice details following the formatting rules (see below **CSV formatting guide** section)
+6. Click **Upload CSV**
+
+## CSV formatting guide
+
+- Send to: recipient's email address (ex: john.smith@companydomain.com)
+- Share: email address (ex: julie.clarke@companydomain.com)
+- Report Name: this will be the name of the Invoice report
+- Merchant: business name of invoice sender
+- Amount: use the number format in this column. Negative amounts cannot be invoiced.
+- Date: YYYY-MM-DD formatting
+- Due Date: YYYY-MM-DD formatting
+
+## After the Invoices are uploaded
+
+- After you click **Upload**, the invoices will automatically be created and viewable on the **Reports** page.
+- The **Send To** contact will get an email notifying them of the invoice you sent.
+- You can manually edit the invoice details.
+- You can manually upload a PDF of the invoice to the report.
+
+{% include faq-begin.md %}
+
+## Are there any fees associated with Invoices in Expensify?
+No, Invoices are part of the [Control Plan](https://help.expensify.com/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription#change-group-plan).
+
+## Can Invoices be revised once they are sent?
+If you sent an invoice by mistake, you can click **Undo Send** on the invoice to revoke it. If you’d like to add more details to a sent invoice, you can add those as a [Report comment](https://help.expensify.com/articles/expensify-classic/reports/Add-comments-and-attachments-to-a-report) for everyone to view.
+
+## How do I communicate with the payor
+You can communicate with the payor through [Report comments](https://help.expensify.com/articles/expensify-classic/reports/Add-comments-and-attachments-to-a-report).
+
+## What’s the difference between an Invoice and an Expense Report in Expensify?
+An invoice is an expense submitted to a client or contractor for payment. An expense report is an expense or group of expenses submitted to an employer for reimbursement.
+
+{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/expenses/Send-and-Receive-Payment-for-Invoices.md b/docs/articles/expensify-classic/expenses/Send-and-Receive-Payment-for-Invoices.md
new file mode 100644
index 000000000000..c2ebb64b0af6
--- /dev/null
+++ b/docs/articles/expensify-classic/expenses/Send-and-Receive-Payment-for-Invoices.md
@@ -0,0 +1,39 @@
+---
+title: Send and Receive Payment for Invoices
+description: How to Send and Receive Payments for Invoices
+---
+
+Simplify your back office by sending invoices to vendors and suppliers in Expensify.
+Invoices can be sent to anyone with or without an Expensify account and paid directly to your business bank account through Expensify.
+
+## How to send an invoice in Expensify
+
+1. Sign in to your [Expensify web account](www.expensify.com)
+2. Customize your company invoices following the steps in this [help article](https://help.expensify.com/articles/expensify-classic/workspaces/Set-Up-Invoicing). (Optional)
+3. From the **Reports** page, click the drop-down and select **Invoice**.
+4. Upload a PDF/image of the invoice.
+5. Add applicable tags and categories based on your workspace settings.
+6. Click **Send**.
+
+## How to Receive an Invoice Payment in Expensify
+
+1. To use Expensify payments, you must have a [business bank account connected to your Expensify account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account).
+2. Ensure the payment details are on the invoice sent to the payor.
+3. The payor will receive a notification of the submitted invoice.
+4. They will have the option to pay the invoice through Expensify.
+
+{% include faq-begin.md %}
+
+## Are there any fees associated with Invoices in Expensify?
+No, Invoices are part of the [Control Plan](https://help.expensify.com/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription#change-group-plan).
+
+## Can Invoices be revised once they are sent?
+If you sent an invoice by mistake, you can click **Undo Send** on the invoice to revoke it. If you’d like to add more details to a sent invoice, you can add those as a [Report comment](https://help.expensify.com/articles/expensify-classic/reports/Add-comments-and-attachments-to-a-report) for everyone to view.
+
+## How do I communicate with the payor
+You can communicate with the payor through [Report comments](https://help.expensify.com/articles/expensify-classic/reports/Add-comments-and-attachments-to-a-report).
+
+## What’s the difference between an Invoice and an Expense Report in Expensify?
+An invoice is an expense submitted to a client or contractor for payment. An expense report is an expense or group of expenses submitted to an employer for reimbursement.
+
+{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/expenses/Trips.md b/docs/articles/expensify-classic/expenses/Trips.md
index 04f95c96eb44..1c84cd9189f7 100644
--- a/docs/articles/expensify-classic/expenses/Trips.md
+++ b/docs/articles/expensify-classic/expenses/Trips.md
@@ -2,38 +2,35 @@
title: Trips
description: Automate getting paid back for your travel through Expensify's Trips feature.
---
-# Overview
-Discover how Expensify streamlines your travel expense management when it comes to trips. With the automatic classification of trip receipts and real-time notifications for travel changes, you can effortlessly stay organized and informed on all your trips.
-
-When a travel receipt/itinerary is uploaded into Expensify and SmartScanned, the Trips section of the mobile app will automatically populate your trip information.. If your flight has any cancellations, unexpected changes, or delays, we will make sure you know about it. We will notify you of the change as soon as it happens via the mobile app.
+When a travel receipt or itinerary is uploaded to Expensify via SmartScan, the Trips section of the mobile app automatically populates your trip information. If your flight has any cancellations, unexpected changes, or delays, we will notify you of them as soon as they occur via the mobile app.
For the receipt to be processed as a Trip, it must include the total amount of the expense, date, and merchant name.
If your company is using a travel integration from the list shown below, you can automate this process entirely:
- [TravelPerk](https://help.expensify.com/articles/expensify-classic/integrations/travel-integrations/TravelPerk)
- [Egencia](https://help.expensify.com/articles/expensify-classic/integrations/travel-integrations/Egencia)
-- [Navan](https://help.expensify.com/articles/expensify-classic/integrations/travel-integrations/Trip-Actions)
+- [Navan](https://help.expensify.com/articles/expensify-classic/connections/Navan)
-# How to add a Trip to your account
+# Add a Trip to your account
-Trip receipts are typically sent via email, and will include multiple pages. With that in mind, we recommend emailing receipts directly to Expensify for ease.
+Trip receipts are typically sent via email and will include multiple pages. For ease, we recommend emailing receipts directly to Expensify.
-To email a flight or hotel receipt, you’ll forward the receipt from your Expensify-associated email address to receipts@expensify.com.
+To email a flight or hotel receipt, simply forward the receipt from your Expensify-associated email address to receipts@expensify.com.
-# How to access your Trip information
+# Access your Trip information
To view details about your past or upcoming trips, follow these steps within the Expensify mobile app:
1. Open the Expensify mobile app
-2. Navigate to the "Menu" option (top left ≡ icon)
+2. Navigate to the **Menu** option (top left ≡ icon)
3. Select **Trips**
{% include faq-begin.md %}
## How do I capture Trip receipts sent to my personal email address?
-If you received your receipt in an email that is not associated with your Expensify account, you can add this email as a [secondary login](https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details#how-to-add-a-secondary-login) to directly forward the receipt into your account.
+If you received your receipt in an email that is not associated with your Expensify account, you can add this email as a [secondary login](https://help.expensify.com/articles/expensify-classic/settings/Change-or-add-email-address) to directly forward the receipt into your account.
## How do I upload Trip receipts that were not sent to me by email?
-If your trip receipt was not sent to you by email, you can manually upload the receipt to your account. Check out this resource for more information on [manually uploading receipts](https://help.expensify.com/articles/expensify-classic/expenses/expenses/Upload-Receipts#manually-upload).
+If your trip receipt was not sent to you by email, you can manually upload the receipt to your account. Check out this resource for more information on [manually creating expenses](https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense#add-an-expense-manually).
{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/expensify-billing/Consolidated-Domain-Billing.md b/docs/articles/expensify-classic/expensify-billing/Consolidated-Domain-Billing.md
index 2e829c0785d3..671d0c41e772 100644
--- a/docs/articles/expensify-classic/expensify-billing/Consolidated-Domain-Billing.md
+++ b/docs/articles/expensify-classic/expensify-billing/Consolidated-Domain-Billing.md
@@ -16,9 +16,12 @@ When a Domain Admin enables Consolidated Domain Billing, all Group workspaces ow
If you don’t have multiple billing owners across your organization, or if you want to keep billing separate for any reason, then this feature isn’t necessary.
If you have an Annual Subscription and enable Consolidated Domain Billing, the Consolidated Domain Billing feature will gather the amounts due for each Group workspace Billing Owner (listed under **Settings > Workspaces > Group**). To make full use of the Annual Subscription for all workspaces in your domain, you should also be the billing owner for all Group workspaces.
+
{% include faq-begin.md %}
+
## How do I take over the billing of a workspace with Consolidated Domain Billing enabled?
You’ll have to toggle off Consolidated Domain Billing, take over ownership of the workspace, and then toggle it back on.
+
## Can I use Consolidated Domain Billing to cover the bill for some workspaces, but not others?
No, this feature means that you’ll be paying the bill for all domain members who choose a subscription.
diff --git a/docs/articles/expensify-classic/expensify-billing/Receipt-Breakdown.md b/docs/articles/expensify-classic/expensify-billing/Receipt-Breakdown.md
index 21e2db5604f8..8f512fb71512 100644
--- a/docs/articles/expensify-classic/expensify-billing/Receipt-Breakdown.md
+++ b/docs/articles/expensify-classic/expensify-billing/Receipt-Breakdown.md
@@ -2,46 +2,36 @@
title: Receipts Breakdown
description: This article goes over the Expensify receipt for billing owners.
---
+**Your receipt is broken up into the following sections:**
+- A high-level summary of your total Expensify bill
+- Ways to reduce your bill and get paid to use Expensify
+- A billing breakdown that covers all activity and discounts
+- An activity breakdown by workspace
-# Overview
-This article will give you (the billing owner) a detailed breakdown of your Expensify bill.
+## A high-level summary
-Your receipt is broken up into multiple sections that include:
-1. A high-level summary of your total Expensify bill
-2. Ways to reduce your bill and get paid to use Expensify
-3. A billing breakdown that covers all activity and discounts
-4. An activity breakdown by workspace
+- The top section will show the total amount you paid as the billing owner of Expensify Workspaces and give you a breakdown of the price per member.
+- Every member of your workspace(s) can store data, review data, and access free features like Expensify Chat.
+- We show the total price and then calculate the price per member by using the number of members across all of the workspaces you own.
+- Further down in the receipt, there's a breakdown of the members who generated billable activity.
-## How-to understand the high-level summary
-The top section will show the total amount you paid as the billing owner of Expensify workspaces and give you a breakdown of price per member. Every member of your workspace(s) gets to store data, review data, and access free features like Expensify Chat. Thus, we show the total price and then use all of the members across all of the workspaces you own to calculate the price per member. Further down in the receipt, and in this article, we break down the members who generated billable activity.
-
-## How-to reduce your bill and get paid to use Expensify
+## Reduce your bill and get paid to use Expensify
Chances are you can actually get paid to use Expensify with the Expensify Card. In this section of the receipt, we outline how much money you're leaving on the table by not using the Expensify Card. You can click `Get started` to connect with your account manager (if you have one) or Concierge, both of whom can help get you started with the card.
_Note: Currently, we offer Expensify Cards to companies with US bank accounts._
-## How-to understand your billing breakdown
+## The billing breakdown
Your receipt will have a detailed breakdown of activity and discounts across all workspaces. Here's a description of items that may appear on your bill:
-- [Number of] Inactive workspace members @ $0.00
- - All inactive members from any of your workspaces.
-- [Number of] Chat-only members @ $0.00
- - Any workspace members who chatted but didn't generate any other billable activity. Learn more about [chatting for free.](https://help.expensify.com/articles/new-expensify/chat/Introducing-Expensify-Chat)
-- [Number of] Annual Control members @ $18.00
- - Any members included in your annual subscription on the Control plan.
-- [Number of] Pay-per-use Control members @ $36.00
- - Any members above your annual subscription size on the Control plan. They're billed at the pay-per-use rate.
-- [Number of] Annual Collect members @ $10.00
- - Any members included in your annual subscription on the Collect plan.
-- [Number of] Pay-per-use Collect members @ $20.00
- - Any members above your annual subscription size on the Collect plan. These members are billed at the pay-per-use rate.
-- X% Expensify Card discount with $Y spend
- - The % discount you're getting based on total settled US purchases across your Expensify Cards.
-- X% Expensify Card cash back credit for $Y spend
- - The amount of cash back you've earned based on total settled US purchases across your Expensify Cards.
-- 50% ExpensifyApproved! partner discount
- - If you're part of an accounting firm, you get an additional discount for being our partner. [Learn more about our ExpensifyApproved! accountants program.](https://use.expensify.com/accountants-program)
-- Total
- - Sum of all the line items above.
+- **[Number of] Inactive workspace members @ $0.00:** All inactive members from any of your workspaces.
+- **[Number of] Chat-only members @ $0.00:** Any workspace members who chatted but didn't generate any other billable activity. Learn more about [chatting for free.](https://help.expensify.com/new-expensify/hubs/chat/)
+- **[Number of] Annual Control members @ $18.00:** Any members included in your annual subscription on the Control plan.
+- **[Number of] Pay-per-use Control members @ $36.00:** Any members above your annual subscription size on the Control plan. They're billed at the pay-per-use rate.
+- **[Number of] Annual Collect members @ $10.00:** Any members included in your annual subscription on the Collect plan.
+- **[Number of] Pay-per-use Collect members @ $20.00:** Any members above your annual subscription size on the Collect plan. These members are billed at the pay-per-use rate.
+- **X% Expensify Card discount with $Y spend:** The % discount you're getting based on total settled US purchases across your Expensify Cards.
+- **X% Expensify Card cash back credit for $Y spend:** The amount of cash back you've earned based on total settled US purchases across your Expensify Cards.
+- **50% ExpensifyApproved! partner discount:** If you're part of an accounting firm, you get an additional discount for being our partner -- learn more about our ExpensifyApproved! accountant program [here](https://use.expensify.com/accountants-program).
+- **Total:** The sum of all the line items above.
-## How-to understand your activity breakdown
+## The activity breakdown
This section will list all of your workspaces alongside their IDs and break down the billing for each of them.
diff --git a/docs/articles/expensify-classic/expensify-partner-program/Card-Revenue-Share.md b/docs/articles/expensify-classic/expensify-partner-program/Card-Revenue-Share.md
index b6c3bc0904c0..663a5e3cd9c8 100644
--- a/docs/articles/expensify-classic/expensify-partner-program/Card-Revenue-Share.md
+++ b/docs/articles/expensify-classic/expensify-partner-program/Card-Revenue-Share.md
@@ -2,26 +2,23 @@
title: Expensify Card revenue share for ExpensifyApproved! partners
description: Earn money when your clients adopt the Expensify Card
---
-# Overview
You can now earn additional income for your firm every time your client uses their Expensify Card. In short, your firm gets 0.5% of your clients’ total Expensify Card spend as cash back. The more your clients spend, the more cashback your firm receives!
This program is currently only available to US-based ExpensifyApproved! partner accountants.
-# Become a domain admin
-To benefit from this program, you or a member of your firm must be a domain admin on your client’s domain in Expensify.
+# Become a Domain Admin
+To benefit from this program, you or a member of your firm must be a domain admin on the client’s domain in Expensify:
1. Head to *Settings > Domains*
-2. Click the name of your client's domain
+2. Click the client's domain
+ - If you can click on the domain and access the domain settings, you are a Domain Admin
+ - If you’re not a Domain Admin, your client can add you as one by heading to **Settings > Domains > [Client's Domain] > Domain Admins > Add Admin**.
-If you can click into the domain and access the domain settings, that means you are a domain admin.
+_**Note:** You can view all domain admins under Settings > Domains > [Client's Domain] > Domain Admins._
-Note: You can view all domain admins under *Settings > Domains > [Client's domain name] > Domain Admins*.
+# Connect a deposit-only business bank account
+[Follow these instructions](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account#connect-a-business-deposit-only-account) to connect a deposit-only business bank account.
-If you’re not a domain admin, your client can add you as one by heading to **Settings > Domains > [Client's domain name] > Domain Admins > Add admin**.
-
-# Connect a deposit account
-Next, connect a deposit-only business bank account. Any revenue earned will be deposited directly into that account.
-
-Instructions to connect a deposit-only business bank account are [here](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-USD#how-to-connect-a-business-deposit-only-bank-account).
+Once that's complete, any revenue earned will be deposited directly into that bank account.
{% include faq-begin.md %}
diff --git a/docs/articles/expensify-classic/spending-insights/Insights.md b/docs/articles/expensify-classic/spending-insights/Insights.md
index c5ee218352fd..edd9c2207466 100644
--- a/docs/articles/expensify-classic/spending-insights/Insights.md
+++ b/docs/articles/expensify-classic/spending-insights/Insights.md
@@ -1,12 +1,11 @@
---
title: Custom Reporting and Insights
-description: How to get the most out of the Custom Reporing and Insights
+description: How to get the most out of the Custom Reporting and Insights
redirect_from: articles/other/Insights/
---
-# Overview
-The Insights dashboard allows you to monitor all aspects of company spending across categories, employees, projects, departments, and more. You can see trends in real-time, forecast company budgets, and build unlimited custom reports with help from our trained specialist team.
+The Insights dashboard allows you to monitor all aspects of company spending across categories, employees, projects, departments, and more. You can see trends in real time, forecast company budgets, and build unlimited custom reports with help from our trained specialist team.
![Insights Pie Chart](https://help.expensify.com/assets/images/insights-chart.png){:width="100%"}
## Review your Insights data
@@ -15,7 +14,7 @@ The Insights dashboard allows you to monitor all aspects of company spending acr
2. Select a specific date range (the default view has the current month pre-selected)
3. Use the filter options to select the categories, tags, employees, or any other parameter
4. Make sure that View in the top right corner is set to the pie chart icon
-5. You can view any dataset in more detail by clicking in the “View Raw Data” column
+5. You can view any dataset in more detail by clicking in the **View Raw Data** column
## Export your Insights data
@@ -26,46 +25,47 @@ The Insights dashboard allows you to monitor all aspects of company spending acr
## Create a Custom Export Report for your Expenses
1. Navigate to **Settings > Account > Preferences > scroll down to CSV Export Formats**
-2. Build up a report using these [formulas]((https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates))
+2. Build up a report using these [expense-level formulas](https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates#expense-level)
3. Click the **Custom Export** button on the Insights page and your Account Manager will help get you started on building up your report
## Create a Custom Export Report for your Workspace
1. Navigate to **Settings > Workspaces > Group > [Workspace Name] > Export Formats**
-2. Build up a report using these [formulas](https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates)
-3. If you need any help, click the **Support** button on the top left to contact your Account Manager
+2. Build up a report using these [report-level formulas](https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates#report-level)
+3. If you need any help, click the **Support** button on the top left to contact Concierge or your Account Manager
{% include faq-begin.md %}
-#### Can I share my custom export report?
+## Can I share my custom export report?
If you would like to create a custom export report that can be shared with other workspace admins, you can do so by navigating to the **[Settings > Workspaces > Group > [Workspace Name] > Export Formats** page. Custom export reports created under the **Settings > Account > Preferences** page are only available to the member who created them.
-#### Can I put expenses from different workspaces on the same report?
+## Can I put expenses from different workspaces on the same report?
-Custom export reports created under the Settings > Account > Preferences page can export expenses from multiple workspaces, and custom export formats created under Settings > Workspaces> Group > [Workspace Name] > Export Formats are for expenses reported under that workspace only.
+Custom export reports created under the **Settings > Account > Preferences** page can export expenses from multiple workspaces.
-#### Are there any default export reports available?
+Custom export formats created under **Settings > Workspaces> Group > [Workspace Name] > Export Formats** are for expenses reported under that workspace only.
+
+## Are there any default export reports available?
Yes! We have [seven default reports](https://help.expensify.com/articles/expensify-classic/spending-insights/Default-Export-Templates) available to export directly from the Reports page:
-- **All Data** - Expense Level Export** - the name says it all! This is for the people who want ALL the details from their expense reports. We're talking Tax, Merchant Category Codes, Approvers - you name it, this report's got it!
-- **All Data** - Report Level Export - this is the report for those who don't need to see each individual expense but want to see a line-by-line breakdown at a report level - submitter, total amount, report ID - that kind of stuff
-- **Basic Export** - this is the best way to get a simple breakdown of all your expenses - just the basics
-- **Canadian Multiple Tax Export** - tax, GST, PST...if you need to know tax then this is the export you want!
-- **Category Export** - want to see a breakdown of your expenses by Category? This is the export you
-- **Per Diem Export** - the name says it all
-- **Tag Export** - much like the Category Export, but for Tags
+- **All Data — Expense Level Export**: Use this to view all of the details from their expense reports (tax, merchant category codes, approvals, etc.)
+- **All Data—Report Level Export**: This report is great if you don't need to see each individual expense but want to see a line-by-line breakdown at a report level (submitter, total amount, report ID, etc.)
+- **Basic Export**: The best way to get a simple breakdown of all your expenses
+- **Canadian Multiple Tax Export**: To gain a better understanding of the various Canadian taxes tied to expenses (GST, PST, etc.)
+- **Category Export**: Use this to see a breakdown of your expenses by Category
+- **Per Diem Export**: Use to export your Per Diem details
+- **Tag Export**: Similar to the Category Export, but for Tags
*These reports will be emailed directly to your email address rather than automatically downloaded.*
-#### How many expenses can I export in one report?
+## How many expenses can I export in one report?
The custom export reports are best for small-to-medium chunks of data. If you want to export large amounts of data, we recommend you use a [default export report](https://help.expensify.com/articles/expensify-classic/spending-insights/Default-Export-Templates) that you can run from the Reports page.
-#### What other kinds of export reports can my Account Manager help me create?
+## What other kinds of export reports can my Account Manager help me create?
We’ve built a huge variety of custom reports for customers, so make sure to reach out to your Account Manager for more details. Some examples of custom reports we’ve built for customers before are:
-
- Accrual Report
- Aged Approval Reports
- Attendee Reporting
diff --git a/docs/articles/expensify-classic/workspaces/Create-tags.md b/docs/articles/expensify-classic/workspaces/Create-tags.md
index ad3f51bc8c58..0743b53ff5fa 100644
--- a/docs/articles/expensify-classic/workspaces/Create-tags.md
+++ b/docs/articles/expensify-classic/workspaces/Create-tags.md
@@ -1,23 +1,20 @@
---
-title: Create tags
+title: Create Tags
description: Code expenses by creating tags
---
You can tag expenses for a specific department, project, location, cost center, customer, etc. You can also use different tags for each workspace to create customized coding for different employees.
-You can use single tags or multi-level tags:
-- **Single Tags**: Employees click one dropdown to select one tag. Single tags are helpful if employees need to select only one tag from a list, for example their department.
-- **Multi-level Tags**: Employees click multiple dropdowns to select more than one tag. You can also create dependent tags that only appear if another tag has already been selected. Multi-tags are helpful if you have multiple tags, for example projects, locations, cost centers, etc., for employees to select, or if you have dependent tags. For example, if an employee selects a specific department, another tag can appear where they have to select their project.
+**There are two options for tag configuration in Expensify:**
+- **Single Tags**: Employees click one dropdown to select one tag. Single tags are helpful if employees need to select only one tag from a list, for example, their department.
+- **Multi-level Tags**: Employees click multiple dropdowns to select more than one tag. You can also create dependent tags that only appear if another tag has already been selected. Multi-tags are helpful if you have multiple tags, for example, projects, locations, cost centers, etc., for employees to select or if you have dependent tags. For example, if an employee selects a specific department, another tag can appear where they have to select their project.
-To add your tags, you can either import them for an accounting system or spreadsheet, or add them manually.
+# Individual Tags
-# Single tags
-
-## Import a spreadsheet
-
-You can add a list of single tags by importing them in a .csv, .txt, .xls, or .xlsx spreadsheet.
+## Import via spreadsheet
+You can add a list of single tags by importing them via .csv, .txt, .xls, or .xlsx spreadsheet:
1. Hover over Settings, then click **Workspaces**.
2. Click the **Group** tab on the left.
3. Click the desired workspace name.
@@ -30,31 +27,35 @@ Each time you upload a list of tags, it will override your previous list. To avo
{% include end-info.html %}
## Manually add individual tags
-
+You can also add single tags by adding them manually:
1. Hover over Settings, then click **Workspaces**.
2. Click the **Group** tab on the left.
3. Click the desired workspace name.
4. Click the **Tags** tab on the left.
5. Enter a tag name into the field and click **Add**.
-# Multi-level tags
+# Multi-level Tags
+
+## Automatic import via accounting integration
+
+When you first connect your accounting integration (for example, QuickBooks Online, QuickBooks Desktop, Sage Intacct, Xero, or NetSuite), you’ll configure classes, customers, projects, department locations, etc., that automatically import into Expensify as tags.
-## Automatic import with accounting integration
+To update your tags in Expensify, you must first update the tag in your accounting system:
+1. Hover over Settings, then click **Workspaces**.
+2. Click the **Group** tab on the left.
+3. Click the desired workspace name.
+4. Click the **Connections** tab on the left.
+5. Click **Sync Now**.
-When you first connect your accounting integration (for example, QuickBooks Online, QuickBooks Desktop, Sage Intacct, Xero, or NetSuite), you’ll configure classes, customers, projects, departments locations, etc. that automatically import into Expensify as tags.
+Once the tags are updated in your accounting integration, the changes will automatically reflect in Expensify after the connection sync is run.
-1. To update your tags in Expensify, you must first update the tag in your accounting system. Then in Expensify,
-2. Hover over Settings, then click **Workspaces**.
-3. Click the **Group** tab on the left.
-4. Click the desired workspace name.
-5. Click the **Connections** tab on the left.
-6. Click **Sync Now**.
+## Import via spreadsheet
-## Import a spreadsheet
+You can add mutli-level tags by importing them in a .csv, .txt, .xls, or .xlsx spreadsheet.
-You can add a list of single tags by importing them in a .csv, .txt, .xls, or .xlsx spreadsheet.
+First, determine whether you will use independent (a separate tag for department and project) or dependent tags (the project tags populate different options based on the department selected) and whether you will capture general ledger (GL) codes.
-1. Determine whether you will use independent (a separate tag for department and project) or dependent tags (the project tags populate different options based on the department selected), and whether you will capture general ledge (GL) codes. Then use one of the following templates to build your tags list:
+Then use one of the following templates to build your tags list:
- [Dependent tags with GL codes]({{site.url}}/assets/Files/Dependent+with+GL+codes+format.csv)
- [Dependent tags without GL codes]({{site.url}}/assets/Files/Dependent+without+GL+codes+format.csv)
- [Independent tags with GL codes]({{site.url}}/assets/Files/Independent+with+GL+codes+format.csv)
@@ -64,21 +65,22 @@ You can add a list of single tags by importing them in a .csv, .txt, .xls, or .x
If you have more than 50,000 tags, divide them into two separate files.
{% include end-info.html %}
-2. Hover over Settings, then click **Workspaces**.
-3. Click the **Group** tab on the left.
-4. Click the desired workspace name.
-5. Click the **Tags** tab on the left.
-6. Enable the “Use multiple levels of tags” option.
-7. Click **Import from Spreadsheet**.
-8. Select the applicable checkboxes and click **Upload Tags**.
+To import multi-level tags:
+1. Hover over Settings, then click **Workspaces**.
+2. Click the **Group** tab on the left.
+3. Click the desired workspace name.
+4. Click the **Tags** tab on the left.
+5. Enable the “Use multiple levels of tags” option.
+6. Click **Import from Spreadsheet**.
+7. Select the applicable checkboxes and click **Upload Tags**.
{% include info.html %}
Each time you upload a list of tags, it will override your previous list. To avoid losing tags, update your current spreadsheet and re-import it into Expensify.
{% include end-info.html %}
-# FAQs
+# FAQ
-**Why can’t I see a "Do you want to use multiple level tags" option on my workspace.**
+## Why can’t I see a multi-level tags option on my workspace?
If you are connected to an accounting integration, you will not see this feature. You will need to add those tags in your integration first, then sync the connection.
diff --git a/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md b/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md
index 56e456eb1256..8fffec75e744 100644
--- a/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md
+++ b/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md
@@ -4,25 +4,26 @@ description: Get the new Expensify Visa® Commercial Card
---
-If your company is already using Expensify Cards, you can upgrade your cards for free to the new Expensify Visa® Commercial Card to get even more tools to manage employee spending, including:
-- Unlimited virtual cards
+When you upgrade the Expensify Cards to the new program, you'll have access to even more tools to manage employee spending, including:
+- Unlimited [virtual cards](https://use.expensify.com/unlimited-virtual-cards)
- Controlled spending amounts on virtual cards to manage subscriptions
- Tighter controls for managing spend across employees and merchants
-- Fixed or monthly spend limits for each card
+- Fixed or monthly spending limits for each card
- Unique naming for each virtual card for simplified expense categorization
-# Upgrade your company’s Expensify Cards
-
{% include info.html %}
-This process must be completed by a Domain Admin. Although the process is available for all Domain Admins, only one admin needs to complete these steps.
+The Expensify Card upgrade must be completed by November 1, 2024.
+{% include end-info.html %}
-Before completing this process, you’ll want to:
+# Upgrade your company’s Expensify Card program
+This process must be completed by a Domain Admin. Any domain Admin can complete the upgrade, but only one admin needs to complete these steps.
-- Have your employees update their address if needed so that they receive their new Expensify Card in the mail before completing the steps below.
-- Ensure that existing cardholders have a limit greater than $0 if you want them to receive a new Expensify Card. If their limit is $0, increase the limit.
-{% include end-info.html %}
+**Before updating the card program:**
+- Make sure your employees' address is up-to-date in their Expensify account
+- Confirm the employees who should be receiving a new Expensify Card have a card limit set that's greater than $0
-1. On your Home page, click the task titled “Upgrade to the new and improved Expensify Card.”
+## Steps to upgrade the Expensify Cards
+1. On your Home page, click the task titled _Upgrade to the new and improved Expensify Card._
2. Review and agree to the Terms of Service.
3. Click **Get the new card**. All existing cardholders with a limit greater than $0 will be automatically mailed a new physical card to the address they have on file. Virtual cards will be automatically issued and available for immediate use.
4. If you have Positive Pay enabled for your settlement account, contact your bank as soon as possible to whitelist the new ACH ID: 2270239450.
@@ -36,19 +37,19 @@ Cards won’t be issued to any employees who don’t currently have them. In thi
{% include faq-begin.md %}
-**Why don’t I see the task to agree to new terms on my Home page?**
+## Why don’t I see the task to agree to new terms on my Home page?
There are a few reasons why you might not see the task on your Home page:
- You may not be a Domain Admin
- Another domain admin has already accepted the terms
- The task may be hidden. To find hidden tasks, scroll to the bottom of the Home page and click **Show Hidden Tasks** to see all of your available tasks.
-**Will this affect the continuous reconciliation process?**
+## Will this affect the continuous reconciliation process?
No. During the transition period, you may have some employees with old cards and some with new cards, so you’ll have two different debits (settlements) made to your settlement account for each settlement period. Once all spending has transitioned to the new cards, you’ll only see one debit/settlement.
-**Do I have to upgrade to the new Expensify Visa® Commercial Card?**
+## Do I have to upgrade to the new Expensify Visa® Commercial Card?
-Yes. We’ll provide a deadline soon. But don’t worry—you’ll have plenty of time to upgrade.
+Yes, the Expensify Cards will not work on the old program. This must be completed by November 1, 2024.
{% include faq-end.md %}
diff --git a/docs/assets/images/ExpensifyHelp-AttendeeTracking-1.png b/docs/assets/images/ExpensifyHelp-AttendeeTracking-1.png
new file mode 100644
index 000000000000..e3c08b9133b8
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-AttendeeTracking-1.png differ
diff --git a/docs/assets/images/quickbooks-desktop-access-rights.png b/docs/assets/images/quickbooks-desktop-access-rights.png
new file mode 100644
index 000000000000..bcdd35b8c827
Binary files /dev/null and b/docs/assets/images/quickbooks-desktop-access-rights.png differ
diff --git a/docs/assets/images/quickbooks-desktop-advanced-settings.png b/docs/assets/images/quickbooks-desktop-advanced-settings.png
new file mode 100644
index 000000000000..181380ed7674
Binary files /dev/null and b/docs/assets/images/quickbooks-desktop-advanced-settings.png differ
diff --git a/docs/assets/images/quickbooks-desktop-coding-settings.png b/docs/assets/images/quickbooks-desktop-coding-settings.png
new file mode 100644
index 000000000000..7b9fc8086c9f
Binary files /dev/null and b/docs/assets/images/quickbooks-desktop-coding-settings.png differ
diff --git a/docs/assets/images/quickbooks-desktop-company-preferences.png b/docs/assets/images/quickbooks-desktop-company-preferences.png
new file mode 100644
index 000000000000..31f2be54bfb8
Binary files /dev/null and b/docs/assets/images/quickbooks-desktop-company-preferences.png differ
diff --git a/docs/assets/images/quickbooks-desktop-export-settings.png b/docs/assets/images/quickbooks-desktop-export-settings.png
new file mode 100644
index 000000000000..3ff190bc2d60
Binary files /dev/null and b/docs/assets/images/quickbooks-desktop-export-settings.png differ
diff --git a/docs/assets/images/quickbooks-desktop-exported-report-comments.png b/docs/assets/images/quickbooks-desktop-exported-report-comments.png
new file mode 100644
index 000000000000..2b0d2939e4b0
Binary files /dev/null and b/docs/assets/images/quickbooks-desktop-exported-report-comments.png differ
diff --git a/docs/assets/images/quickbooks-desktop-web-connector.png b/docs/assets/images/quickbooks-desktop-web-connector.png
new file mode 100644
index 000000000000..b2086420edd8
Binary files /dev/null and b/docs/assets/images/quickbooks-desktop-web-connector.png differ
diff --git a/docs/redirects.csv b/docs/redirects.csv
index 37e56f31c78d..90baeff59260 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -584,3 +584,6 @@ https://community.expensify.com/discussion/6699/faq-troubleshooting-known-bank-s
https://community.expensify.com/discussion/4730/faq-expenses-are-exporting-to-the-wrong-accounts-whys-that,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings
https://community.expensify.com/discussion/9000/how-to-integrate-with-deel,https://help.expensify.com/articles/expensify-classic/connections/Deel
https://community.expensify.com/categories/expensify-classroom,https://use.expensify.com
+https://help.expensify.com/articles/expensify-classic/articles/expensify-classic/expenses/Send-Receive-for-Invoices,https://help.expensify.com/articles/expensify-classic/articles/expensify-classic/expenses/Send-and-Receive-Payment-for-Invoices.md
+https://help.expensify.com/articles/expensify-classic/articles/expensify-classic/expenses/Bulk-Upload-Multiple-Invoices,https://help.expensify.com/articles/expensify-classic/articles/expensify-classic/expenses/Add-Invoices-in-Bulk
+https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills
diff --git a/ios/NewApp_AdHoc.mobileprovision.gpg b/ios/NewApp_AdHoc.mobileprovision.gpg
index d2f181f6b7f4..76307ce1b460 100644
Binary files a/ios/NewApp_AdHoc.mobileprovision.gpg and b/ios/NewApp_AdHoc.mobileprovision.gpg differ
diff --git a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg
index c0afa40ecb29..6437d0a3f096 100644
Binary files a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg and b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg differ
diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj
index 1a29a275b956..96baba0d4e87 100644
--- a/ios/NewExpensify.xcodeproj/project.pbxproj
+++ b/ios/NewExpensify.xcodeproj/project.pbxproj
@@ -43,7 +43,7 @@
D27CE6B77196EF3EF450EEAC /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 0D3F9E814828D91464DF9D35 /* PrivacyInfo.xcprivacy */; };
DD79042B2792E76D004484B4 /* RCTBootSplash.mm in Sources */ = {isa = PBXBuildFile; fileRef = DD79042A2792E76D004484B4 /* RCTBootSplash.mm */; };
DDCB2E57F334C143AC462B43 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D20D83B0E39BA6D21761E72 /* ExpoModulesProvider.swift */; };
- E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */ = {isa = PBXBuildFile; };
+ E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */ = {isa = PBXBuildFile; };
E9DF872D2525201700607FDC /* AirshipConfig.plist in Resources */ = {isa = PBXBuildFile; fileRef = E9DF872C2525201700607FDC /* AirshipConfig.plist */; };
ED222ED90E074A5481A854FA /* ExpensifyNeue-BoldItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = 8B28D84EF339436DBD42A203 /* ExpensifyNeue-BoldItalic.otf */; };
F0C450EA2705020500FD2970 /* colors.json in Resources */ = {isa = PBXBuildFile; fileRef = F0C450E92705020500FD2970 /* colors.json */; };
@@ -176,8 +176,8 @@
buildActionMask = 2147483647;
files = (
383643682B6D4AE2005BB9AE /* DeviceCheck.framework in Frameworks */,
- E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */,
- E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */,
+ E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */,
+ E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */,
8744C5400E24E379441C04A4 /* libPods-NewExpensify.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -1848,6 +1848,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CXX = "";
+ DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386;
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 978cc5b3c2ab..c4bfdbb064d5 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 9.0.48
+ 9.0.49CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 9.0.48.0
+ 9.0.49.2FullStoryOrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index e6a14e30fd04..618f368f5d05 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 9.0.48
+ 9.0.49CFBundleSignature????CFBundleVersion
- 9.0.48.0
+ 9.0.49.2
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index d8f422542d0f..241f2cc0896e 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName$(PRODUCT_NAME)CFBundleShortVersionString
- 9.0.48
+ 9.0.49CFBundleVersion
- 9.0.48.0
+ 9.0.49.2NSExtensionNSExtensionPointIdentifier
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 68266651661a..1242ab7a5a39 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -1606,16 +1606,29 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- - react-native-config (1.5.0):
- - react-native-config/App (= 1.5.0)
- - react-native-config/App (1.5.0):
- - RCT-Folly
+ - react-native-config (1.5.3):
+ - react-native-config/App (= 1.5.3)
+ - react-native-config/App (1.5.3):
+ - DoubleConversion
+ - glog
+ - hermes-engine
+ - RCT-Folly (= 2024.01.01.00)
- RCTRequired
- RCTTypeSafety
- - React
- - React-Codegen
+ - React-Core
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-NativeModulesApple
- React-RCTFabric
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
+ - Yoga
- react-native-document-picker (9.3.1):
- DoubleConversion
- glog
@@ -1687,7 +1700,7 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- - react-native-keyboard-controller (1.14.0):
+ - react-native-keyboard-controller (1.14.1):
- DoubleConversion
- glog
- hermes-engine
@@ -3173,12 +3186,12 @@ SPEC CHECKSUMS:
react-native-airship: e10f6823d8da49bbcb2db4bdb16ff954188afccc
react-native-blob-util: 221c61c98ae507b758472ac4d2d489119d1a6c44
react-native-cameraroll: 478a0c1fcdd39f08f6ac272b7ed06e92b2c7c129
- react-native-config: 5ce986133b07fc258828b20b9506de0e683efc1c
+ react-native-config: 742a9e0a378a78d0eaff1fb3477d8c0ae222eb51
react-native-document-picker: e9d83c149bdd72dc01cf8dcb8df0389c6bd5fddb
react-native-geolocation: b9bd12beaf0ebca61a01514517ca8455bd26fa06
react-native-image-picker: f8a13ff106bcc7eb00c71ce11fdc36aac2a44440
react-native-key-command: aae312752fcdfaa2240be9a015fc41ce54087546
- react-native-keyboard-controller: 17d5830f2bd6c6cad44682eb2cc13f9078eff985
+ react-native-keyboard-controller: 902c07f41a415b632583b384427a71770a8b02a3
react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d
react-native-netinfo: fb5112b1fa754975485884ae85a3fb6a684f49d5
react-native-pager-view: 94195f1bf32e7f78359fa20057c97e632364a08b
diff --git a/package-lock.json b/package-lock.json
index e08766b5e490..36f2782380c4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.0.48-0",
+ "version": "9.0.49-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.48-0",
+ "version": "9.0.49-2",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -78,7 +78,7 @@
"react-native-android-location-enabler": "^2.0.1",
"react-native-blob-util": "0.19.4",
"react-native-collapsible": "^1.6.2",
- "react-native-config": "1.5.0",
+ "react-native-config": "1.5.3",
"react-native-dev-menu": "^4.1.1",
"react-native-device-info": "10.3.1",
"react-native-document-picker": "^9.3.1",
@@ -90,7 +90,7 @@
"react-native-image-picker": "^7.0.3",
"react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9",
"react-native-key-command": "^1.0.8",
- "react-native-keyboard-controller": "1.14.0",
+ "react-native-keyboard-controller": "1.14.1",
"react-native-launch-arguments": "^4.0.2",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
@@ -101,7 +101,7 @@
"react-native-permissions": "^3.10.0",
"react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf",
"react-native-plaid-link-sdk": "11.11.0",
- "react-native-qrcode-svg": "git+https://github.com/Expensify/react-native-qrcode-svg-old",
+ "react-native-qrcode-svg": "6.3.11",
"react-native-quick-sqlite": "git+https://github.com/margelo/react-native-quick-sqlite#99f34ebefa91698945f3ed26622e002bd79489e0",
"react-native-reanimated": "3.15.1",
"react-native-release-profiler": "^0.2.1",
@@ -34469,11 +34469,10 @@
}
},
"node_modules/react-native-config": {
- "version": "1.5.0",
- "license": "MIT",
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/react-native-config/-/react-native-config-1.5.3.tgz",
+ "integrity": "sha512-3D05Abgk5DfDw9w258EzXvX5AkU7eqj3u9H0H0L4gUga4nYg/zuupcrpGbpF4QeXBcJ84jjs6g8JaEP6VBT7Pg==",
"peerDependencies": {
- "react": "*",
- "react-native": "*",
"react-native-windows": ">=0.61"
},
"peerDependenciesMeta": {
@@ -34622,9 +34621,9 @@
"license": "MIT"
},
"node_modules/react-native-keyboard-controller": {
- "version": "1.14.0",
- "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.14.0.tgz",
- "integrity": "sha512-JW9k2fehFXOpvLWh1YcgyubLodg/HPi6bR11sCZB/BOawf1tnbGnqk967B8XkxDOKHH6mg+z82quCvv8ALh1rg==",
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.14.1.tgz",
+ "integrity": "sha512-HUrZTaaDPxm94EVXlguwJB2gm6mc+VRTTzR66luFGZJZnL2SJoxN+dwsNW3twkwUVDrCPPA3U21q9YWUKVmwvg==",
"peerDependencies": {
"react": "*",
"react-native": "*",
@@ -35509,11 +35508,13 @@
}
},
"node_modules/react-native-qrcode-svg": {
- "version": "6.3.0",
- "resolved": "git+ssh://git@github.com/Expensify/react-native-qrcode-svg-old.git#295f87d45c0f10d9b50838ad28fa70e47d054c3b",
+ "version": "6.3.11",
+ "resolved": "https://registry.npmjs.org/react-native-qrcode-svg/-/react-native-qrcode-svg-6.3.11.tgz",
+ "integrity": "sha512-bhjh4KT8NTQjJyu/tGaplR53OIqtvUJcWZ713H++GLKRpldNDyywwLVW+HdlGZ3N7jk3TxCchQMDMzndLlV4sA==",
"dependencies": {
"prop-types": "^15.8.0",
- "qrcode": "^1.5.1"
+ "qrcode": "^1.5.1",
+ "text-encoding": "^0.7.0"
},
"peerDependencies": {
"react": "*",
@@ -39398,6 +39399,12 @@
"node": ">=8"
}
},
+ "node_modules/text-encoding": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz",
+ "integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==",
+ "deprecated": "no longer maintained"
+ },
"node_modules/text-segmentation": {
"version": "1.0.3",
"license": "MIT",
diff --git a/package.json b/package.json
index b944cd941493..0e939214ad0f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.48-0",
+ "version": "9.0.49-2",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -133,7 +133,7 @@
"react-native-android-location-enabler": "^2.0.1",
"react-native-blob-util": "0.19.4",
"react-native-collapsible": "^1.6.2",
- "react-native-config": "1.5.0",
+ "react-native-config": "1.5.3",
"react-native-dev-menu": "^4.1.1",
"react-native-device-info": "10.3.1",
"react-native-document-picker": "^9.3.1",
@@ -145,7 +145,7 @@
"react-native-image-picker": "^7.0.3",
"react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9",
"react-native-key-command": "^1.0.8",
- "react-native-keyboard-controller": "1.14.0",
+ "react-native-keyboard-controller": "1.14.1",
"react-native-launch-arguments": "^4.0.2",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
@@ -156,7 +156,7 @@
"react-native-permissions": "^3.10.0",
"react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf",
"react-native-plaid-link-sdk": "11.11.0",
- "react-native-qrcode-svg": "git+https://github.com/Expensify/react-native-qrcode-svg-old",
+ "react-native-qrcode-svg": "6.3.11",
"react-native-quick-sqlite": "git+https://github.com/margelo/react-native-quick-sqlite#99f34ebefa91698945f3ed26622e002bd79489e0",
"react-native-reanimated": "3.15.1",
"react-native-release-profiler": "^0.2.1",
diff --git a/patches/react-native-config+1.5.0.patch b/patches/react-native-config+1.5.3.patch
similarity index 71%
rename from patches/react-native-config+1.5.0.patch
rename to patches/react-native-config+1.5.3.patch
index 4b5a597de4dd..d2c093705032 100644
--- a/patches/react-native-config+1.5.0.patch
+++ b/patches/react-native-config+1.5.3.patch
@@ -1,68 +1,11 @@
-diff --git a/node_modules/react-native-config/README.md b/node_modules/react-native-config/README.md
-index 8424402..ca29e39 100644
---- a/node_modules/react-native-config/README.md
-+++ b/node_modules/react-native-config/README.md
-@@ -78,13 +78,13 @@ if cocoapods are used in the project then pod has to be installed as well:
- **MainApplication.java**
-
- ```diff
-- + import com.lugg.ReactNativeConfig.ReactNativeConfigPackage;
-+ + import com.lugg.RNCConfig.RNCConfigPackage;
-
- @Override
- protected List getPackages() {
- return Arrays.asList(
- new MainReactPackage()
-- + new ReactNativeConfigPackage()
-+ + new RNCConfigPackage()
- );
- }
- ```
diff --git a/node_modules/react-native-config/android/build.gradle b/node_modules/react-native-config/android/build.gradle
-index c8f7fd4..86b3e1a 100644
+index d3bdb07..1629423 100644
--- a/node_modules/react-native-config/android/build.gradle
+++ b/node_modules/react-native-config/android/build.gradle
-@@ -15,6 +15,55 @@ def safeExtGet(prop, fallback) {
+@@ -15,6 +15,18 @@ def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
-+def resolveReactNativeDirectory() {
-+ def reactNativeLocation = safeExtGet("REACT_NATIVE_NODE_MODULES_DIR", null)
-+ if (reactNativeLocation != null) {
-+ return file(reactNativeLocation)
-+ }
-+
-+ // monorepo workaround
-+ // react-native can be hoisted or in project's own node_modules
-+ def reactNativeFromProjectNodeModules = file("${rootProject.projectDir}/../node_modules/react-native")
-+ if (reactNativeFromProjectNodeModules.exists()) {
-+ return reactNativeFromProjectNodeModules
-+ }
-+
-+ def reactNativeFromNodeModulesWithRNCConfig = file("${projectDir}/../../react-native")
-+ if (reactNativeFromNodeModulesWithRNCConfig.exists()) {
-+ return reactNativeFromNodeModulesWithRNCConfig
-+ }
-+
-+ throw new Exception(
-+ "[react-native-config] Unable to resolve react-native location in " +
-+ "node_modules. You should add project extension property (in app/build.gradle) " +
-+ "`REACT_NATIVE_NODE_MODULES_DIR` with path to react-native."
-+ )
-+}
-+
-+def getReactNativeMinorVersion() {
-+ def REACT_NATIVE_DIR = resolveReactNativeDirectory()
-+
-+ def reactProperties = new Properties()
-+ file("$REACT_NATIVE_DIR/ReactAndroid/gradle.properties").withInputStream { reactProperties.load(it) }
-+
-+ def REACT_NATIVE_VERSION = reactProperties.getProperty("VERSION_NAME")
-+ def REACT_NATIVE_MINOR_VERSION = REACT_NATIVE_VERSION.startsWith("0.0.0-") ? 1000 : REACT_NATIVE_VERSION.split("\\.")[1].toInteger()
-+
-+ return REACT_NATIVE_MINOR_VERSION
-+}
-+
+def isNewArchitectureEnabled() {
+ // To opt-in for the New Architecture, you can either:
+ // - Set `newArchEnabled` to true inside the `gradle.properties` file
@@ -75,10 +18,10 @@ index c8f7fd4..86b3e1a 100644
+ apply plugin: "com.facebook.react"
+}
+
- android {
- compileSdkVersion rootProject.ext.compileSdkVersion
-
-@@ -23,10 +72,23 @@ android {
+ def supportsNamespace() {
+ def parsed = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.');
+ def major = parsed[0].toInteger();
+@@ -44,10 +56,23 @@ android {
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
@@ -102,44 +45,45 @@ index c8f7fd4..86b3e1a 100644
}
repositories {
-@@ -34,5 +96,9 @@ repositories {
+@@ -55,5 +80,5 @@ repositories {
}
dependencies {
- implementation "com.facebook.react:react-native:${safeExtGet("reactNative", "+")}" // from node_modules
-+ if (isNewArchitectureEnabled() && getReactNativeMinorVersion() < 71) {
-+ implementation project(":ReactAndroid")
-+ } else {
-+ implementation 'com.facebook.react:react-native:+'
-+ }
++ implementation 'com.facebook.react:react-native:+'
}
diff --git a/node_modules/react-native-config/android/src/main/java/com/lugg/RNCConfig/RNCConfigModule.java b/node_modules/react-native-config/android/src/main/java/com/lugg/RNCConfig/RNCConfigModule.java
-index 0b52515..bef2834 100644
+index 55b853b..2784795 100644
--- a/node_modules/react-native-config/android/src/main/java/com/lugg/RNCConfig/RNCConfigModule.java
+++ b/node_modules/react-native-config/android/src/main/java/com/lugg/RNCConfig/RNCConfigModule.java
-@@ -13,20 +13,32 @@ import java.lang.reflect.Field;
+@@ -11,41 +11,58 @@ import java.lang.reflect.Field;
import java.util.Map;
import java.util.HashMap;
-public class RNCConfigModule extends ReactContextBaseJavaModule {
+public class RNCConfigModule extends NativeConfigModuleSpec {
+ public static final String NAME = "RNCConfigModule";
-+
- public RNCConfigModule(ReactApplicationContext reactContext) {
- super(reactContext);
- }
- @Override
- public String getName() {
-- return "RNCConfigModule";
-+ return NAME;
- }
+- public RNCConfigModule(ReactApplicationContext reactContext) {
+- super(reactContext);
+- }
++ public RNCConfigModule(ReactApplicationContext reactContext) {
++ super(reactContext);
++ }
- @Override
-- public Map getConstants() {
+- @Override
+- public String getName() {
+- return "RNCConfigModule";
+- }
++ @Override
++ public String getName() {
++ return NAME;
++ }
++
++ @Override
+ public Map getTypedExportedConstants() {
- final Map constants = new HashMap<>();
-
++ final Map constants = new HashMap<>();
++
+ // Codegen ensures that the constants defined in the module spec and in the native module implementation
+ // are consistent, which is tad problematic in this case, as the constants are dependant on the `.env`
+ // file. The simple workaround is to define a `constants` object that will contain actual constants.
@@ -149,33 +93,64 @@ index 0b52515..bef2834 100644
+ // we export { constants: { constant1: "value1", constant2: "value2" } }
+ // because of type safety on the new arch
+ final Map realConstants = new HashMap<>();
-+
- try {
- Context context = getReactApplicationContext();
- int resId = context.getResources().getIdentifier("build_config_package", "string", context.getPackageName());
-@@ -40,7 +52,7 @@ public class RNCConfigModule extends ReactContextBaseJavaModule {
- Field[] fields = clazz.getDeclaredFields();
- for(Field f: fields) {
+
+- @Override
+- public Map getConstants() {
+- final Map constants = new HashMap<>();
++ try {
++ Context context = getReactApplicationContext();
++ int resId = context.getResources().getIdentifier("build_config_package", "string", context.getPackageName());
++ String className;
++ try {
++ className = context.getString(resId);
++ } catch (Resources.NotFoundException e) {
++ className = getReactApplicationContext().getApplicationContext().getPackageName();
++ }
++ Class clazz = Class.forName(className + ".BuildConfig");
++ Field[] fields = clazz.getDeclaredFields();
++ for(Field f: fields) {
try {
-- constants.put(f.getName(), f.get(null));
+- Context context = getReactApplicationContext();
+- int resId = context.getResources().getIdentifier("build_config_package", "string", context.getPackageName());
+- String className;
+- try {
+- className = context.getString(resId);
+- } catch (Resources.NotFoundException e) {
+- className = getReactApplicationContext().getApplicationContext().getPackageName();
+- }
+- Class clazz = Class.forName(className + ".BuildConfig");
+- Field[] fields = clazz.getDeclaredFields();
+- for (Field f : fields) {
+- try {
+- constants.put(f.getName(), f.get(null));
+- } catch (IllegalAccessException e) {
+- Log.d("ReactNative", "ReactConfig: Could not access BuildConfig field " + f.getName());
+- }
+- }
+- } catch (ClassNotFoundException e) {
+- Log.d("ReactNative", "ReactConfig: Could not find BuildConfig class");
+ realConstants.put(f.getName(), f.get(null));
}
- catch (IllegalAccessException e) {
- Log.d("ReactNative", "ReactConfig: Could not access BuildConfig field " + f.getName());
-@@ -51,6 +63,8 @@ public class RNCConfigModule extends ReactContextBaseJavaModule {
- Log.d("ReactNative", "ReactConfig: Could not find BuildConfig class");
+- return constants;
++ catch (IllegalAccessException e) {
++ Log.d("ReactNative", "ReactConfig: Could not access BuildConfig field " + f.getName());
++ }
++ }
++ }
++ catch (ClassNotFoundException e) {
++ Log.d("ReactNative", "ReactConfig: Could not find BuildConfig class");
}
-
++
+ constants.put("constants", realConstants);
+
- return constants;
- }
++ return constants;
++ }
}
diff --git a/node_modules/react-native-config/android/src/main/java/com/lugg/RNCConfig/RNCConfigPackage.java b/node_modules/react-native-config/android/src/main/java/com/lugg/RNCConfig/RNCConfigPackage.java
-index 9251c09..2edd797 100644
+index 599a81a..2edd797 100644
--- a/node_modules/react-native-config/android/src/main/java/com/lugg/RNCConfig/RNCConfigPackage.java
+++ b/node_modules/react-native-config/android/src/main/java/com/lugg/RNCConfig/RNCConfigPackage.java
-@@ -1,29 +1,42 @@
+@@ -1,27 +1,42 @@
package com.lugg.RNCConfig;
-import com.facebook.react.ReactPackage;
@@ -184,26 +159,24 @@ index 9251c09..2edd797 100644
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
-import com.facebook.react.uimanager.ViewManager;
-+import com.facebook.react.module.model.ReactModuleInfo;
-+import com.facebook.react.module.model.ReactModuleInfoProvider;
-
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
++import com.facebook.react.module.model.ReactModuleInfo;
++import com.facebook.react.module.model.ReactModuleInfoProvider;
+
+-public class RNCConfigPackage implements ReactPackage {
+import java.util.HashMap;
+import java.util.Map;
--public class RNCConfigPackage implements ReactPackage {
- @Override
- public List createNativeModules(ReactApplicationContext reactContext) {
-- return Arrays.asList(
-- new RNCConfigModule(reactContext)
-- );
+- return Arrays.asList(new RNCConfigModule(reactContext));
- }
+public class RNCConfigPackage extends TurboReactPackage {
-- public List> createJSModules() {
-- return Collections.emptyList();
+- public List> createJSModules() {
+- return Collections.emptyList();
+ @Override
+ public NativeModule getModule(String name, ReactApplicationContext reactContext) {
+ if (name.equals(RNCConfigModule.NAME)) {
@@ -336,31 +309,29 @@ index 70866c4..a8f3624 100644
-export const Config = NativeModules.RNCConfigModule || {}
export default Config;
-diff --git a/node_modules/react-native-config/ios/ReactNativeConfig/GeneratedDotEnv.m b/node_modules/react-native-config/ios/ReactNativeConfig/GeneratedDotEnv.m
-index 04a2f3d..59df625 100644
---- a/node_modules/react-native-config/ios/ReactNativeConfig/GeneratedDotEnv.m
-+++ b/node_modules/react-native-config/ios/ReactNativeConfig/GeneratedDotEnv.m
-@@ -1 +1 @@
-- #define DOT_ENV @{ };
-+ #define DOT_ENV @{ @"ENV":@"dev",@"API_URL":@"http://localhost" };
diff --git a/node_modules/react-native-config/ios/ReactNativeConfig/RNCConfigModule.h b/node_modules/react-native-config/ios/ReactNativeConfig/RNCConfigModule.h
-index 755d103..5341aca 100644
+index 755d103..4e4c564 100644
--- a/node_modules/react-native-config/ios/ReactNativeConfig/RNCConfigModule.h
+++ b/node_modules/react-native-config/ios/ReactNativeConfig/RNCConfigModule.h
-@@ -1,3 +1,9 @@
+@@ -1,12 +1,15 @@
+-#if __has_include()
+-#import
+-#elif __has_include("React/RCTBridgeModule.h")
+-#import "React/RCTBridgeModule.h"
+#ifdef RCT_NEW_ARCH_ENABLED
+#import "RNCConfigSpec.h"
-+
-+@interface RNCConfigModule : NSObject
-+#else
-+
- #if __has_include()
- #import
- #elif __has_include("React/RCTBridgeModule.h")
-@@ -7,6 +13,7 @@
- #endif
+ #else
+-#import "RCTBridgeModule.h"
+-#endif
++#import
++#endif // RCT_NEW_ARCH_ENABLED
- @interface RNCConfigModule : NSObject
+-@interface RNCConfigModule : NSObject
++@interface RNCConfigModule : NSObject
++#ifdef RCT_NEW_ARCH_ENABLED
++
++#else
++
+#endif // RCT_NEW_ARCH_ENABLED
+ (NSDictionary *)env;
@@ -440,10 +411,10 @@ index 0000000..1cacb65
+
+@end
diff --git a/node_modules/react-native-config/package.json b/node_modules/react-native-config/package.json
-index b4d1fba..0a018a7 100644
+index f758725..f338b41 100644
--- a/node_modules/react-native-config/package.json
+++ b/node_modules/react-native-config/package.json
-@@ -26,6 +26,7 @@
+@@ -27,6 +27,7 @@
"android/",
"ios/",
"windows/",
@@ -451,8 +422,8 @@ index b4d1fba..0a018a7 100644
"index.js",
"index.d.ts",
"react-native-config.podspec",
-@@ -38,11 +39,21 @@
- "semantic-release": "^17.0.4"
+@@ -39,11 +40,21 @@
+ "semantic-release": "^19.0.5"
},
"peerDependencies": {
+ "react": "*",
@@ -474,7 +445,7 @@ index b4d1fba..0a018a7 100644
}
}
diff --git a/node_modules/react-native-config/react-native-config.podspec b/node_modules/react-native-config/react-native-config.podspec
-index 35313d4..56bce4a 100644
+index 449b970..88b14c5 100644
--- a/node_modules/react-native-config/react-native-config.podspec
+++ b/node_modules/react-native-config/react-native-config.podspec
@@ -4,6 +4,8 @@ require 'json'
@@ -486,7 +457,7 @@ index 35313d4..56bce4a 100644
Pod::Spec.new do |s|
s.name = 'react-native-config'
s.version = package['version']
-@@ -33,8 +35,27 @@ HOST_PATH="$SRCROOT/../.."
+@@ -35,8 +37,13 @@ HOST_PATH="$SRCROOT/../.."
s.default_subspec = 'App'
s.subspec 'App' do |app|
@@ -495,21 +466,7 @@ index 35313d4..56bce4a 100644
+ app.source_files = 'ios/**/*.{h,m,mm}'
+
+ if fabric_enabled
-+ folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32'
-+
-+ app.pod_target_xcconfig = {
-+ 'HEADER_SEARCH_PATHS' => '"$(PODS_ROOT)/boost" "$(PODS_ROOT)/boost-for-react-native" "$(PODS_ROOT)/RCT-Folly"',
-+ 'CLANG_CXX_LANGUAGE_STANDARD' => 'c++17',
-+ }
-+ app.compiler_flags = folly_compiler_flags + ' -DRCT_NEW_ARCH_ENABLED'
-+
-+ app.dependency "React"
-+ app.dependency "React-RCTFabric" # This is for fabric component
-+ app.dependency "React-Codegen"
-+ app.dependency "RCT-Folly"
-+ app.dependency "RCTRequired"
-+ app.dependency "RCTTypeSafety"
-+ app.dependency "ReactCommon/turbomodule/core"
++ install_modules_dependencies(app)
+ else
+ app.dependency 'React-Core'
+ end
diff --git a/patches/react-native-keyboard-controller+1.14.0+001+disable-android.patch b/patches/react-native-keyboard-controller+1.14.1+001+disable-android.patch
similarity index 100%
rename from patches/react-native-keyboard-controller+1.14.0+001+disable-android.patch
rename to patches/react-native-keyboard-controller+1.14.1+001+disable-android.patch
diff --git a/src/CONST.ts b/src/CONST.ts
index a43e438937a5..04e5862669b2 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -205,6 +205,10 @@ const CONST = {
IN: 'in',
OUT: 'out',
},
+ POPOVER_ACCOUNT_SWITCHER_POSITION: {
+ horizontal: 12,
+ vertical: 80,
+ },
// Multiplier for gyroscope animation in order to make it a bit more subtle
ANIMATION_GYROSCOPE_VALUE: 0.4,
ANIMATION_PAID_DURATION: 200,
@@ -741,6 +745,8 @@ const CONST = {
HOW_TO_CONNECT_TO_SAGE_INTACCT: 'https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct#how-to-connect-to-sage-intacct',
PRICING: `https://www.expensify.com/pricing`,
COMPANY_CARDS_HELP: 'https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Commercial-Card-Feeds',
+ COMPANY_CARDS_CONNECT_CREDIT_CARDS_HELP_URL:
+ 'https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Commercial-Card-Feeds#what-is-the-difference-between-commercial-card-feeds-and-your-direct-bank-connections',
CUSTOM_REPORT_NAME_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates',
CONFIGURE_REIMBURSEMENT_SETTINGS_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/workspaces/Configure-Reimbursement-Settings',
COPILOT_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot',
@@ -1102,7 +1108,7 @@ const CONST = {
},
TIMING: {
CALCULATE_MOST_RECENT_LAST_MODIFIED_ACTION: 'calc_most_recent_last_modified_action',
- CHAT_FINDER_RENDER: 'search_render',
+ SEARCH_ROUTER_RENDER: 'search_router_render',
CHAT_RENDER: 'chat_render',
OPEN_REPORT: 'open_report',
HOMEPAGE_INITIAL_RENDER: 'homepage_initial_render',
@@ -1481,9 +1487,18 @@ const CONST = {
QUICKBOOKS_ONLINE: 'quickbooksOnline',
QUICKBOOKS_DESKTOP_CONFIG: {
+ EXPORT_DATE: 'exportDate',
+ EXPORTER: 'exporter',
MARK_CHECKS_TO_BE_PRINTED: 'markChecksToBePrinted',
REIMBURSABLE_ACCOUNT: 'reimbursableAccount',
REIMBURSABLE: 'reimbursable',
+ AUTO_SYNC: 'autoSync',
+ ENABLE_NEW_CATEGORIES: 'enableNewCategories',
+ SHOULD_AUTO_CREATE_VENDOR: 'shouldAutoCreateVendor',
+ MAPPINGS: {
+ CLASSES: 'classes',
+ CUSTOMERS: 'customers',
+ },
},
QUICKBOOKS_CONFIG: {
@@ -2551,16 +2566,23 @@ const CONST = {
CONNECTION_ERROR: 'connectionError',
STEP: {
SELECT_BANK: 'SelectBank',
+ SELECT_FEED_TYPE: 'SelectFeedType',
CARD_TYPE: 'CardType',
CARD_INSTRUCTIONS: 'CardInstructions',
CARD_NAME: 'CardName',
CARD_DETAILS: 'CardDetails',
+ BANK_CONNECTION: 'BankConnection',
+ AMEX_CUSTOM_FEED: 'AmexCustomFeed',
},
CARD_TYPE: {
AMEX: 'amex',
VISA: 'visa',
MASTERCARD: 'mastercard',
},
+ FEED_TYPE: {
+ CUSTOM: 'customFeed',
+ DIRECT: 'directFeed',
+ },
BANKS: {
AMEX: 'American Express',
BANK_OF_AMERICA: 'Bank of America',
@@ -2572,6 +2594,10 @@ const CONST = {
WELLS_FARGO: 'Wells Fargo',
OTHER: 'Other',
},
+ AMEX_CUSTOM_FEED: {
+ CORPORATE: 'American Express Corporate Cards',
+ BUSINESS: 'American Express Business Cards',
+ },
DELETE_TRANSACTIONS: {
RESTRICT: 'corporate',
ALLOW: 'personal',
@@ -5656,6 +5682,7 @@ const CONST = {
KEYWORD: 'keyword',
IN: 'in',
},
+ EMPTY_VALUE: 'none',
},
REFERRER: {
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 419bc2917c1e..98ea64bc65b4 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -74,7 +74,6 @@ const ROUTES = {
route: 'flag/:reportID/:reportActionID',
getRoute: (reportID: string, reportActionID: string, backTo?: string) => getUrlWithBackToParam(`flag/${reportID}/${reportActionID}` as const, backTo),
},
- CHAT_FINDER: 'chat-finder',
PROFILE: {
route: 'a/:accountID',
getRoute: (accountID?: string | number, backTo?: string, login?: string) => {
@@ -670,6 +669,18 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/accounting/quickbooks-online/export/date-select',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/export/date-select` as const,
},
+ WORKSPACE_ACCOUNTING_QUICKBOOKS_DESKTOP_ADVANCED: {
+ route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/advanced',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/advanced` as const,
+ },
+ POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT_DATE_SELECT: {
+ route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/export/date-select',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/export/date-select` as const,
+ },
+ POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_PREFERRED_EXPORTER: {
+ route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/export/preferred-exporter',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/export/preferred-exporter` as const,
+ },
POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES: {
route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/export/out-of-pocket-expense',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/export/out-of-pocket-expense` as const,
@@ -702,6 +713,26 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/import',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/import` as const,
},
+ POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CHART_OF_ACCOUNTS: {
+ route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/import/accounts',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/import/accounts` as const,
+ },
+ POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CLASSES: {
+ route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/import/classes',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/import/classes` as const,
+ },
+ POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CLASSES_DISPLAYED_AS: {
+ route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/import/classes/displayed_as',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/import/classes/displayed_as` as const,
+ },
+ POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CUSTOMERS: {
+ route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/import/customers',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/import/customers` as const,
+ },
+ POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS: {
+ route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/import/customers/displayed_as',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/import/customers/displayed_as` as const,
+ },
WORKSPACE_PROFILE_NAME: {
route: 'settings/workspaces/:policyID/profile/name',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/profile/name` as const,
@@ -1220,8 +1251,10 @@ const ROUTES = {
TRANSACTION_RECEIPT: {
route: 'r/:reportID/transaction/:transactionID/receipt',
- getRoute: (reportID: string, transactionID: string, readonly = false) => `r/${reportID}/transaction/${transactionID}/receipt${readonly ? '?readonly=true' : ''}` as const,
+ getRoute: (reportID: string, transactionID: string, readonly = false, isFromReviewDuplicates = false) =>
+ `r/${reportID}/transaction/${transactionID}/receipt?readonly=${readonly}${isFromReviewDuplicates ? '&isFromReviewDuplicates=true' : ''}` as const,
},
+
TRANSACTION_DUPLICATE_REVIEW_PAGE: {
route: 'r/:threadReportID/duplicates/review',
getRoute: (threadReportID: string, backTo?: string) => getUrlWithBackToParam(`r/${threadReportID}/duplicates/review` as const, backTo),
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index d927162dbb42..719c67f0365b 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -143,7 +143,6 @@ const SCREENS = {
ROOT: 'SaveTheWorld_Root',
},
LEFT_MODAL: {
- CHAT_FINDER: 'ChatFinder',
WORKSPACE_SWITCHER: 'WorkspaceSwitcher',
},
RIGHT_MODAL: {
@@ -317,6 +316,9 @@ const SCREENS = {
QUICKBOOKS_ONLINE_ADVANCED: 'Policy_Accounting_Quickbooks_Online_Advanced',
QUICKBOOKS_ONLINE_ACCOUNT_SELECTOR: 'Policy_Accounting_Quickbooks_Online_Account_Selector',
QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR: 'Policy_Accounting_Quickbooks_Online_Invoice_Account_Selector',
+ QUICKBOOKS_DESKTOP_ADVANCED: 'Policy_Accounting_Quickbooks_Desktop_Advanced',
+ QUICKBOOKS_DESKTOP_EXPORT_DATE_SELECT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Date_Select',
+ QUICKBOOKS_DESKTOP_EXPORT_PREFERRED_EXPORTER: 'Workspace_Accounting_Quickbooks_Desktop_Export_Preferred_Exporter',
QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES: 'Workspace_Accounting_Quickbooks_Desktop_Export_Out_Of_Pocket_Expenses',
QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Out_Of_Pocket_Expenses_Select',
QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Out_Of_Pocket_Expenses_Account_Select',
@@ -325,6 +327,11 @@ const SCREENS = {
QUICKBOOKS_DESKTOP_SETUP_REQUIRED_DEVICE_MODAL: 'Policy_Accouting_Quickbooks_Desktop_Setup_Required_Device_Modal',
QUICKBOOKS_DESKTOP_TRIGGER_FIRST_SYNC: 'Policy_Accouting_Quickbooks_Desktop_Trigger_First_Sync',
QUICKBOOKS_DESKTOP_IMPORT: 'Policy_Accounting_Quickbooks_Desktop_Import',
+ QUICKBOOKS_DESKTOP_CHART_OF_ACCOUNTS: 'Policy_Accounting_Quickbooks_Desktop_Import_Chart_Of_Accounts',
+ QUICKBOOKS_DESKTOP_CLASSES: 'Policy_Accounting_Quickbooks_Desktop_Import_Classes',
+ QUICKBOOKS_DESKTOP_CLASSES_DISPLAYED_AS: 'Policy_Accounting_Quickbooks_Desktop_Import_Classes_Dipslayed_As',
+ QUICKBOOKS_DESKTOP_CUSTOMERS: 'Policy_Accounting_Quickbooks_Desktop_Import_Customers',
+ QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS: 'Policy_Accounting_Quickbooks_Desktop_Import_Customers_Dipslayed_As',
XERO_IMPORT: 'Policy_Accounting_Xero_Import',
XERO_ORGANIZATION: 'Policy_Accounting_Xero_Customers',
XERO_CHART_OF_ACCOUNTS: 'Policy_Accounting_Xero_Import_Chart_Of_Accounts',
diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx
index 9b5d21743bef..8ccab44a2cb9 100644
--- a/src/components/AccountSwitcher.tsx
+++ b/src/components/AccountSwitcher.tsx
@@ -9,6 +9,7 @@ import usePermissions from '@hooks/usePermissions';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
import {clearDelegatorErrors, connect, disconnect} from '@libs/actions/Delegate';
import * as ErrorUtils from '@libs/ErrorUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
@@ -22,10 +23,8 @@ import Avatar from './Avatar';
import ConfirmModal from './ConfirmModal';
import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
-import type {MenuItemProps} from './MenuItem';
-import MenuItemList from './MenuItemList';
-import type {MenuItemWithLink} from './MenuItemList';
-import Popover from './Popover';
+import type {PopoverMenuItem} from './PopoverMenu';
+import PopoverMenu from './PopoverMenu';
import {PressableWithFeedback} from './Pressable';
import Text from './Text';
@@ -41,6 +40,7 @@ function AccountSwitcher() {
const [session] = useOnyx(ONYXKEYS.SESSION);
const [user] = useOnyx(ONYXKEYS.USER);
const buttonRef = useRef(null);
+ const {windowHeight} = useWindowDimensions();
const [shouldShowDelegatorMenu, setShouldShowDelegatorMenu] = useState(false);
const [shouldShowOfflineModal, setShouldShowOfflineModal] = useState(false);
@@ -49,10 +49,14 @@ function AccountSwitcher() {
const isActingAsDelegate = !!account?.delegatedAccess?.delegate ?? false;
const canSwitchAccounts = canUseNewDotCopilot && (delegators.length > 0 || isActingAsDelegate);
- const createBaseMenuItem = (personalDetails: PersonalDetails | undefined, errors?: Errors, additionalProps: MenuItemWithLink = {}): MenuItemWithLink => {
+ const createBaseMenuItem = (
+ personalDetails: PersonalDetails | undefined,
+ errors?: Errors,
+ additionalProps: Partial> = {},
+ ): PopoverMenuItem => {
const error = Object.values(errors ?? {}).at(0) ?? '';
return {
- title: personalDetails?.displayName ?? personalDetails?.login,
+ text: personalDetails?.displayName ?? personalDetails?.login ?? '',
description: Str.removeSMSDomain(personalDetails?.login ?? ''),
avatarID: personalDetails?.accountID ?? -1,
icon: personalDetails?.avatar ?? '',
@@ -66,14 +70,12 @@ function AccountSwitcher() {
};
};
- const menuItems = (): MenuItemProps[] => {
+ const menuItems = (): PopoverMenuItem[] => {
const currentUserMenuItem = createBaseMenuItem(currentUserPersonalDetails, undefined, {
- wrapperStyle: [styles.buttonDefaultBG],
- focused: true,
shouldShowRightIcon: true,
iconRight: Expensicons.Checkmark,
success: true,
- key: `${currentUserPersonalDetails?.login}-current`,
+ isSelected: true,
});
if (isActingAsDelegate) {
@@ -89,34 +91,32 @@ function AccountSwitcher() {
return [
createBaseMenuItem(delegatePersonalDetails, error, {
- onPress: () => {
+ onSelected: () => {
if (isOffline) {
Modal.close(() => setShouldShowOfflineModal(true));
return;
}
disconnect();
},
- key: `${delegateEmail}-delegate`,
}),
currentUserMenuItem,
];
}
- const delegatorMenuItems: MenuItemProps[] = delegators
+ const delegatorMenuItems: PopoverMenuItem[] = delegators
.filter(({email}) => email !== currentUserPersonalDetails.login)
- .map(({email, role, errorFields}, index) => {
+ .map(({email, role, errorFields}) => {
const error = ErrorUtils.getLatestErrorField({errorFields}, 'connect');
const personalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(email);
return createBaseMenuItem(personalDetails, error, {
badgeText: translate('delegate.role', {role}),
- onPress: () => {
+ onSelected: () => {
if (isOffline) {
Modal.close(() => setShouldShowOfflineModal(true));
return;
}
connect(email);
},
- key: `${email}-${index}`,
});
});
@@ -181,23 +181,27 @@ function AccountSwitcher() {
{canSwitchAccounts && (
- {
setShouldShowDelegatorMenu(false);
clearDelegatorErrors();
}}
anchorRef={buttonRef}
- anchorPosition={styles.accountSwitcherAnchorPosition}
- >
-
- {translate('delegate.switchAccount')}
-
-
-
+ anchorPosition={CONST.POPOVER_ACCOUNT_SWITCHER_POSITION}
+ anchorAlignment={{
+ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
+ vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
+ }}
+ menuItems={menuItems()}
+ headerText={translate('delegate.switchAccount')}
+ containerStyles={[{maxHeight: windowHeight / 2}, styles.pb0, styles.mw100, shouldUseNarrowLayout ? {} : styles.wFitContent]}
+ headerStyles={styles.pt0}
+ innerContainerStyle={styles.pb0}
+ scrollContainerStyle={styles.pb4}
+ shouldUseScrollView
+ shouldUpdateFocusedIndex={false}
+ />
)}
;
-};
-
-type BaseAnchorForAttachmentsOnlyProps = AnchorForAttachmentsOnlyProps &
- BaseAnchorForAttachmentsOnlyOnyxProps & {
- /** Press in handler for the link */
- onPressIn?: () => void;
+type BaseAnchorForAttachmentsOnlyProps = AnchorForAttachmentsOnlyProps & {
+ /** Press in handler for the link */
+ onPressIn?: () => void;
- /** Press out handler for the link */
- onPressOut?: () => void;
- };
+ /** Press out handler for the link */
+ onPressOut?: () => void;
+};
-function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', download, onPressIn, onPressOut}: BaseAnchorForAttachmentsOnlyProps) {
+function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', onPressIn, onPressOut}: BaseAnchorForAttachmentsOnlyProps) {
const sourceURLWithAuth = addEncryptedAuthTokenToURL(source);
const sourceID = (source.match(CONST.REGEX.ATTACHMENT_ID) ?? [])[1];
+ const [download] = useOnyx(`${ONYXKEYS.COLLECTION.DOWNLOAD}${sourceID}`);
+
const {isOffline} = useNetwork();
const styles = useThemeStyles();
@@ -43,7 +37,7 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow
{({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => (
{
if (isDownloading || isOffline || !sourceID) {
return;
@@ -69,6 +63,7 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow
shouldShowDownloadIcon={!!sourceID && !isOffline}
shouldShowLoadingSpinnerIcon={isDownloading}
isUsedAsChatAttachment
+ isUploading={!sourceID}
/>
)}
@@ -78,11 +73,4 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow
BaseAnchorForAttachmentsOnly.displayName = 'BaseAnchorForAttachmentsOnly';
-export default withOnyx({
- download: {
- key: ({source}) => {
- const sourceID = (source?.match(CONST.REGEX.ATTACHMENT_ID) ?? [])[1];
- return `${ONYXKEYS.COLLECTION.DOWNLOAD}${sourceID}`;
- },
- },
-})(BaseAnchorForAttachmentsOnly);
+export default BaseAnchorForAttachmentsOnly;
diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx
index 8de7f4575e75..0bc233812ca7 100644
--- a/src/components/AttachmentModal.tsx
+++ b/src/components/AttachmentModal.tsx
@@ -132,6 +132,8 @@ type AttachmentModalProps = {
fallbackSource?: AvatarSource;
canEditReceipt?: boolean;
+
+ shouldDisableSendButton?: boolean;
};
function AttachmentModal({
@@ -158,6 +160,7 @@ function AttachmentModal({
shouldShowNotFoundPage = false,
type = undefined,
accountID = undefined,
+ shouldDisableSendButton = false,
}: AttachmentModalProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -589,7 +592,7 @@ function AttachmentModal({
textStyles={[styles.buttonConfirmText]}
text={translate('common.send')}
onPress={submitAndClose}
- isDisabled={isConfirmButtonDisabled}
+ isDisabled={isConfirmButtonDisabled || shouldDisableSendButton}
pressOnEnter
/>
diff --git a/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx b/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx
index ee594f66aabc..e6ac9f9f21c7 100644
--- a/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx
+++ b/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx
@@ -24,9 +24,12 @@ type DefaultAttachmentViewProps = {
containerStyles?: StyleProp;
icon?: IconAsset;
+
+ /** Flag indicating if the attachment is being uploaded. */
+ isUploading?: boolean;
};
-function DefaultAttachmentView({fileName = '', shouldShowLoadingSpinnerIcon = false, shouldShowDownloadIcon, containerStyles, icon}: DefaultAttachmentViewProps) {
+function DefaultAttachmentView({fileName = '', shouldShowLoadingSpinnerIcon = false, shouldShowDownloadIcon, containerStyles, icon, isUploading}: DefaultAttachmentViewProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
@@ -53,7 +56,7 @@ function DefaultAttachmentView({fileName = '', shouldShowLoadingSpinnerIcon = fa
)}
{shouldShowLoadingSpinnerIcon && (
-
+ ;
-};
+type AttachmentViewProps = Attachment & {
+ /** Whether this view is the active screen */
+ isFocused?: boolean;
-type AttachmentViewProps = AttachmentViewOnyxProps &
- Attachment & {
- /** Whether this view is the active screen */
- isFocused?: boolean;
+ /** Function for handle on press */
+ onPress?: (e?: GestureResponderEvent | KeyboardEvent) => void;
- /** Function for handle on press */
- onPress?: (e?: GestureResponderEvent | KeyboardEvent) => void;
+ /** Whether the attachment is used in attachment modal */
+ isUsedInAttachmentModal?: boolean;
- isUsedInAttachmentModal?: boolean;
+ /** Flag to show/hide download icon */
+ shouldShowDownloadIcon?: boolean;
- /** Flag to show/hide download icon */
- shouldShowDownloadIcon?: boolean;
+ /** Flag to show the loading indicator */
+ shouldShowLoadingSpinnerIcon?: boolean;
- /** Flag to show the loading indicator */
- shouldShowLoadingSpinnerIcon?: boolean;
+ /** Notify parent that the UI should be modified to accommodate keyboard */
+ onToggleKeyboard?: (shouldFadeOut: boolean) => void;
- /** Notify parent that the UI should be modified to accommodate keyboard */
- onToggleKeyboard?: (shouldFadeOut: boolean) => void;
+ /** A callback when the PDF fails to load */
+ onPDFLoadError?: () => void;
- /** A callback when the PDF fails to load */
- onPDFLoadError?: () => void;
+ /** Extra styles to pass to View wrapper */
+ containerStyles?: StyleProp;
- /** Extra styles to pass to View wrapper */
- containerStyles?: StyleProp;
+ /** Denotes whether it is a workspace avatar or not */
+ isWorkspaceAvatar?: boolean;
- /** Denotes whether it is a workspace avatar or not */
- isWorkspaceAvatar?: boolean;
+ /** Denotes whether it is an icon (ex: SVG) */
+ maybeIcon?: boolean;
- /** Denotes whether it is an icon (ex: SVG) */
- maybeIcon?: boolean;
+ /** Fallback source to use in case of error */
+ fallbackSource?: AttachmentSource;
- /** Fallback source to use in case of error */
- fallbackSource?: AttachmentSource;
+ /* Whether it is hovered or not */
+ isHovered?: boolean;
- /* Whether it is hovered or not */
- isHovered?: boolean;
+ /** Whether the attachment is used as a chat attachment */
+ isUsedAsChatAttachment?: boolean;
- /** Whether the attachment is used as a chat attachment */
- isUsedAsChatAttachment?: boolean;
+ /* Flag indicating whether the attachment has been uploaded. */
+ isUploaded?: boolean;
- /* Flag indicating whether the attachment has been uploaded. */
- isUploaded?: boolean;
- };
+ /** Flag indicating if the attachment is being uploaded. */
+ isUploading?: boolean;
+};
function AttachmentView({
source,
@@ -95,16 +92,20 @@ function AttachmentView({
isWorkspaceAvatar,
maybeIcon,
fallbackSource,
- transaction,
+ transactionID = '-1',
reportActionID,
isHovered,
duration,
isUsedAsChatAttachment,
isUploaded = true,
+ isUploading = false,
}: AttachmentViewProps) {
const {translate} = useLocalize();
const {updateCurrentlyPlayingURL} = usePlaybackContext();
const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext);
+
+ const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`);
+
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -288,20 +289,16 @@ function AttachmentView({
);
}
AttachmentView.displayName = 'AttachmentView';
-export default memo(
- withOnyx({
- transaction: {
- key: ({transactionID}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
- },
- })(AttachmentView),
-);
+export default memo(AttachmentView);
export type {AttachmentViewProps};
diff --git a/src/components/Composer/index.native.tsx b/src/components/Composer/implementation/index.native.tsx
similarity index 89%
rename from src/components/Composer/index.native.tsx
rename to src/components/Composer/implementation/index.native.tsx
index e542ed56bdd3..9f237dd02424 100644
--- a/src/components/Composer/index.native.tsx
+++ b/src/components/Composer/implementation/index.native.tsx
@@ -1,14 +1,14 @@
import type {MarkdownStyle} from '@expensify/react-native-live-markdown';
import mimeDb from 'mime-db';
import type {ForwardedRef} from 'react';
-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import type {NativeSyntheticEvent, TextInput, TextInputChangeEventData, TextInputPasteEventData} from 'react-native';
import {StyleSheet} from 'react-native';
import type {FileObject} from '@components/AttachmentModal';
+import type {ComposerProps} from '@components/Composer/types';
import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput';
import RNMarkdownTextInput from '@components/RNMarkdownTextInput';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
-import useKeyboardState from '@hooks/useKeyboardState';
import useMarkdownStyle from '@hooks/useMarkdownStyle';
import useResetComposerFocus from '@hooks/useResetComposerFocus';
import useStyleUtils from '@hooks/useStyleUtils';
@@ -18,7 +18,6 @@ import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullCompo
import * as EmojiUtils from '@libs/EmojiUtils';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import CONST from '@src/CONST';
-import type {ComposerProps} from './types';
const excludeNoStyles: Array = [];
const excludeReportMentionStyle: Array = ['mentionReport'];
@@ -39,7 +38,6 @@ function Composer(
selection,
value,
isGroupPolicyReport = false,
- showSoftInputOnFocus = true,
...props
}: ComposerProps,
ref: ForwardedRef,
@@ -52,11 +50,7 @@ function Composer(
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
- const [contextMenuHidden, setContextMenuHidden] = useState(true);
-
const {inputCallbackRef, inputRef: autoFocusInputRef} = useAutoFocusInput();
- const keyboardState = useKeyboardState();
- const isKeyboardShown = keyboardState?.isKeyboardShown ?? false;
useEffect(() => {
if (autoFocus === !!autoFocusInputRef.current) {
@@ -116,13 +110,6 @@ function Composer(
const maxHeightStyle = useMemo(() => StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), [StyleUtils, isComposerFullSize, maxLines]);
const composerStyle = useMemo(() => StyleSheet.flatten([style, textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}]), [style, textContainsOnlyEmojis, styles]);
- useEffect(() => {
- if (!showSoftInputOnFocus || !isKeyboardShown) {
- return;
- }
- setContextMenuHidden(false);
- }, [showSoftInputOnFocus, isKeyboardShown]);
-
return (
);
}
diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx
new file mode 100755
index 000000000000..4431007793cb
--- /dev/null
+++ b/src/components/Composer/implementation/index.tsx
@@ -0,0 +1,368 @@
+import type {MarkdownStyle} from '@expensify/react-native-live-markdown';
+import lodashDebounce from 'lodash/debounce';
+import type {BaseSyntheticEvent, ForwardedRef} from 'react';
+import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
+// eslint-disable-next-line no-restricted-imports
+import type {NativeSyntheticEvent, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData} from 'react-native';
+import {DeviceEventEmitter, StyleSheet} from 'react-native';
+import type {ComposerProps} from '@components/Composer/types';
+import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput';
+import RNMarkdownTextInput from '@components/RNMarkdownTextInput';
+import useHtmlPaste from '@hooks/useHtmlPaste';
+import useIsScrollBarVisible from '@hooks/useIsScrollBarVisible';
+import useMarkdownStyle from '@hooks/useMarkdownStyle';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL';
+import * as Browser from '@libs/Browser';
+import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullComposerAvailable';
+import * as EmojiUtils from '@libs/EmojiUtils';
+import * as FileUtils from '@libs/fileDownload/FileUtils';
+import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition';
+import CONST from '@src/CONST';
+
+const excludeNoStyles: Array = [];
+const excludeReportMentionStyle: Array = ['mentionReport'];
+const imagePreviewAuthRequiredURLs = [CONST.EXPENSIFY_URL, CONST.STAGING_EXPENSIFY_URL];
+
+// Enable Markdown parsing.
+// On web we like to have the Text Input field always focused so the user can easily type a new chat
+function Composer(
+ {
+ value,
+ defaultValue,
+ maxLines = -1,
+ onKeyPress = () => {},
+ style,
+ autoFocus = false,
+ shouldCalculateCaretPosition = false,
+ isDisabled = false,
+ onClear = () => {},
+ onPasteFile = () => {},
+ onSelectionChange = () => {},
+ setIsFullComposerAvailable = () => {},
+ checkComposerVisibility = () => false,
+ selection: selectionProp = {
+ start: 0,
+ end: 0,
+ },
+ isComposerFullSize = false,
+ shouldContainScroll = true,
+ isGroupPolicyReport = false,
+ ...props
+ }: ComposerProps,
+ ref: ForwardedRef,
+) {
+ const textContainsOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]);
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles);
+ const StyleUtils = useStyleUtils();
+ const textInput = useRef(null);
+ const [selection, setSelection] = useState<
+ | {
+ start: number;
+ end?: number;
+ positionX?: number;
+ positionY?: number;
+ }
+ | undefined
+ >({
+ start: selectionProp.start,
+ end: selectionProp.end,
+ });
+ const [isRendered, setIsRendered] = useState(false);
+ const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? '');
+ const [prevScroll, setPrevScroll] = useState();
+ const isReportFlatListScrolling = useRef(false);
+
+ useEffect(() => {
+ if (!!selection && selectionProp.start === selection.start && selectionProp.end === selection.end) {
+ return;
+ }
+ setSelection(selectionProp);
+ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
+ }, [selectionProp]);
+
+ /**
+ * Adds the cursor position to the selection change event.
+ */
+ const addCursorPositionToSelectionChange = (event: NativeSyntheticEvent) => {
+ const webEvent = event as BaseSyntheticEvent;
+ const sel = window.getSelection();
+ if (shouldCalculateCaretPosition && isRendered && sel) {
+ const range = sel.getRangeAt(0).cloneRange();
+ range.collapse(true);
+ const rect = range.getClientRects()[0] || range.startContainer.parentElement?.getClientRects()[0];
+ const containerRect = textInput.current?.getBoundingClientRect();
+
+ let x = 0;
+ let y = 0;
+ if (rect && containerRect) {
+ x = rect.left - containerRect.left;
+ y = rect.top - containerRect.top + (textInput?.current?.scrollTop ?? 0) - rect.height / 2;
+ }
+
+ const selectionValue = {
+ start: webEvent.nativeEvent.selection.start,
+ end: webEvent.nativeEvent.selection.end,
+ positionX: x - CONST.SPACE_CHARACTER_WIDTH,
+ positionY: y,
+ };
+
+ onSelectionChange({
+ ...webEvent,
+ nativeEvent: {
+ ...webEvent.nativeEvent,
+ selection: selectionValue,
+ },
+ });
+ setSelection(selectionValue);
+ } else {
+ onSelectionChange(webEvent);
+ setSelection(webEvent.nativeEvent.selection);
+ }
+ };
+
+ /**
+ * Check the paste event for an attachment, parse the data and call onPasteFile from props with the selected file,
+ * Otherwise, convert pasted HTML to Markdown and set it on the composer.
+ */
+ const handlePaste = useCallback(
+ (event: ClipboardEvent) => {
+ const isVisible = checkComposerVisibility();
+ const isFocused = textInput.current?.isFocused();
+ const isContenteditableDivFocused = document.activeElement?.nodeName === 'DIV' && document.activeElement?.hasAttribute('contenteditable');
+
+ if (!(isVisible || isFocused)) {
+ return true;
+ }
+
+ if (textInput.current !== event.target && !(isContenteditableDivFocused && !event.clipboardData?.files.length)) {
+ const eventTarget = event.target as HTMLInputElement | HTMLTextAreaElement | null;
+ // To make sure the composer does not capture paste events from other inputs, we check where the event originated
+ // If it did originate in another input, we return early to prevent the composer from handling the paste
+ const isTargetInput = eventTarget?.nodeName === 'INPUT' || eventTarget?.nodeName === 'TEXTAREA' || eventTarget?.contentEditable === 'true';
+ if (isTargetInput || (!isFocused && isContenteditableDivFocused && event.clipboardData?.files.length)) {
+ return true;
+ }
+
+ textInput.current?.focus();
+ }
+
+ event.preventDefault();
+
+ const TEXT_HTML = 'text/html';
+
+ const clipboardDataHtml = event.clipboardData?.getData(TEXT_HTML) ?? '';
+
+ // If paste contains files, then trigger file management
+ if (event.clipboardData?.files.length && event.clipboardData.files.length > 0) {
+ // Prevent the default so we do not post the file name into the text box
+ onPasteFile(event.clipboardData.files[0]);
+ return true;
+ }
+
+ // If paste contains base64 image
+ if (clipboardDataHtml?.includes(CONST.IMAGE_BASE64_MATCH)) {
+ const domparser = new DOMParser();
+ const pastedHTML = clipboardDataHtml;
+ const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML)?.images;
+
+ if (embeddedImages.length > 0 && embeddedImages[0].src) {
+ const src = embeddedImages[0].src;
+ const file = FileUtils.base64ToFile(src, 'image.png');
+ onPasteFile(file);
+ return true;
+ }
+ }
+
+ // If paste contains image from Google Workspaces ex: Sheets, Docs, Slide, etc
+ if (clipboardDataHtml?.includes(CONST.GOOGLE_DOC_IMAGE_LINK_MATCH)) {
+ const domparser = new DOMParser();
+ const pastedHTML = clipboardDataHtml;
+ const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML).images;
+
+ if (embeddedImages.length > 0 && embeddedImages[0]?.src) {
+ const src = embeddedImages[0].src;
+ if (src.includes(CONST.GOOGLE_DOC_IMAGE_LINK_MATCH)) {
+ fetch(src)
+ .then((response) => response.blob())
+ .then((blob) => {
+ const file = new File([blob], 'image.jpg', {type: 'image/jpeg'});
+ onPasteFile(file);
+ });
+ return true;
+ }
+ }
+ }
+ return false;
+ },
+ [onPasteFile, checkComposerVisibility],
+ );
+
+ useEffect(() => {
+ if (!textInput.current) {
+ return;
+ }
+ const debouncedSetPrevScroll = lodashDebounce(() => {
+ if (!textInput.current) {
+ return;
+ }
+ setPrevScroll(textInput.current.scrollTop);
+ }, 100);
+
+ textInput.current.addEventListener('scroll', debouncedSetPrevScroll);
+ return () => {
+ textInput.current?.removeEventListener('scroll', debouncedSetPrevScroll);
+ };
+ }, []);
+
+ useEffect(() => {
+ const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling: boolean) => {
+ isReportFlatListScrolling.current = scrolling;
+ });
+
+ return () => scrollingListener.remove();
+ }, []);
+
+ useEffect(() => {
+ const handleWheel = (e: MouseEvent) => {
+ if (isReportFlatListScrolling.current) {
+ e.preventDefault();
+ return;
+ }
+ e.stopPropagation();
+ };
+ textInput.current?.addEventListener('wheel', handleWheel, {passive: false});
+
+ return () => {
+ textInput.current?.removeEventListener('wheel', handleWheel);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!textInput.current || prevScroll === undefined) {
+ return;
+ }
+ // eslint-disable-next-line react-compiler/react-compiler
+ textInput.current.scrollTop = prevScroll;
+ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
+ }, [isComposerFullSize]);
+
+ useHtmlPaste(textInput, handlePaste, true);
+
+ useEffect(() => {
+ setIsRendered(true);
+ }, []);
+
+ const clear = useCallback(() => {
+ if (!textInput.current) {
+ return;
+ }
+
+ const currentText = textInput.current.value;
+ textInput.current.clear();
+
+ // We need to reset the selection to 0,0 manually after clearing the text input on web
+ const selectionEvent = {
+ nativeEvent: {
+ selection: {
+ start: 0,
+ end: 0,
+ },
+ },
+ } as NativeSyntheticEvent;
+ onSelectionChange(selectionEvent);
+ setSelection({start: 0, end: 0});
+
+ onClear(currentText);
+ }, [onClear, onSelectionChange]);
+
+ useImperativeHandle(
+ ref,
+ () => {
+ const textInputRef = textInput.current;
+ if (!textInputRef) {
+ throw new Error('textInputRef is not available. This should never happen and indicates a developer error.');
+ }
+
+ return {
+ ...textInputRef,
+ // Overwrite clear with our custom implementation, which mimics how the native TextInput's clear method works
+ clear,
+ // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly
+ blur: () => textInputRef.blur(),
+ focus: () => textInputRef.focus(),
+ get scrollTop() {
+ return textInputRef.scrollTop;
+ },
+ };
+ },
+ [clear],
+ );
+
+ const handleKeyPress = useCallback(
+ (e: NativeSyntheticEvent) => {
+ // Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed
+ if (!onKeyPress || isEnterWhileComposition(e as unknown as KeyboardEvent)) {
+ return;
+ }
+
+ onKeyPress(e);
+ },
+ [onKeyPress],
+ );
+
+ const scrollStyleMemo = useMemo(() => {
+ if (shouldContainScroll) {
+ return isScrollBarVisible ? [styles.overflowScroll, styles.overscrollBehaviorContain] : styles.overflowHidden;
+ }
+ return styles.overflowAuto;
+ }, [shouldContainScroll, styles.overflowAuto, styles.overflowScroll, styles.overscrollBehaviorContain, styles.overflowHidden, isScrollBarVisible]);
+
+ const inputStyleMemo = useMemo(
+ () => [
+ StyleSheet.flatten([style, {outline: 'none'}]),
+ StyleUtils.getComposeTextAreaPadding(isComposerFullSize),
+ Browser.isMobileSafari() || Browser.isSafari() ? styles.rtlTextRenderForSafari : {},
+ scrollStyleMemo,
+ StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize),
+ isComposerFullSize ? {height: '100%', maxHeight: 'none'} : undefined,
+ textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {},
+ ],
+
+ [style, styles.rtlTextRenderForSafari, styles.onlyEmojisTextLineHeight, scrollStyleMemo, StyleUtils, maxLines, isComposerFullSize, textContainsOnlyEmojis],
+ );
+
+ return (
+ (textInput.current = el)}
+ selection={selection}
+ style={[inputStyleMemo]}
+ markdownStyle={markdownStyle}
+ value={value}
+ defaultValue={defaultValue}
+ autoFocus={autoFocus}
+ /* eslint-disable-next-line react/jsx-props-no-spreading */
+ {...props}
+ onSelectionChange={addCursorPositionToSelectionChange}
+ onContentSizeChange={(e) => {
+ updateIsFullComposerAvailable({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles);
+ }}
+ disabled={isDisabled}
+ onKeyPress={handleKeyPress}
+ addAuthTokenToImageURLCallback={addEncryptedAuthTokenToURL}
+ imagePreviewAuthRequiredURLs={imagePreviewAuthRequiredURLs}
+ />
+ );
+}
+
+Composer.displayName = 'Composer';
+
+export default React.forwardRef(Composer);
diff --git a/src/components/Composer/index.e2e.tsx b/src/components/Composer/index.e2e.tsx
new file mode 100644
index 000000000000..38cf065f7b8e
--- /dev/null
+++ b/src/components/Composer/index.e2e.tsx
@@ -0,0 +1,19 @@
+import type {ForwardedRef} from 'react';
+import React from 'react';
+import type {TextInput} from 'react-native';
+import Composer from './implementation';
+import type {ComposerProps} from './types';
+
+function ComposerE2E(props: ComposerProps, ref: ForwardedRef) {
+ return (
+
+ );
+}
+
+export default React.forwardRef(ComposerE2E);
diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx
old mode 100755
new mode 100644
index 26eb0f960c61..d9474effa478
--- a/src/components/Composer/index.tsx
+++ b/src/components/Composer/index.tsx
@@ -1,370 +1,3 @@
-import type {MarkdownStyle} from '@expensify/react-native-live-markdown';
-import lodashDebounce from 'lodash/debounce';
-import type {BaseSyntheticEvent, ForwardedRef} from 'react';
-import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
-// eslint-disable-next-line no-restricted-imports
-import type {NativeSyntheticEvent, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData} from 'react-native';
-import {DeviceEventEmitter, StyleSheet} from 'react-native';
-import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput';
-import RNMarkdownTextInput from '@components/RNMarkdownTextInput';
-import useHtmlPaste from '@hooks/useHtmlPaste';
-import useIsScrollBarVisible from '@hooks/useIsScrollBarVisible';
-import useMarkdownStyle from '@hooks/useMarkdownStyle';
-import useStyleUtils from '@hooks/useStyleUtils';
-import useTheme from '@hooks/useTheme';
-import useThemeStyles from '@hooks/useThemeStyles';
-import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL';
-import * as Browser from '@libs/Browser';
-import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullComposerAvailable';
-import * as EmojiUtils from '@libs/EmojiUtils';
-import * as FileUtils from '@libs/fileDownload/FileUtils';
-import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition';
-import CONST from '@src/CONST';
-import type {ComposerProps} from './types';
+import Composer from './implementation';
-const excludeNoStyles: Array = [];
-const excludeReportMentionStyle: Array = ['mentionReport'];
-const imagePreviewAuthRequiredURLs = [CONST.EXPENSIFY_URL, CONST.STAGING_EXPENSIFY_URL];
-
-// Enable Markdown parsing.
-// On web we like to have the Text Input field always focused so the user can easily type a new chat
-function Composer(
- {
- value,
- defaultValue,
- maxLines = -1,
- onKeyPress = () => {},
- style,
- autoFocus = false,
- shouldCalculateCaretPosition = false,
- isDisabled = false,
- onClear = () => {},
- onPasteFile = () => {},
- onSelectionChange = () => {},
- setIsFullComposerAvailable = () => {},
- checkComposerVisibility = () => false,
- selection: selectionProp = {
- start: 0,
- end: 0,
- },
- isComposerFullSize = false,
- shouldContainScroll = true,
- isGroupPolicyReport = false,
- showSoftInputOnFocus = true,
- ...props
- }: ComposerProps,
- ref: ForwardedRef,
-) {
- const textContainsOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]);
- const theme = useTheme();
- const styles = useThemeStyles();
- const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles);
- const StyleUtils = useStyleUtils();
- const textInput = useRef(null);
- const [selection, setSelection] = useState<
- | {
- start: number;
- end?: number;
- positionX?: number;
- positionY?: number;
- }
- | undefined
- >({
- start: selectionProp.start,
- end: selectionProp.end,
- });
- const [isRendered, setIsRendered] = useState(false);
- const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? '');
- const [prevScroll, setPrevScroll] = useState();
- const isReportFlatListScrolling = useRef(false);
-
- useEffect(() => {
- if (!!selection && selectionProp.start === selection.start && selectionProp.end === selection.end) {
- return;
- }
- setSelection(selectionProp);
- // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- }, [selectionProp]);
-
- /**
- * Adds the cursor position to the selection change event.
- */
- const addCursorPositionToSelectionChange = (event: NativeSyntheticEvent) => {
- const webEvent = event as BaseSyntheticEvent;
- const sel = window.getSelection();
- if (shouldCalculateCaretPosition && isRendered && sel) {
- const range = sel.getRangeAt(0).cloneRange();
- range.collapse(true);
- const rect = range.getClientRects()[0] || range.startContainer.parentElement?.getClientRects()[0];
- const containerRect = textInput.current?.getBoundingClientRect();
-
- let x = 0;
- let y = 0;
- if (rect && containerRect) {
- x = rect.left - containerRect.left;
- y = rect.top - containerRect.top + (textInput?.current?.scrollTop ?? 0) - rect.height / 2;
- }
-
- const selectionValue = {
- start: webEvent.nativeEvent.selection.start,
- end: webEvent.nativeEvent.selection.end,
- positionX: x - CONST.SPACE_CHARACTER_WIDTH,
- positionY: y,
- };
-
- onSelectionChange({
- ...webEvent,
- nativeEvent: {
- ...webEvent.nativeEvent,
- selection: selectionValue,
- },
- });
- setSelection(selectionValue);
- } else {
- onSelectionChange(webEvent);
- setSelection(webEvent.nativeEvent.selection);
- }
- };
-
- /**
- * Check the paste event for an attachment, parse the data and call onPasteFile from props with the selected file,
- * Otherwise, convert pasted HTML to Markdown and set it on the composer.
- */
- const handlePaste = useCallback(
- (event: ClipboardEvent) => {
- const isVisible = checkComposerVisibility();
- const isFocused = textInput.current?.isFocused();
- const isContenteditableDivFocused = document.activeElement?.nodeName === 'DIV' && document.activeElement?.hasAttribute('contenteditable');
-
- if (!(isVisible || isFocused)) {
- return true;
- }
-
- if (textInput.current !== event.target && !(isContenteditableDivFocused && !event.clipboardData?.files.length)) {
- const eventTarget = event.target as HTMLInputElement | HTMLTextAreaElement | null;
- // To make sure the composer does not capture paste events from other inputs, we check where the event originated
- // If it did originate in another input, we return early to prevent the composer from handling the paste
- const isTargetInput = eventTarget?.nodeName === 'INPUT' || eventTarget?.nodeName === 'TEXTAREA' || eventTarget?.contentEditable === 'true';
- if (isTargetInput || (!isFocused && isContenteditableDivFocused && event.clipboardData?.files.length)) {
- return true;
- }
-
- textInput.current?.focus();
- }
-
- event.preventDefault();
-
- const TEXT_HTML = 'text/html';
-
- const clipboardDataHtml = event.clipboardData?.getData(TEXT_HTML) ?? '';
-
- // If paste contains files, then trigger file management
- if (event.clipboardData?.files.length && event.clipboardData.files.length > 0) {
- // Prevent the default so we do not post the file name into the text box
- onPasteFile(event.clipboardData.files[0]);
- return true;
- }
-
- // If paste contains base64 image
- if (clipboardDataHtml?.includes(CONST.IMAGE_BASE64_MATCH)) {
- const domparser = new DOMParser();
- const pastedHTML = clipboardDataHtml;
- const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML)?.images;
-
- if (embeddedImages.length > 0 && embeddedImages[0].src) {
- const src = embeddedImages[0].src;
- const file = FileUtils.base64ToFile(src, 'image.png');
- onPasteFile(file);
- return true;
- }
- }
-
- // If paste contains image from Google Workspaces ex: Sheets, Docs, Slide, etc
- if (clipboardDataHtml?.includes(CONST.GOOGLE_DOC_IMAGE_LINK_MATCH)) {
- const domparser = new DOMParser();
- const pastedHTML = clipboardDataHtml;
- const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML).images;
-
- if (embeddedImages.length > 0 && embeddedImages[0]?.src) {
- const src = embeddedImages[0].src;
- if (src.includes(CONST.GOOGLE_DOC_IMAGE_LINK_MATCH)) {
- fetch(src)
- .then((response) => response.blob())
- .then((blob) => {
- const file = new File([blob], 'image.jpg', {type: 'image/jpeg'});
- onPasteFile(file);
- });
- return true;
- }
- }
- }
- return false;
- },
- [onPasteFile, checkComposerVisibility],
- );
-
- useEffect(() => {
- if (!textInput.current) {
- return;
- }
- const debouncedSetPrevScroll = lodashDebounce(() => {
- if (!textInput.current) {
- return;
- }
- setPrevScroll(textInput.current.scrollTop);
- }, 100);
-
- textInput.current.addEventListener('scroll', debouncedSetPrevScroll);
- return () => {
- textInput.current?.removeEventListener('scroll', debouncedSetPrevScroll);
- };
- }, []);
-
- useEffect(() => {
- const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling: boolean) => {
- isReportFlatListScrolling.current = scrolling;
- });
-
- return () => scrollingListener.remove();
- }, []);
-
- useEffect(() => {
- const handleWheel = (e: MouseEvent) => {
- if (isReportFlatListScrolling.current) {
- e.preventDefault();
- return;
- }
- e.stopPropagation();
- };
- textInput.current?.addEventListener('wheel', handleWheel, {passive: false});
-
- return () => {
- textInput.current?.removeEventListener('wheel', handleWheel);
- };
- }, []);
-
- useEffect(() => {
- if (!textInput.current || prevScroll === undefined) {
- return;
- }
- // eslint-disable-next-line react-compiler/react-compiler
- textInput.current.scrollTop = prevScroll;
- // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- }, [isComposerFullSize]);
-
- useHtmlPaste(textInput, handlePaste, true);
-
- useEffect(() => {
- setIsRendered(true);
- }, []);
-
- const clear = useCallback(() => {
- if (!textInput.current) {
- return;
- }
-
- const currentText = textInput.current.value;
- textInput.current.clear();
-
- // We need to reset the selection to 0,0 manually after clearing the text input on web
- const selectionEvent = {
- nativeEvent: {
- selection: {
- start: 0,
- end: 0,
- },
- },
- } as NativeSyntheticEvent;
- onSelectionChange(selectionEvent);
- setSelection({start: 0, end: 0});
-
- onClear(currentText);
- }, [onClear, onSelectionChange]);
-
- useImperativeHandle(
- ref,
- () => {
- const textInputRef = textInput.current;
- if (!textInputRef) {
- throw new Error('textInputRef is not available. This should never happen and indicates a developer error.');
- }
-
- return {
- ...textInputRef,
- // Overwrite clear with our custom implementation, which mimics how the native TextInput's clear method works
- clear,
- // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly
- blur: () => textInputRef.blur(),
- focus: () => textInputRef.focus(),
- get scrollTop() {
- return textInputRef.scrollTop;
- },
- };
- },
- [clear],
- );
-
- const handleKeyPress = useCallback(
- (e: NativeSyntheticEvent) => {
- // Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed
- if (!onKeyPress || isEnterWhileComposition(e as unknown as KeyboardEvent)) {
- return;
- }
-
- onKeyPress(e);
- },
- [onKeyPress],
- );
-
- const scrollStyleMemo = useMemo(() => {
- if (shouldContainScroll) {
- return isScrollBarVisible ? [styles.overflowScroll, styles.overscrollBehaviorContain] : styles.overflowHidden;
- }
- return styles.overflowAuto;
- }, [shouldContainScroll, styles.overflowAuto, styles.overflowScroll, styles.overscrollBehaviorContain, styles.overflowHidden, isScrollBarVisible]);
-
- const inputStyleMemo = useMemo(
- () => [
- StyleSheet.flatten([style, {outline: 'none'}]),
- StyleUtils.getComposeTextAreaPadding(isComposerFullSize),
- Browser.isMobileSafari() || Browser.isSafari() ? styles.rtlTextRenderForSafari : {},
- scrollStyleMemo,
- StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize),
- isComposerFullSize ? {height: '100%', maxHeight: 'none'} : undefined,
- textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {},
- ],
-
- [style, styles.rtlTextRenderForSafari, styles.onlyEmojisTextLineHeight, scrollStyleMemo, StyleUtils, maxLines, isComposerFullSize, textContainsOnlyEmojis],
- );
-
- return (
- (textInput.current = el)}
- selection={selection}
- style={[inputStyleMemo]}
- markdownStyle={markdownStyle}
- value={value}
- defaultValue={defaultValue}
- autoFocus={autoFocus}
- inputMode={showSoftInputOnFocus ? 'text' : 'none'}
- /* eslint-disable-next-line react/jsx-props-no-spreading */
- {...props}
- onSelectionChange={addCursorPositionToSelectionChange}
- onContentSizeChange={(e) => {
- updateIsFullComposerAvailable({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles);
- }}
- disabled={isDisabled}
- onKeyPress={handleKeyPress}
- addAuthTokenToImageURLCallback={addEncryptedAuthTokenToURL}
- imagePreviewAuthRequiredURLs={imagePreviewAuthRequiredURLs}
- />
- );
-}
-
-Composer.displayName = 'Composer';
-
-export default React.forwardRef(Composer);
+export default Composer;
diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts
index 7f54c7486e8d..ef497dd52e47 100644
--- a/src/components/Composer/types.ts
+++ b/src/components/Composer/types.ts
@@ -74,9 +74,6 @@ type ComposerProps = Omit & {
/** Indicates whether the composer is in a group policy report. Used for disabling report mentioning style in markdown input */
isGroupPolicyReport?: boolean;
-
- /** Whether the soft keyboard is open */
- showSoftInputOnFocus?: boolean;
};
export type {TextSelection, ComposerProps, CustomSelectionChangeEvent};
diff --git a/src/components/DelegateNoAccessModal.tsx b/src/components/DelegateNoAccessModal.tsx
index 8b708459c122..442c3ec9c4e2 100644
--- a/src/components/DelegateNoAccessModal.tsx
+++ b/src/components/DelegateNoAccessModal.tsx
@@ -15,13 +15,11 @@ export default function DelegateNoAccessModal({isNoDelegateAccessMenuVisible = f
const {translate} = useLocalize();
const noDelegateAccessPromptStart = translate('delegate.notAllowedMessageStart', {accountOwnerEmail: delegatorEmail});
const noDelegateAccessHyperLinked = translate('delegate.notAllowedMessageHyperLinked');
- const noDelegateAccessPromptEnd = translate('delegate.notAllowedMessageEnd');
const delegateNoAccessPrompt = (
{noDelegateAccessPromptStart}
- {noDelegateAccessHyperLinked}
- {noDelegateAccessPromptEnd}
+ {noDelegateAccessHyperLinked}.
);
diff --git a/src/components/FocusTrap/TOP_TAB_SCREENS.ts b/src/components/FocusTrap/TOP_TAB_SCREENS.ts
index 6bee36b86883..34610c4b0f11 100644
--- a/src/components/FocusTrap/TOP_TAB_SCREENS.ts
+++ b/src/components/FocusTrap/TOP_TAB_SCREENS.ts
@@ -1,5 +1,10 @@
+import type {TupleToUnion} from 'type-fest';
import CONST from '@src/CONST';
-const TOP_TAB_SCREENS: string[] = [CONST.TAB.NEW_CHAT, CONST.TAB.NEW_ROOM, CONST.TAB_REQUEST.DISTANCE, CONST.TAB_REQUEST.MANUAL, CONST.TAB_REQUEST.SCAN];
+const TOP_TAB_SCREENS = [CONST.TAB.NEW_CHAT, CONST.TAB.NEW_ROOM, CONST.TAB_REQUEST.DISTANCE, CONST.TAB_REQUEST.MANUAL, CONST.TAB_REQUEST.SCAN] as const;
+
+type TopTabScreen = TupleToUnion;
+
+export type {TopTabScreen};
export default TOP_TAB_SCREENS;
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx
index 9fe1088c9809..e44d3ef97df6 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx
@@ -2,6 +2,7 @@ import {createContext} from 'react';
type MentionReportContextProps = {
currentReportID: string;
+ exactlyMatch?: boolean;
};
const MentionReportContext = createContext({
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx
index 7aa0f5eca22a..3ab907dc767d 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx
@@ -3,7 +3,7 @@ import React, {useContext, useMemo} from 'react';
import type {TextStyle} from 'react-native';
import {StyleSheet} from 'react-native';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
-import {useOnyx, withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html';
import {ShowContextMenuContext} from '@components/ShowContextMenuContext';
import Text from '@components/Text';
@@ -18,12 +18,7 @@ import type {Report} from '@src/types/onyx';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import MentionReportContext from './MentionReportContext';
-type MentionReportOnyxProps = {
- /** All reports shared with the user */
- reports: OnyxCollection;
-};
-
-type MentionReportRendererProps = MentionReportOnyxProps & CustomRendererProps;
+type MentionReportRendererProps = CustomRendererProps;
const removeLeadingLTRAndHash = (value: string) => value.replace(CONST.UNICODE.LTR, '').replace('#', '');
@@ -53,11 +48,12 @@ const getMentionDetails = (htmlAttributeReportID: string, currentReport: OnyxEnt
return {reportID, mentionDisplayText};
};
-function MentionReportRenderer({style, tnode, TDefaultRenderer, reports, ...defaultRendererProps}: MentionReportRendererProps) {
+function MentionReportRenderer({style, tnode, TDefaultRenderer, ...defaultRendererProps}: MentionReportRendererProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const htmlAttributeReportID = tnode.attributes.reportid;
- const {currentReportID: currentReportIDContext} = useContext(MentionReportContext);
+ const {currentReportID: currentReportIDContext, exactlyMatch} = useContext(MentionReportContext);
+ const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
const currentReportID = useCurrentReportID();
const currentReportIDValue = currentReportIDContext || currentReportID?.currentReportID;
@@ -86,7 +82,7 @@ function MentionReportRenderer({style, tnode, TDefaultRenderer, reports, ...defa
// eslint-disable-next-line react/jsx-props-no-spreading
{...defaultRendererProps}
style={
- isGroupPolicyReport
+ isGroupPolicyReport && (!exactlyMatch || navigationRoute)
? [styles.link, styleWithoutColor, StyleUtils.getMentionStyle(isCurrentRoomMention), {color: StyleUtils.getMentionTextColor(isCurrentRoomMention)}]
: []
}
@@ -111,17 +107,4 @@ function MentionReportRenderer({style, tnode, TDefaultRenderer, reports, ...defa
MentionReportRenderer.displayName = 'MentionReportRenderer';
-const chatReportSelector = (report: OnyxEntry): Report =>
- (report && {
- reportID: report.reportID,
- reportName: report.reportName,
- displayName: report.displayName,
- policyID: report.policyID,
- }) as Report;
-
-export default withOnyx({
- reports: {
- key: ONYXKEYS.COLLECTION.REPORT,
- selector: chatReportSelector,
- },
-})(MentionReportRenderer);
+export default MentionReportRenderer;
diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx
index eb04ad5540eb..e1843ee506d5 100755
--- a/src/components/HeaderWithBackButton/index.tsx
+++ b/src/components/HeaderWithBackButton/index.tsx
@@ -191,7 +191,7 @@ function HeaderWithBackButton({
/>
)}
{middleContent}
-
+
{children}
{shouldShowDownloadButton && (
diff --git a/src/components/MagicCodeInput.tsx b/src/components/MagicCodeInput.tsx
index 89b9fc9a9e16..ce4f3380a9b7 100644
--- a/src/components/MagicCodeInput.tsx
+++ b/src/components/MagicCodeInput.tsx
@@ -277,8 +277,10 @@ function MagicCodeInput(
const indexBeforeLastEditIndex = editIndex === 0 ? editIndex : editIndex - 1;
const indexToFocus = numbers.at(editIndex) === CONST.MAGIC_CODE_EMPTY_CHAR ? indexBeforeLastEditIndex : editIndex;
- const formElement = inputRefs.current as HTMLFormElement | null;
- (formElement?.[indexToFocus] as HTMLInputElement)?.focus();
+ if (indexToFocus !== undefined) {
+ lastFocusedIndex.current = indexToFocus;
+ inputRefs.current?.focus();
+ }
onChangeTextProp(value.substring(0, indexToFocus));
return;
@@ -314,6 +316,7 @@ function MagicCodeInput(
onChangeTextProp(composeToString(numbers));
if (newFocusedIndex !== undefined) {
+ lastFocusedIndex.current = newFocusedIndex;
inputRefs.current?.focus();
}
}
diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx
index 1faef0c6b44c..f14ee940e329 100755
--- a/src/components/MoneyRequestConfirmationList.tsx
+++ b/src/components/MoneyRequestConfirmationList.tsx
@@ -256,6 +256,7 @@ function MoneyRequestConfirmationList({
const prevRate = usePrevious(rate);
const currency = (mileageRate as MileageRate)?.currency ?? policyCurrency;
+ const prevCurrency = usePrevious(currency);
// A flag for showing the categories field
const shouldShowCategories = (isPolicyExpenseChat || isTypeInvoice) && (!!iouCategory || OptionsListUtils.hasEnabledOptions(Object.values(policyCategories ?? {})));
@@ -292,7 +293,7 @@ function MoneyRequestConfirmationList({
const distance = TransactionUtils.getDistanceInMeters(transaction, unit);
const prevDistance = usePrevious(distance);
- const shouldCalculateDistanceAmount = isDistanceRequest && (iouAmount === 0 || prevRate !== rate || prevDistance !== distance);
+ const shouldCalculateDistanceAmount = isDistanceRequest && (iouAmount === 0 || prevRate !== rate || prevDistance !== distance || prevCurrency !== currency);
const hasRoute = TransactionUtils.hasRoute(transaction, isDistanceRequest);
const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !rate) && !isMovingTransactionFromTrackExpense;
diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx
index 42ac08f567b6..b1aa2fc28338 100644
--- a/src/components/PopoverMenu.tsx
+++ b/src/components/PopoverMenu.tsx
@@ -1,8 +1,9 @@
+/* eslint-disable react/jsx-props-no-spreading */
import lodashIsEqual from 'lodash/isEqual';
import type {RefObject} from 'react';
-import React, {useLayoutEffect, useState} from 'react';
-import {StyleSheet} from 'react-native';
-import type {View} from 'react-native';
+import React, {Fragment, useLayoutEffect, useState} from 'react';
+import {StyleSheet, View} from 'react-native';
+import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
import type {ModalProps} from 'react-native-modal';
import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
@@ -67,7 +68,7 @@ type PopoverMenuProps = Partial & {
isVisible: boolean;
/** Callback to fire when a CreateMenu item is selected */
- onItemSelected: (selectedItem: PopoverMenuItem, index: number) => void;
+ onItemSelected?: (selectedItem: PopoverMenuItem, index: number) => void;
/** Menu items to be rendered on the list */
menuItems: PopoverMenuItem[];
@@ -107,6 +108,24 @@ type PopoverMenuProps = Partial & {
/** Whether to show the selected option checkmark */
shouldShowSelectedItemCheck?: boolean;
+
+ /** The style of content container which wraps all child views */
+ containerStyles?: StyleProp;
+
+ /** Used to apply styles specifically to the header text */
+ headerStyles?: StyleProp;
+
+ /** Modal container styles */
+ innerContainerStyle?: ViewStyle;
+
+ /** These styles will be applied to the scroll view content container which wraps all of the child views */
+ scrollContainerStyle?: StyleProp;
+
+ /** Whether we should wrap the list item in a scroll view */
+ shouldUseScrollView?: boolean;
+
+ /** Whether to update the focused index on a row select */
+ shouldUpdateFocusedIndex?: boolean;
};
function PopoverMenu({
@@ -132,6 +151,12 @@ function PopoverMenu({
shouldEnableNewFocusManagement,
restoreFocusType,
shouldShowSelectedItemCheck = false,
+ containerStyles,
+ headerStyles,
+ innerContainerStyle,
+ scrollContainerStyle,
+ shouldUseScrollView = false,
+ shouldUpdateFocusedIndex = true,
}: PopoverMenuProps) {
const styles = useThemeStyles();
const theme = useTheme();
@@ -143,6 +168,7 @@ function PopoverMenu({
const {windowHeight} = useWindowDimensions();
const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: currentMenuItemsFocusedIndex, maxIndex: currentMenuItems.length - 1, isActive: isVisible});
+ const WrapComponent = shouldUseScrollView ? ScrollView : Fragment;
const selectItem = (index: number) => {
const selectedItem = currentMenuItems.at(index);
@@ -155,7 +181,7 @@ function PopoverMenu({
const selectedSubMenuItemIndex = selectedItem?.subMenuItems.findIndex((option) => option.isSelected);
setFocusedIndex(selectedSubMenuItemIndex);
} else if (selectedItem.shouldCallAfterModalHide && !Browser.isSafari()) {
- onItemSelected(selectedItem, index);
+ onItemSelected?.(selectedItem, index);
Modal.close(
() => {
selectedItem.onSelected?.();
@@ -164,7 +190,7 @@ function PopoverMenu({
selectedItem.shouldCloseAllModals,
);
} else {
- onItemSelected(selectedItem, index);
+ onItemSelected?.(selectedItem, index);
selectedItem.onSelected?.();
}
};
@@ -210,7 +236,7 @@ function PopoverMenu({
if (!headerText || enteredSubMenuIndexes.length !== 0) {
return;
}
- return {headerText};
+ return {headerText};
};
useKeyboardShortcut(
@@ -263,61 +289,46 @@ function PopoverMenu({
shouldEnableNewFocusManagement={shouldEnableNewFocusManagement}
useNativeDriver
restoreFocusType={restoreFocusType}
+ innerContainerStyle={innerContainerStyle}
>
-
+
{renderHeaderText()}
{enteredSubMenuIndexes.length > 0 && renderBackButtonItem()}
- {currentMenuItems.map((item, menuIndex) => (
-
- selectItem(menuIndex)}
- focused={focusedIndex === menuIndex}
- displayInDefaultIconColor={item.displayInDefaultIconColor}
- shouldShowRightIcon={item.shouldShowRightIcon}
- shouldShowRightComponent={item.shouldShowRightComponent}
- iconRight={item.iconRight}
- rightComponent={item.rightComponent}
- shouldPutLeftPaddingWhenNoIcon={item.shouldPutLeftPaddingWhenNoIcon}
- label={item.label}
- style={{backgroundColor: item.isSelected ? theme.activeComponentBG : undefined}}
- isLabelHoverable={item.isLabelHoverable}
- floatRightAvatars={item.floatRightAvatars}
- floatRightAvatarSize={item.floatRightAvatarSize}
- shouldShowSubscriptRightAvatar={item.shouldShowSubscriptRightAvatar}
- disabled={item.disabled}
- onFocus={() => setFocusedIndex(menuIndex)}
- success={item.success}
- containerStyle={item.containerStyle}
- shouldRenderTooltip={item.shouldRenderTooltip}
- tooltipAnchorAlignment={item.tooltipAnchorAlignment}
- tooltipShiftHorizontal={item.tooltipShiftHorizontal}
- tooltipShiftVertical={item.tooltipShiftVertical}
- tooltipWrapperStyle={item.tooltipWrapperStyle}
- renderTooltipContent={item.renderTooltipContent}
- numberOfLinesTitle={item.numberOfLinesTitle}
- interactive={item.interactive}
- isSelected={item.isSelected}
- badgeText={item.badgeText}
- />
-
- ))}
-
+ {/** eslint-disable-next-line react/jsx-props-no-spreading */}
+
+ {currentMenuItems.map((item, menuIndex) => {
+ const {text, onSelected, subMenuItems, shouldCallAfterModalHide, ...menuItemProps} = item;
+ return (
+
+ selectItem(menuIndex)}
+ focused={focusedIndex === menuIndex}
+ shouldShowSelectedItemCheck={shouldShowSelectedItemCheck}
+ shouldCheckActionAllowedOnPress={false}
+ onFocus={() => {
+ if (!shouldUpdateFocusedIndex) {
+ return;
+ }
+ setFocusedIndex(menuIndex);
+ }}
+ style={{backgroundColor: item.isSelected ? theme.activeComponentBG : undefined}}
+ titleStyle={StyleSheet.flatten([styles.flex1, item.titleStyle])}
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {...menuItemProps}
+ />
+
+ );
+ })}
+
+
);
@@ -328,7 +339,7 @@ PopoverMenu.displayName = 'PopoverMenu';
export default React.memo(
PopoverMenu,
(prevProps, nextProps) =>
- prevProps.menuItems.length === nextProps.menuItems.length &&
+ lodashIsEqual(prevProps.menuItems, nextProps.menuItems) &&
prevProps.isVisible === nextProps.isVisible &&
lodashIsEqual(prevProps.anchorPosition, nextProps.anchorPosition) &&
prevProps.anchorRef === nextProps.anchorRef &&
diff --git a/src/components/RadioButton.tsx b/src/components/RadioButton.tsx
index 2d18ccb480b8..0bf7e370e480 100644
--- a/src/components/RadioButton.tsx
+++ b/src/components/RadioButton.tsx
@@ -1,5 +1,4 @@
import React from 'react';
-import {View} from 'react-native';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
@@ -36,17 +35,16 @@ function RadioButton({isChecked, onPress, accessibilityLabel, hasError = false,
pressDimmingValue={1}
accessibilityLabel={accessibilityLabel}
role={CONST.ROLE.RADIO}
+ style={[styles.radioButtonContainer, hasError && styles.borderColorDanger, disabled && styles.cursorDisabled]}
>
-
- {isChecked && (
-
- )}
-
+ {isChecked && (
+
+ )}
);
}
diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx
index 9a0861962637..cdff8a1988e1 100644
--- a/src/components/ReportActionItem/MoneyRequestView.tsx
+++ b/src/components/ReportActionItem/MoneyRequestView.tsx
@@ -56,6 +56,9 @@ type MoneyRequestViewProps = {
/** Whether we should show Money Request with disabled all fields */
readonly?: boolean;
+ /** whether or not this report is from review duplicates */
+ isFromReviewDuplicates?: boolean;
+
/** Updated transaction to show in duplicate transaction flow */
updatedTransaction?: OnyxEntry;
};
@@ -75,7 +78,7 @@ const getTransactionID = (report: OnyxEntry, parentReportActio
return originalMessage?.IOUTransactionID ?? -1;
};
-function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = false, updatedTransaction}: MoneyRequestViewProps) {
+function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = false, updatedTransaction, isFromReviewDuplicates = false}: MoneyRequestViewProps) {
const theme = useTheme();
const styles = useThemeStyles();
const session = useSession();
@@ -200,7 +203,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals
const hasRoute = TransactionUtils.hasRoute(transactionBackup ?? transaction, isDistanceRequest);
const rateID = TransactionUtils.getRateID(transaction) ?? '-1';
- const currency = policy ? policy.outputCurrency : PolicyUtils.getPersonalPolicy()?.outputCurrency ?? CONST.CURRENCY.USD;
+ const currency = transactionCurrency ?? CONST.CURRENCY.USD;
const mileageRate = TransactionUtils.isCustomUnitRateIDForP2P(transaction) ? DistanceRequestUtils.getRateForP2P(currency) : distanceRates[rateID] ?? {};
const {unit} = mileageRate;
@@ -508,6 +511,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals
transaction={updatedTransaction ?? transaction}
enablePreviewModal
readonly={readonly || !canEditReceipt}
+ isFromReviewDuplicates={isFromReviewDuplicates}
/>
)}
diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx
index d967a914c9f9..668338440f73 100644
--- a/src/components/ReportActionItem/ReportActionItemImage.tsx
+++ b/src/components/ReportActionItem/ReportActionItemImage.tsx
@@ -55,6 +55,9 @@ type ReportActionItemImageProps = {
/** Whether the receipt is not editable */
readonly?: boolean;
+
+ /** whether or not this report is from review duplicates */
+ isFromReviewDuplicates?: boolean;
};
/**
@@ -75,6 +78,7 @@ function ReportActionItemImage({
isSingleImage = true,
readonly = false,
shouldMapHaveBorderRadius,
+ isFromReviewDuplicates = false,
}: ReportActionItemImageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
@@ -135,7 +139,12 @@ function ReportActionItemImage({
style={[styles.w100, styles.h100, styles.noOutline as ViewStyle]}
onPress={() =>
Navigation.navigate(
- ROUTES.TRANSACTION_RECEIPT.getRoute(transactionThreadReport?.reportID ?? report?.reportID ?? '-1', transaction?.transactionID ?? '-1', readonly),
+ ROUTES.TRANSACTION_RECEIPT.getRoute(
+ transactionThreadReport?.reportID ?? report?.reportID ?? '-1',
+ transaction?.transactionID ?? '-1',
+ readonly,
+ isFromReviewDuplicates,
+ ),
)
}
accessibilityLabel={translate('accessibilityHints.viewAttachment')}
diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx
index f10951f2b1a0..4cd0341a2718 100644
--- a/src/components/ReportActionItem/ReportPreview.tsx
+++ b/src/components/ReportActionItem/ReportPreview.tsx
@@ -134,16 +134,9 @@ function ReportPreview({
const iouSettled = ReportUtils.isSettled(iouReportID) || action?.childStatusNum === CONST.REPORT.STATUS_NUM.REIMBURSED;
const previewMessageOpacity = useSharedValue(1);
const previewMessageStyle = useAnimatedStyle(() => ({
- ...styles.flex1,
- ...styles.flexRow,
- ...styles.alignItemsCenter,
opacity: previewMessageOpacity.value,
}));
const checkMarkScale = useSharedValue(iouSettled ? 1 : 0);
- const checkMarkStyle = useAnimatedStyle(() => ({
- ...styles.defaultCheckmarkWrapper,
- transform: [{scale: checkMarkScale.value}],
- }));
const moneyRequestComment = action?.childLastMoneyRequestComment ?? '';
const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport);
@@ -471,7 +464,7 @@ function ReportPreview({
-
+ {previewMessage}
{shouldShowRBR && (
@@ -493,7 +486,7 @@ function ReportPreview({
{getDisplayAmount()}
{iouSettled && (
-
+
{hasAssignee && (
-
+
+
+
+
+
)}
{taskTitle}
diff --git a/src/components/Search/SearchMultipleSelectionPicker.tsx b/src/components/Search/SearchMultipleSelectionPicker.tsx
index 558b89715b61..d76f2e76ab02 100644
--- a/src/components/Search/SearchMultipleSelectionPicker.tsx
+++ b/src/components/Search/SearchMultipleSelectionPicker.tsx
@@ -7,6 +7,7 @@ import useLocalize from '@hooks/useLocalize';
import localeCompare from '@libs/LocaleCompare';
import Navigation from '@libs/Navigation/Navigation';
import type {OptionData} from '@libs/ReportUtils';
+import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
type SearchMultipleSelectionPickerItem = {
@@ -28,6 +29,17 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit
const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');
const [selectedItems, setSelectedItems] = useState(initiallySelectedItems ?? []);
+ const sortOptionsWithEmptyValue = (a: SearchMultipleSelectionPickerItem, b: SearchMultipleSelectionPickerItem) => {
+ // Always show `No category` and `No tag` as the first option
+ if (a.value === CONST.SEARCH.EMPTY_VALUE) {
+ return -1;
+ }
+ if (b.value === CONST.SEARCH.EMPTY_VALUE) {
+ return 1;
+ }
+ return localeCompare(a.name, b.name);
+ };
+
useEffect(() => {
setSelectedItems(initiallySelectedItems ?? []);
}, [initiallySelectedItems]);
@@ -35,7 +47,7 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit
const {sections, noResultsFound} = useMemo(() => {
const selectedItemsSection = selectedItems
.filter((item) => item?.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()))
- .sort((a, b) => localeCompare(a.name, b.name))
+ .sort((a, b) => sortOptionsWithEmptyValue(a, b))
.map((item) => ({
text: item.name,
keyForList: item.name,
@@ -44,7 +56,7 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit
}));
const remainingItemsSection = items
.filter((item) => selectedItems.some((selectedItem) => selectedItem.value === item.value) === false && item?.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()))
- .sort((a, b) => localeCompare(a.name, b.name))
+ .sort((a, b) => sortOptionsWithEmptyValue(a, b))
.map((item) => ({
text: item.name,
keyForList: item.name,
diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx
index ec43c9762239..4c383021645f 100644
--- a/src/components/Search/SearchPageHeader.tsx
+++ b/src/components/Search/SearchPageHeader.tsx
@@ -373,7 +373,6 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) {
diff --git a/src/components/Search/SearchRouter/SearchButton.tsx b/src/components/Search/SearchRouter/SearchButton.tsx
index 05693ad5ea22..7ed22ec8162f 100644
--- a/src/components/Search/SearchRouter/SearchButton.tsx
+++ b/src/components/Search/SearchRouter/SearchButton.tsx
@@ -1,30 +1,37 @@
import React from 'react';
+import type {StyleProp, ViewStyle} from 'react-native';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import {PressableWithoutFeedback} from '@components/Pressable';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
-import Permissions from '@libs/Permissions';
+import Performance from '@libs/Performance';
+import * as Session from '@userActions/Session';
+import Timing from '@userActions/Timing';
+import CONST from '@src/CONST';
import {useSearchRouterContext} from './SearchRouterContext';
-function SearchButton() {
+type SearchButtonProps = {
+ style?: StyleProp;
+};
+
+function SearchButton({style}: SearchButtonProps) {
const styles = useThemeStyles();
const theme = useTheme();
const {translate} = useLocalize();
const {openSearchRouter} = useSearchRouterContext();
- if (!Permissions.canUseNewSearchRouter()) {
- return;
- }
-
return (
{
+ style={[styles.flexRow, styles.touchableButtonImage, style]}
+ onPress={Session.checkIfActionIsAllowed(() => {
+ Timing.start(CONST.TIMING.SEARCH_ROUTER_RENDER);
+ Performance.markStart(CONST.TIMING.SEARCH_ROUTER_RENDER);
+
openSearchRouter();
- }}
+ })}
>
void;
+};
+
+function SearchRouter({onRouterClose}: SearchRouterProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [betas] = useOnyx(ONYXKEYS.BETAS);
@@ -37,7 +41,6 @@ function SearchRouter() {
const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false});
const {isSmallScreenWidth} = useResponsiveLayout();
- const {isSearchRouterDisplayed, closeSearchRouter} = useSearchRouterContext();
const listRef = useRef(null);
const taxRates = getAllTaxRates();
@@ -69,7 +72,9 @@ function SearchRouter() {
};
}
+ Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS);
const newOptions = OptionsListUtils.filterOptions(searchOptions, debouncedInputValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true});
+ Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS);
return {
recentReports: newOptions.recentReports,
@@ -91,15 +96,6 @@ function SearchRouter() {
Report.searchInServer(debouncedInputValue.trim());
}, [debouncedInputValue]);
- useEffect(() => {
- if (!textInputValue && isSearchRouterDisplayed) {
- return;
- }
- listRef.current?.updateAndScrollToFocusedIndex(0);
- // eslint-disable-next-line react-compiler/react-compiler
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isSearchRouterDisplayed]);
-
const contextualReportData = contextualReportID ? searchOptions.recentReports?.find((option) => option.reportID === contextualReportID) : undefined;
const clearUserQuery = () => {
@@ -136,18 +132,18 @@ function SearchRouter() {
};
const closeAndClearRouter = useCallback(() => {
- closeSearchRouter();
+ onRouterClose();
clearUserQuery();
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [closeSearchRouter]);
+ }, [onRouterClose]);
const onSearchSubmit = useCallback(
(query: SearchQueryJSON | undefined) => {
if (!query) {
return;
}
- closeSearchRouter();
+ onRouterClose();
const standardizedQuery = SearchUtils.standardizeQueryJSON(query, cardList, taxRates);
const queryString = SearchUtils.buildSearchQueryString(standardizedQuery);
Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: queryString}));
@@ -155,22 +151,24 @@ function SearchRouter() {
},
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
- [closeSearchRouter],
+ [onRouterClose],
);
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => {
- closeSearchRouter();
- clearUserQuery();
+ closeAndClearRouter();
});
- const modalWidth = isSmallScreenWidth ? styles.w100 : {width: variables.popoverWidth};
+ const modalWidth = isSmallScreenWidth ? styles.w100 : {width: variables.searchRouterPopoverWidth};
return (
-
+
{isSmallScreenWidth && (
closeSearchRouter()}
+ onBackButtonPress={() => onRouterClose()}
/>
)}
{
+ onSearchSubmit(SearchUtils.buildSearchQueryJSON(textInputValue));
+ }}
routerListRef={listRef}
- wrapperStyle={[isSmallScreenWidth ? styles.mv3 : styles.mv2, isSmallScreenWidth ? styles.mh5 : styles.mh2, styles.border]}
+ shouldShowOfflineMessage
+ wrapperStyle={[styles.border, styles.alignItemsCenter]}
+ outerWrapperStyle={[isSmallScreenWidth ? styles.mv3 : styles.mv2, isSmallScreenWidth ? styles.mh5 : styles.mh2]}
wrapperFocusedStyle={[styles.borderColorFocus]}
isSearchingForReports={isSearchingForReports}
/>
diff --git a/src/components/Search/SearchRouter/SearchRouterContext.tsx b/src/components/Search/SearchRouter/SearchRouterContext.tsx
index d935fff110a4..2e4cbec0d6bb 100644
--- a/src/components/Search/SearchRouter/SearchRouterContext.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterContext.tsx
@@ -1,10 +1,12 @@
-import React, {useContext, useMemo, useState} from 'react';
+import React, {useContext, useMemo, useRef, useState} from 'react';
+import * as Modal from '@userActions/Modal';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
const defaultSearchContext = {
isSearchRouterDisplayed: false,
openSearchRouter: () => {},
closeSearchRouter: () => {},
+ toggleSearchRouter: () => {},
};
type SearchRouterContext = typeof defaultSearchContext;
@@ -13,15 +15,39 @@ const Context = React.createContext(defaultSearchContext);
function SearchRouterContextProvider({children}: ChildrenProps) {
const [isSearchRouterDisplayed, setIsSearchRouterDisplayed] = useState(false);
+ const searchRouterDisplayedRef = useRef(false);
const routerContext = useMemo(() => {
- const openSearchRouter = () => setIsSearchRouterDisplayed(true);
- const closeSearchRouter = () => setIsSearchRouterDisplayed(false);
+ const openSearchRouter = () => {
+ Modal.close(
+ () => {
+ setIsSearchRouterDisplayed(true);
+ searchRouterDisplayedRef.current = true;
+ },
+ false,
+ true,
+ );
+ };
+ const closeSearchRouter = () => {
+ setIsSearchRouterDisplayed(false);
+ searchRouterDisplayedRef.current = false;
+ };
+
+ // There are callbacks that live outside of React render-loop and interact with SearchRouter
+ // So we need a function that is based on ref to correctly open/close it
+ const toggleSearchRouter = () => {
+ if (searchRouterDisplayedRef.current) {
+ closeSearchRouter();
+ } else {
+ openSearchRouter();
+ }
+ };
return {
isSearchRouterDisplayed,
openSearchRouter,
closeSearchRouter,
+ toggleSearchRouter,
};
}, [isSearchRouterDisplayed, setIsSearchRouterDisplayed]);
diff --git a/src/components/Search/SearchRouter/SearchRouterInput.tsx b/src/components/Search/SearchRouter/SearchRouterInput.tsx
index 7b850f96efc1..ef6963152c42 100644
--- a/src/components/Search/SearchRouter/SearchRouterInput.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterInput.tsx
@@ -2,9 +2,11 @@ import React, {useState} from 'react';
import type {ReactNode, RefObject} from 'react';
import {View} from 'react-native';
import type {StyleProp, ViewStyle} from 'react-native';
+import FormHelpMessage from '@components/FormHelpMessage';
import type {SelectionListHandle} from '@components/SelectionList/types';
import TextInput from '@components/TextInput';
import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';
@@ -31,6 +33,9 @@ type SearchRouterInputProps = {
/** Whether the input is disabled */
disabled?: boolean;
+ /** Whether the offline message should be shown */
+ shouldShowOfflineMessage?: boolean;
+
/** Whether the input should be focused */
autoFocus?: boolean;
@@ -40,6 +45,9 @@ type SearchRouterInputProps = {
/** Any additional styles to apply when input is focused */
wrapperFocusedStyle?: StyleProp;
+ /** Any additional styles to apply to text input along with FormHelperMessage */
+ outerWrapperStyle?: StyleProp;
+
/** Component to be displayed on the right */
rightComponent?: ReactNode;
@@ -55,15 +63,19 @@ function SearchRouterInput({
routerListRef,
isFullWidth,
disabled = false,
+ shouldShowOfflineMessage = false,
autoFocus = true,
wrapperStyle,
wrapperFocusedStyle,
+ outerWrapperStyle,
rightComponent,
isSearchingForReports,
}: SearchRouterInputProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [isFocused, setIsFocused] = useState(false);
+ const {isOffline} = useNetwork();
+ const offlineMessage: string = isOffline && shouldShowOfflineMessage ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : '';
const onChangeText = (text: string) => {
setValue(text);
@@ -73,34 +85,45 @@ function SearchRouterInput({
const inputWidth = isFullWidth ? styles.w100 : {width: variables.popoverWidth};
return (
-
-
- {
- setIsFocused(true);
- routerListRef?.current?.updateExternalTextInputFocus(true);
- }}
- onBlur={() => {
- setIsFocused(false);
- routerListRef?.current?.updateExternalTextInputFocus(false);
- }}
- isLoading={!!isSearchingForReports}
- />
+
+
+
+ {
+ setIsFocused(true);
+ routerListRef?.current?.updateExternalTextInputFocus(true);
+ }}
+ onBlur={() => {
+ setIsFocused(false);
+ routerListRef?.current?.updateExternalTextInputFocus(false);
+ }}
+ isLoading={!!isSearchingForReports}
+ />
+
+ {rightComponent && {rightComponent}}
- {rightComponent && {rightComponent}}
+
);
}
diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx
index 7d86ce1150d5..9830ea4e9506 100644
--- a/src/components/Search/SearchRouter/SearchRouterList.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterList.tsx
@@ -13,10 +13,13 @@ import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
+import Performance from '@libs/Performance';
import {getAllTaxRates} from '@libs/PolicyUtils';
import type {OptionData} from '@libs/ReportUtils';
import * as SearchUtils from '@libs/SearchUtils';
import * as Report from '@userActions/Report';
+import Timing from '@userActions/Timing';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
@@ -47,6 +50,11 @@ type SearchRouterListProps = {
closeAndClearRouter: () => void;
};
+const setPerformanceTimersEnd = () => {
+ Timing.end(CONST.TIMING.SEARCH_ROUTER_RENDER);
+ Performance.markEnd(CONST.TIMING.SEARCH_ROUTER_RENDER);
+};
+
function isSearchQueryItem(item: OptionData | SearchQueryItem): item is SearchQueryItem {
if ('singleIcon' in item && item.singleIcon && 'query' in item && item.query) {
return true;
@@ -72,7 +80,6 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList
return (
@@ -91,7 +98,6 @@ function SearchRouterList(
const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
const taxRates = getAllTaxRates();
const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST);
- const contextualQuery = `in:${reportForContextualSearch?.reportID}`;
const sections: Array> = [];
if (currentQuery?.inputQuery) {
@@ -108,7 +114,7 @@ function SearchRouterList(
});
}
- if (reportForContextualSearch && !currentQuery?.inputQuery?.includes(contextualQuery)) {
+ if (reportForContextualSearch && !currentQuery?.inputQuery) {
sections.push({
data: [
{
@@ -137,7 +143,7 @@ function SearchRouterList(
sections.push({title: translate('search.recentSearches'), data: recentSearchesData});
}
- const styledRecentReports = recentReports.map((item) => ({...item, pressableStyle: styles.br2}));
+ const styledRecentReports = recentReports.map((item) => ({...item, pressableStyle: styles.br2, wrapperStyle: [styles.pr3, styles.pl3]}));
sections.push({title: translate('search.recentChats'), data: styledRecentReports});
const onSelectRow = useCallback(
@@ -159,7 +165,7 @@ function SearchRouterList(
// Handle selection of "Recent chat"
closeAndClearRouter();
if ('reportID' in item && item?.reportID) {
- Navigation.closeAndNavigate(ROUTES.REPORT_WITH_ID.getRoute(item?.reportID));
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item?.reportID));
} else if ('login' in item) {
Report.navigateToAndOpenReport(item?.login ? [item.login] : []);
}
@@ -174,8 +180,12 @@ function SearchRouterList(
ListItem={SearchRouterItem}
containerStyle={[styles.mh100]}
sectionListStyle={[isSmallScreenWidth ? styles.ph5 : styles.ph2, styles.pb2]}
+ listItemWrapperStyle={[styles.pr3, styles.pl3]}
+ onLayout={setPerformanceTimersEnd}
ref={ref}
showScrollIndicator={!isSmallScreenWidth}
+ sectionTitleStyles={styles.mhn2}
+ shouldSingleExecuteRowSelect
/>
);
}
diff --git a/src/components/Search/SearchRouter/SearchRouterModal.tsx b/src/components/Search/SearchRouter/SearchRouterModal.tsx
index 1f438d254a5f..7e403461dd34 100644
--- a/src/components/Search/SearchRouter/SearchRouterModal.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterModal.tsx
@@ -17,10 +17,10 @@ function SearchRouterModal() {
type={modalType}
fullscreen
isVisible={isSearchRouterDisplayed}
- popoverAnchorPosition={{right: 20, top: 20}}
+ popoverAnchorPosition={{right: 6, top: 6}}
onClose={closeSearchRouter}
>
- {isSearchRouterDisplayed && }
+ {isSearchRouterDisplayed && }
);
}
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index 2f4f239cbf2b..670cfef54df8 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -93,7 +93,7 @@ function Search({queryJSON, onSearchListScroll, contentContainerStyle}: SearchPr
const lastSearchResultsRef = useRef>();
const {setCurrentSearchHash, setSelectedTransactions, selectedTransactions, clearSelectedTransactions, setShouldShowStatusBarLoading, lastSearchType, setLastSearchType} =
useSearchContext();
- const {selectionMode} = useMobileSelectionMode();
+ const {selectionMode} = useMobileSelectionMode(false);
const [offset, setOffset] = useState(0);
const {type, status, sortBy, sortOrder, hash} = queryJSON;
@@ -400,6 +400,12 @@ function Search({queryJSON, onSearchListScroll, contentContainerStyle}: SearchPr
/>
)
}
+ isSelected={(item) =>
+ status !== CONST.SEARCH.STATUS.EXPENSE.ALL && SearchUtils.isReportListItemType(item)
+ ? item.transactions.some((transaction) => selectedTransactions[transaction.keyForList]?.isSelected)
+ : !!item.isSelected
+ }
+ shouldAutoTurnOff={false}
onScroll={onSearchListScroll}
canSelectMultiple={type !== CONST.SEARCH.DATA_TYPES.CHAT && canSelectMultiple}
customListHeaderHeight={searchHeaderHeight}
diff --git a/src/components/SelectionList/CardListItem.tsx b/src/components/SelectionList/CardListItem.tsx
index 2b3baa3d3037..b6267a04d133 100644
--- a/src/components/SelectionList/CardListItem.tsx
+++ b/src/components/SelectionList/CardListItem.tsx
@@ -12,7 +12,7 @@ import type {BankIcon} from '@src/types/onyx/Bank';
import BaseListItem from './BaseListItem';
import type {BaseListItemProps, ListItem} from './types';
-type CardListItemProps = BaseListItemProps;
+type CardListItemProps = BaseListItemProps;
function CardListItem({
item,
@@ -38,6 +38,11 @@ function CardListItem({
}
}, [item, onCheckboxPress, onSelectRow]);
+ const subtitleText =
+ `${item.lastFourPAN ? `${translate('paymentMethodList.accountLastFour')} ${item.lastFourPAN}` : ''}` +
+ `${item.lastFourPAN && item.isVirtual ? ` ${CONST.DOT_SEPARATOR} ` : ''}` +
+ `${item.isVirtual ? translate('workspace.expensifyCard.virtual') : ''}`;
+
return (
({
item.alternateText ? styles.mb1 : null,
]}
/>
- {!!item.lastFourPAN && (
+ {!!subtitleText && (
)}
diff --git a/src/components/SelectionListWithModal/index.tsx b/src/components/SelectionListWithModal/index.tsx
index 2d218bc815fe..46d6494d1d21 100644
--- a/src/components/SelectionListWithModal/index.tsx
+++ b/src/components/SelectionListWithModal/index.tsx
@@ -1,5 +1,6 @@
+import {useIsFocused} from '@react-navigation/native';
import type {ForwardedRef} from 'react';
-import React, {forwardRef, useEffect, useState} from 'react';
+import React, {forwardRef, useEffect, useRef, useState} from 'react';
import * as Expensicons from '@components/Icon/Expensicons';
import MenuItem from '@components/MenuItem';
import Modal from '@components/Modal';
@@ -14,10 +15,12 @@ import CONST from '@src/CONST';
type SelectionListWithModalProps = BaseSelectionListProps & {
turnOnSelectionModeOnLongPress?: boolean;
onTurnOnSelectionMode?: (item: TItem | null) => void;
+ shouldAutoTurnOff?: boolean;
+ isSelected?: (item: TItem) => boolean;
};
function SelectionListWithModal(
- {turnOnSelectionModeOnLongPress, onTurnOnSelectionMode, onLongPressRow, sections, ...rest}: SelectionListWithModalProps,
+ {turnOnSelectionModeOnLongPress, onTurnOnSelectionMode, onLongPressRow, sections, shouldAutoTurnOff, isSelected, ...rest}: SelectionListWithModalProps,
ref: ForwardedRef,
) {
const [isModalVisible, setIsModalVisible] = useState(false);
@@ -26,21 +29,52 @@ function SelectionListWithModal(
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout here because there is a race condition that causes shouldUseNarrowLayout to change indefinitely in this component
// See https://github.com/Expensify/App/issues/48675 for more details
const {isSmallScreenWidth} = useResponsiveLayout();
- const {selectionMode} = useMobileSelectionMode(true);
+ const isFocused = useIsFocused();
+
+ const {selectionMode} = useMobileSelectionMode(shouldAutoTurnOff);
+ // Check if selection should be on when the modal is opened
+ const wasSelectionOnRef = useRef(false);
+ // Keep track of the number of selected items to determine if we should turn off selection mode
+ const selectionRef = useRef(0);
useEffect(() => {
// We can access 0 index safely as we are not displaying multiple sections in table view
- const selectedItems = sections[0].data.filter((item) => item.isSelected);
+ const selectedItems = sections[0].data.filter((item) => {
+ if (isSelected) {
+ return isSelected(item);
+ }
+ return !!item.isSelected;
+ });
+ selectionRef.current = selectedItems.length;
+
if (!isSmallScreenWidth) {
if (selectedItems.length === 0) {
turnOffMobileSelectionMode();
}
return;
}
+ if (!isFocused) {
+ return;
+ }
+ if (!wasSelectionOnRef.current && selectedItems.length > 0) {
+ wasSelectionOnRef.current = true;
+ }
if (selectedItems.length > 0 && !selectionMode?.isEnabled) {
turnOnMobileSelectionMode();
+ } else if (selectedItems.length === 0 && selectionMode?.isEnabled && !wasSelectionOnRef.current) {
+ turnOffMobileSelectionMode();
}
- }, [sections, selectionMode, isSmallScreenWidth]);
+ }, [sections, selectionMode, isSmallScreenWidth, isSelected, isFocused]);
+
+ useEffect(
+ () => () => {
+ if (selectionRef.current !== 0) {
+ return;
+ }
+ turnOffMobileSelectionMode();
+ },
+ [],
+ );
const handleLongPressRow = (item: TItem) => {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
diff --git a/src/components/SettlementButton/index.tsx b/src/components/SettlementButton/index.tsx
index f371545ab7b0..f859f4a36803 100644
--- a/src/components/SettlementButton/index.tsx
+++ b/src/components/SettlementButton/index.tsx
@@ -190,7 +190,7 @@ function SettlementButton({
if (iouPaymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY || iouPaymentType === CONST.IOU.PAYMENT_TYPE.VBBA) {
if (!isUserValidated) {
- Navigation.navigate(ROUTES.SETTINGS_WALLET_VERIFY_ACCOUNT.getRoute());
+ Navigation.navigate(ROUTES.SETTINGS_WALLET_VERIFY_ACCOUNT.getRoute(ROUTES.SETTINGS_ADD_BANK_ACCOUNT));
return;
}
triggerKYCFlow(event, iouPaymentType);
diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx
index 871ab8b25ac2..eadcabb8bfc6 100644
--- a/src/components/TextInput/BaseTextInput/index.native.tsx
+++ b/src/components/TextInput/BaseTextInput/index.native.tsx
@@ -259,6 +259,7 @@ function BaseTextInput(
!hideFocusedState && isFocused && styles.borderColorFocus,
(!!hasError || !!errorText) && styles.borderColorDanger,
autoGrowHeight && {scrollPaddingTop: typeof maxAutoGrowHeight === 'number' ? 2 * maxAutoGrowHeight : undefined},
+ isAutoGrowHeightMarkdown && styles.pb2,
]);
const inputPaddingLeft = !!prefixCharacter && StyleUtils.getPaddingLeft(StyleUtils.getCharacterPadding(prefixCharacter) + styles.pl1.paddingLeft);
diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx
index 09b644c8e76a..48b99486d6cc 100644
--- a/src/components/TextInput/BaseTextInput/index.tsx
+++ b/src/components/TextInput/BaseTextInput/index.tsx
@@ -261,6 +261,7 @@ function BaseTextInput(
!hideFocusedState && isFocused && styles.borderColorFocus,
(!!hasError || !!errorText) && styles.borderColorDanger,
autoGrowHeight && {scrollPaddingTop: typeof maxAutoGrowHeight === 'number' ? 2 * maxAutoGrowHeight : undefined},
+ isAutoGrowHeightMarkdown && styles.pb2,
]);
const isMultiline = multiline || autoGrowHeight;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 7f3a5a365142..50ae05f61798 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -108,7 +108,6 @@ import type {
PayerSettledParams,
PaySomeoneParams,
ReconciliationWorksParams,
- ReimbursementRateParams,
RemovedFromApprovalWorkflowParams,
RemovedTheRequestParams,
RemoveMemberPromptParams,
@@ -277,6 +276,7 @@ const translations = {
close: 'Close',
download: 'Download',
downloading: 'Downloading',
+ uploading: 'Uploading',
pin: 'Pin',
unPin: 'Unpin',
back: 'Back',
@@ -1015,7 +1015,7 @@ const translations = {
changed: 'changed',
removed: 'removed',
transactionPending: 'Transaction pending.',
- chooseARate: ({unit}: ReimbursementRateParams) => `Select a workspace reimbursement rate per ${unit}`,
+ chooseARate: 'Select a workspace reimbursement rate per mile or kilometer',
unapprove: 'Unapprove',
unapproveReport: 'Unapprove report',
headsUp: 'Heads up!',
@@ -2452,6 +2452,19 @@ const translations = {
classes: 'Classes',
items: 'Items',
customers: 'Customers/projects',
+ accountsDescription: 'Your QuickBooks Desktop chart of accounts will import into Expensify as categories.',
+ accountsSwitchTitle: 'Choose to import new accounts as enabled or disabled categories.',
+ accountsSwitchDescription: 'Enabled categories will be available for members to select when creating their expenses.',
+ classesDescription: 'Choose how to handle QuickBooks Desktop classes in Expensify.',
+ tagsDisplayedAsDescription: 'Line item level',
+ reportFieldsDisplayedAsDescription: 'Report level',
+ customersDescription: 'Choose how to handle QuickBooks Desktop customers/projects in Expensify.',
+ advancedConfig: {
+ autoSyncDescription: 'Expensify will automatically sync with QuickBooks Desktop every day.',
+ createEntities: 'Auto-create entities',
+ createEntitiesDescription:
+ "Expensify will automatically create vendors in QuickBooks Desktop if they don't exist already, and auto-create customers when exporting invoices.",
+ },
},
qbo: {
importDescription: 'Choose which coding configurations to import from QuickBooks Online to Expensify.',
@@ -3039,6 +3052,13 @@ const translations = {
},
yourCardProvider: `Who's your card provider?`,
whoIsYourBankAccount: 'Who’s your bank?',
+ howDoYouWantToConnect: 'How do you want to connect to your bank?',
+ learnMoreAboutConnections: {
+ text: 'Learn more about the ',
+ linkText: 'connection methods.',
+ },
+ customFeedDetails: 'Requires setup with your bank. This is most common for larger companies, and the best option, if you qualify.',
+ directFeedDetails: 'Connect now using your master credentials. This is most common.',
enableFeed: {
title: ({provider}: GoBackMessageParams) => `Enable your ${provider} feed`,
heading: 'We have a direct integration with your card issuer and can import your transaction data into Expensify quickly and accurately.\n\nTo get started, simply:',
@@ -3064,14 +3084,18 @@ const translations = {
distributionLabel: 'Distribution ID',
},
},
+ amexCorporate: 'Select this if the front of your cards say “Corporate”',
+ amexBusiness: 'Select this if the front of your cards say “Business”',
error: {
pleaseSelectProvider: 'Please select a card provider before continuing.',
pleaseSelectBankAccount: 'Please select a bank account before continuing.',
+ pleaseSelectFeedType: 'Please select a feed type before continuing.',
},
},
assignCard: 'Assign card',
cardNumber: 'Card number',
customFeed: 'Custom feed',
+ directFeed: 'Direct feed',
whoNeedsCardAssigned: 'Who needs a card assigned?',
chooseCard: 'Choose a card',
chooseCardFor: ({assignee, feed}: AssignCardParams) => `Choose a card for ${assignee} from the ${feed} cards feed.`,
@@ -4267,6 +4291,8 @@ const translations = {
current: 'Current',
past: 'Past',
},
+ noCategory: 'No category',
+ noTag: 'No tag',
expenseType: 'Expense type',
recentSearches: 'Recent searches',
recentChats: 'Recent chats',
@@ -4402,7 +4428,7 @@ const translations = {
unshare: ({to}: UnshareParams) => `removed user ${to}`,
stripePaid: ({amount, currency}: StripePaidParams) => `paid ${currency}${amount}`,
takeControl: `took control`,
- integrationSyncFailed: ({label, errorMessage}: IntegrationSyncFailedParams) => `failed to sync with ${label} ("${errorMessage}")`,
+ integrationSyncFailed: ({label, errorMessage}: IntegrationSyncFailedParams) => `failed to sync with ${label}${errorMessage ? ` ("${errorMessage}")` : ''}`,
addEmployee: ({email, role}: AddEmployeeParams) => `added ${email} as ${role === 'user' ? 'member' : 'admin'}`,
updateRole: ({email, currentRole, newRole}: UpdateRoleParams) => `updated the role of ${email} from ${currentRole} to ${newRole}`,
removeMember: ({email, role}: AddEmployeeParams) => `removed ${role} ${email}`,
@@ -5008,8 +5034,7 @@ const translations = {
enterMagicCodeUpdate: ({contactMethod}: EnterMagicCodeParams) => `Please enter the magic code sent to ${contactMethod} to update your copilot.`,
notAllowed: 'Not so fast...',
notAllowedMessageStart: ({accountOwnerEmail}: AccountOwnerParams) => `You don't have permission to take this action for ${accountOwnerEmail} as a`,
- notAllowedMessageHyperLinked: ' limited access',
- notAllowedMessageEnd: ' copilot',
+ notAllowedMessageHyperLinked: ' copilot',
},
debug: {
debug: 'Debug',
@@ -5044,6 +5069,7 @@ const translations = {
reasonVisibleInLHN: {
hasDraftComment: 'Has draft comment',
hasGBR: 'Has GBR',
+ hasRBR: 'Has RBR',
pinnedByUser: 'Pinned by user',
hasIOUViolations: 'Has IOU violations',
hasAddWorkspaceRoomErrors: 'Has add workspace room errors',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index d4615b26a255..22b4167231c0 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -106,7 +106,6 @@ import type {
PayerSettledParams,
PaySomeoneParams,
ReconciliationWorksParams,
- ReimbursementRateParams,
RemovedFromApprovalWorkflowParams,
RemovedTheRequestParams,
RemoveMemberPromptParams,
@@ -267,6 +266,7 @@ const translations = {
close: 'Cerrar',
download: 'Descargar',
downloading: 'Descargando',
+ uploading: 'Subiendo',
pin: 'Fijar',
unPin: 'Desfijar',
back: 'Volver',
@@ -1010,7 +1010,7 @@ const translations = {
changed: 'cambió',
removed: 'eliminó',
transactionPending: 'Transacción pendiente.',
- chooseARate: ({unit}: ReimbursementRateParams) => `Selecciona una tasa de reembolso por ${unit} del espacio de trabajo`,
+ chooseARate: 'Selecciona una tasa de reembolso por milla o kilómetro para el espacio de trabajo',
unapprove: 'Desaprobar',
unapproveReport: 'Anular la aprobación del informe',
headsUp: 'Atención!',
@@ -2476,6 +2476,18 @@ const translations = {
classes: 'Clases',
items: 'Artículos',
customers: 'Clientes/proyectos',
+ accountsDescription: 'Tu plan de cuentas de QuickBooks Desktop se importará a Expensify como categorías.',
+ accountsSwitchTitle: 'Elige importar cuentas nuevas como categorías activadas o desactivadas.',
+ accountsSwitchDescription: 'Las categorías activas estarán disponibles para ser escogidas cuando se crea un gasto.',
+ classesDescription: 'Elige cómo gestionar las clases de QuickBooks Desktop en Expensify.',
+ tagsDisplayedAsDescription: 'Nivel de partida',
+ reportFieldsDisplayedAsDescription: 'Nivel de informe',
+ customersDescription: 'Elige cómo gestionar los clientes/proyectos de QuickBooks Desktop en Expensify.',
+ advancedConfig: {
+ autoSyncDescription: 'Expensify se sincronizará automáticamente con QuickBooks Desktop todos los días.',
+ createEntities: 'Crear entidades automáticamente',
+ createEntitiesDescription: 'Expensify creará automáticamente proveedores en QuickBooks Desktop si aún no existen, y creará automáticamente clientes al exportar facturas.',
+ },
},
qbo: {
importDescription: 'Elige que configuraciónes de codificación son importadas desde QuickBooks Online a Expensify.',
@@ -3077,6 +3089,13 @@ const translations = {
},
yourCardProvider: `¿Quién es su proveedor de tarjetas?`,
whoIsYourBankAccount: '¿Cuál es tu banco?',
+ howDoYouWantToConnect: '¿Cómo deseas conectarte a tu banco?',
+ learnMoreAboutConnections: {
+ text: 'Obtén más información sobre ',
+ linkText: 'los métodos de conexión.',
+ },
+ customFeedDetails: 'Requiere configuración con tu banco. Esto es más común para empresas grandes, y la mejor opción, si calificas.',
+ directFeedDetails: 'Conéctate ahora usando tus credenciales maestras. Esto es lo más común.',
enableFeed: {
title: ({provider}: GoBackMessageParams) => `Habilita tu feed ${provider}`,
heading:
@@ -3103,14 +3122,18 @@ const translations = {
distributionLabel: 'ID de distribución',
},
},
+ amexCorporate: 'Seleccione esto si el frente de sus tarjetas dice “Corporativa”',
+ amexBusiness: 'Seleccione esta opción si el frente de sus tarjetas dice “Negocios”',
error: {
pleaseSelectProvider: 'Seleccione un proveedor de tarjetas antes de continuar.',
pleaseSelectBankAccount: 'Seleccione una cuenta bancaria antes de continuar.',
+ pleaseSelectFeedType: 'Seleccione un tipo de pienso antes de continuar.',
},
},
assignCard: 'Asignar tarjeta',
cardNumber: 'Número de la tarjeta',
customFeed: 'Fuente personalizada',
+ directFeed: 'Fuente directa',
whoNeedsCardAssigned: '¿Quién necesita una tarjeta?',
chooseCard: 'Elige una tarjeta',
chooseCardFor: ({assignee, feed}: AssignCardParams) => `Elige una tarjeta para ${assignee} del feed de tarjetas ${feed}.`,
@@ -4314,6 +4337,8 @@ const translations = {
current: 'Actual',
past: 'Anterior',
},
+ noCategory: 'Sin categoría',
+ noTag: 'Sin etiqueta',
expenseType: 'Tipo de gasto',
recentSearches: 'Búsquedas recientes',
recentChats: 'Chats recientes',
@@ -4331,7 +4356,7 @@ const translations = {
},
fileDownload: {
success: {
- title: '!Descargado!',
+ title: '¡Descargado!',
message: 'Archivo descargado correctamente',
qrMessage:
'Busca la copia de tu código QR en la carpeta de fotos o descargas. Consejo: Añádelo a una presentación para que el público pueda escanearlo y conectar contigo directamente.',
@@ -4450,7 +4475,7 @@ const translations = {
unshare: ({to}: UnshareParams) => `usuario eliminado ${to}`,
stripePaid: ({amount, currency}: StripePaidParams) => `pagado ${currency}${amount}`,
takeControl: `tomó el control`,
- integrationSyncFailed: ({label, errorMessage}: IntegrationSyncFailedParams) => `no se pudo sincronizar con ${label} ("${errorMessage}")`,
+ integrationSyncFailed: ({label, errorMessage}: IntegrationSyncFailedParams) => `no se pudo sincronizar con ${label}${errorMessage ? ` ("${errorMessage}")` : ''}`,
addEmployee: ({email, role}: AddEmployeeParams) => `agregó a ${email} como ${role === 'user' ? 'miembro' : 'administrador'}`,
updateRole: ({email, currentRole, newRole}: UpdateRoleParams) =>
`actualicé el rol ${email} de ${currentRole === 'user' ? 'miembro' : 'administrador'} a ${newRole === 'user' ? 'miembro' : 'administrador'}`,
@@ -5171,7 +5196,7 @@ const translations = {
overLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Importe supera el límite${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`,
overLimitAttendee: ({formattedLimit}: ViolationsOverLimitParams) => `Importe supera el límite${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`,
perDayLimit: ({formattedLimit}: ViolationsPerDayLimitParams) => `Importe supera el límite diario de la categoría${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`,
- receiptNotSmartScanned: 'Recibo no verificado. Por favor, confirma tu exactitud',
+ receiptNotSmartScanned: 'Recibo no verificado. Por favor, confirma la exactitud',
receiptRequired: ({formattedLimit, category}: ViolationsReceiptRequiredParams) => {
let message = 'Recibo obligatorio';
if (formattedLimit ?? category) {
@@ -5523,8 +5548,7 @@ const translations = {
`Por favor, introduce el código mágico enviado a ${contactMethod} para actualizar el nivel de acceso de tu copiloto.`,
notAllowed: 'No tan rápido...',
notAllowedMessageStart: ({accountOwnerEmail}: AccountOwnerParams) => `No tienes permiso para realizar esta acción para ${accountOwnerEmail}`,
- notAllowedMessageHyperLinked: ' copiloto con acceso',
- notAllowedMessageEnd: ' limitado',
+ notAllowedMessageHyperLinked: ' copiloto',
},
debug: {
debug: 'Depuración',
@@ -5559,6 +5583,7 @@ const translations = {
reasonVisibleInLHN: {
hasDraftComment: 'Tiene comentario en borrador',
hasGBR: 'Tiene GBR',
+ hasRBR: 'Tiene RBR',
pinnedByUser: 'Fijado por el usuario',
hasIOUViolations: 'Tiene violaciones de IOU',
hasAddWorkspaceRoomErrors: 'Tiene errores al agregar sala de espacio de trabajo',
diff --git a/src/languages/params.ts b/src/languages/params.ts
index f787e630ab0d..9fd980d1e98f 100644
--- a/src/languages/params.ts
+++ b/src/languages/params.ts
@@ -1,6 +1,6 @@
import type {OnyxInputOrEntry, ReportAction} from '@src/types/onyx';
import type {DelegateRole} from '@src/types/onyx/Account';
-import type {AllConnectionName, ConnectionName, PolicyConnectionSyncStage, SageIntacctMappingName, Unit} from '@src/types/onyx/Policy';
+import type {AllConnectionName, ConnectionName, PolicyConnectionSyncStage, SageIntacctMappingName} from '@src/types/onyx/Policy';
import type {ViolationDataType} from '@src/types/onyx/TransactionViolation';
type AddressLineParams = {
@@ -279,8 +279,6 @@ type LogSizeAndDateParams = {size: number; date: string};
type HeldRequestParams = {comment: string};
-type ReimbursementRateParams = {unit: Unit};
-
type ChangeFieldParams = {oldValue?: string; newValue: string; fieldName: string};
type ChangePolicyParams = {fromPolicy: string; toPolicy: string};
@@ -648,7 +646,6 @@ export type {
PayerPaidAmountParams,
PayerPaidParams,
PayerSettledParams,
- ReimbursementRateParams,
RemovedTheRequestParams,
RenamedRoomActionParams,
ReportArchiveReasonsClosedParams,
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index a367314166e2..9d7f12e54526 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -258,10 +258,17 @@ const WRITE_COMMANDS = {
UPDATE_QUICKBOOKS_ONLINE_SYNC_PEOPLE: 'UpdateQuickbooksOnlineSyncPeople',
UPDATE_QUICKBOOKS_ONLINE_REIMBURSEMENT_ACCOUNT_ID: 'UpdateQuickbooksOnlineReimbursementAccountID',
UPDATE_QUICKBOOKS_ONLINE_EXPORT: 'UpdateQuickbooksOnlineExport',
+ UPDATE_QUICKBOOKS_DESKTOP_EXPORT_DATE: 'UpdateQuickbooksDesktopExportDate',
UPDATE_MANY_POLICY_CONNECTION_CONFIGS: 'UpdateManyPolicyConnectionConfigurations',
+ UPDATE_QUICKBOOKS_DESKTOP_AUTO_CREATE_VENDOR: 'UpdateQuickbooksDesktopAutoCreateVendor',
+ UPDATE_QUICKBOOKS_DESKTOP_AUTO_SYNC: 'UpdateQuickbooksDesktopAutoSync',
+ UPDATE_QUICKBOOKS_DESKTOP_EXPORT: 'UpdateQuickbooksDesktopExport',
UPDATE_QUICKBOOKS_DESKTOP_REIMBURSABLE_EXPENSES_ACCOUNT: 'UpdateQuickbooksDesktopReimbursableExpensesAccount',
UPDATE_QUICKBOOKS_DESKTOP_MARK_CHECKS_TO_BE_PRINTED: 'UpdateQuickbooksDesktopMarkChecksToBePrinted',
UPDATE_QUICKBOOKS_DESKTOP_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION: 'UpdateQuickbooksDesktopReimbursableExpensesExportDestination',
+ UPDATE_QUICKBOOKS_DESKTOP_ENABLE_NEW_CATEGORIES: 'UpdateQuickbooksDesktopEnableNewCategories',
+ UPDATE_QUICKBOOKS_DESKTOP_SYNC_CLASSES: 'UpdateQuickbooksDesktopSyncClasses',
+ UPDATE_QUICKBOOKS_DESKTOP_SYNC_CUSTOMERS: 'UpdateQuickbooksDesktopSyncCustomers',
REMOVE_POLICY_CONNECTION: 'RemovePolicyConnection',
SET_POLICY_TAXES_ENABLED: 'SetPolicyTaxesEnabled',
DELETE_POLICY_TAXES: 'DeletePolicyTaxes',
@@ -693,9 +700,16 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_EXPORT_DATE]: Parameters.UpdateQuickbooksOnlineGenericTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_EXPENSES_ACCOUNT]: Parameters.UpdateQuickbooksOnlineGenericTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_COLLECTION_ACCOUNT_ID]: Parameters.UpdateQuickbooksOnlineGenericTypeParams;
+ [WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_EXPORT_DATE]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_MARK_CHECKS_TO_BE_PRINTED]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
+ [WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_AUTO_CREATE_VENDOR]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
+ [WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_AUTO_SYNC]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_REIMBURSABLE_EXPENSES_ACCOUNT]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: Parameters.UpdateQuickbooksDesktopExpensesExportDestinationTypeParams;
+ [WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_ENABLE_NEW_CATEGORIES]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
+ [WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_SYNC_CLASSES]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
+ [WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_SYNC_CUSTOMERS]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
+ [WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_EXPORT]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
[WRITE_COMMANDS.UPDATE_POLICY_CONNECTION_CONFIG]: Parameters.UpdatePolicyConnectionConfigParams;
[WRITE_COMMANDS.UPDATE_MANY_POLICY_CONNECTION_CONFIGS]: Parameters.UpdateManyPolicyConnectionConfigurationsParams;
[WRITE_COMMANDS.REMOVE_POLICY_CONNECTION]: Parameters.RemovePolicyConnectionParams;
diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts
index bd8499fa168f..687f659ae622 100644
--- a/src/libs/CardUtils.ts
+++ b/src/libs/CardUtils.ts
@@ -242,6 +242,40 @@ const getBankCardDetailsImage = (bank: ValueOf
return iconMap[bank];
};
+// We will simplify the logic below once we have #50450 #50451 implemented
+const getCorrectStepForSelectedBank = (selectedBank: ValueOf) => {
+ const banksWithFeedType = [
+ CONST.COMPANY_CARDS.BANKS.BANK_OF_AMERICA,
+ CONST.COMPANY_CARDS.BANKS.CAPITAL_ONE,
+ CONST.COMPANY_CARDS.BANKS.CHASE,
+ CONST.COMPANY_CARDS.BANKS.CITI_BANK,
+ CONST.COMPANY_CARDS.BANKS.WELLS_FARGO,
+ ];
+
+ if (selectedBank === CONST.COMPANY_CARDS.BANKS.STRIPE) {
+ // TODO https://github.com/Expensify/App/issues/50450
+ return;
+ }
+
+ if (selectedBank === CONST.COMPANY_CARDS.BANKS.AMEX) {
+ return CONST.COMPANY_CARDS.STEP.AMEX_CUSTOM_FEED;
+ }
+
+ if (selectedBank === CONST.COMPANY_CARDS.BANKS.BREX) {
+ return CONST.COMPANY_CARDS.STEP.BANK_CONNECTION;
+ }
+
+ if (selectedBank === CONST.COMPANY_CARDS.BANKS.OTHER) {
+ return CONST.COMPANY_CARDS.STEP.CARD_TYPE;
+ }
+
+ if (banksWithFeedType.includes(selectedBank)) {
+ return CONST.COMPANY_CARDS.STEP.SELECT_FEED_TYPE;
+ }
+
+ return CONST.COMPANY_CARDS.STEP.CARD_TYPE;
+};
+
export {
isExpensifyCard,
isCorporateCard,
@@ -261,4 +295,5 @@ export {
getCardDetailsImage,
getMemberCards,
getBankCardDetailsImage,
+ getCorrectStepForSelectedBank,
};
diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts
index 7e6748684022..2cab87639d2f 100644
--- a/src/libs/DateUtils.ts
+++ b/src/libs/DateUtils.ts
@@ -10,7 +10,7 @@ import {
endOfMonth,
endOfWeek,
format,
- formatDistanceToNow,
+ formatDistance,
getDate,
getDay,
isAfter,
@@ -249,7 +249,8 @@ function datetimeToCalendarTime(locale: Locale, datetime: string, includeTimeZon
*/
function datetimeToRelative(locale: Locale, datetime: string): string {
const date = getLocalDateFromDatetime(locale, datetime);
- return formatDistanceToNow(date, {addSuffix: true, locale: locale === CONST.LOCALES.EN ? enGB : es});
+ const now = getLocalDateFromDatetime(locale);
+ return formatDistance(date, now, {addSuffix: true, locale: locale === CONST.LOCALES.EN ? enGB : es});
}
/**
diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts
index 939bd3d1aa10..e7ad63467781 100644
--- a/src/libs/DebugUtils.ts
+++ b/src/libs/DebugUtils.ts
@@ -6,7 +6,6 @@ import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Beta, Policy, Report, ReportAction, ReportActions, TransactionViolation} from '@src/types/onyx';
-import * as OptionsListUtils from './OptionsListUtils';
import * as ReportUtils from './ReportUtils';
class NumberError extends SyntaxError {
@@ -592,12 +591,12 @@ function validateReportActionJSON(json: string) {
/**
* Gets the reason for showing LHN row
*/
-function getReasonForShowingRowInLHN(report: OnyxEntry): TranslationPaths | null {
+function getReasonForShowingRowInLHN(report: OnyxEntry, hasRBR = false): TranslationPaths | null {
if (!report) {
return null;
}
- const doesReportHaveViolations = OptionsListUtils.shouldShowViolations(report, transactionViolations);
+ const doesReportHaveViolations = ReportUtils.shouldShowViolations(report, transactionViolations);
const reason = ReportUtils.reasonForReportToBeInOptionList({
report,
@@ -611,7 +610,12 @@ function getReasonForShowingRowInLHN(report: OnyxEntry): TranslationPath
includeSelfDM: true,
});
- // When there's no specific reason, we default to isFocused since the report is only showing because we're viewing it
+ if (!([CONST.REPORT_IN_LHN_REASONS.HAS_ADD_WORKSPACE_ROOM_ERRORS, CONST.REPORT_IN_LHN_REASONS.HAS_IOU_VIOLATIONS] as Array).includes(reason) && hasRBR) {
+ return `debug.reasonVisibleInLHN.hasRBR`;
+ }
+
+ // When there's no specific reason, we default to isFocused if the report is only showing because we're viewing it
+ // Otherwise we return hasRBR if the report has errors other that failed receipt
if (reason === null || reason === CONST.REPORT_IN_LHN_REASONS.DEFAULT) {
return 'debug.reasonVisibleInLHN.isFocused';
}
@@ -645,7 +649,7 @@ function getReasonAndReportActionForGBRInLHNRow(report: OnyxEntry): GBRR
* Gets the report action that is causing the RBR to show up in LHN
*/
function getRBRReportAction(report: OnyxEntry, reportActions: OnyxEntry): OnyxEntry {
- const {reportAction} = OptionsListUtils.getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions);
+ const {reportAction} = ReportUtils.getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions);
return reportAction;
}
diff --git a/src/libs/E2E/reactNativeLaunchingTest.ts b/src/libs/E2E/reactNativeLaunchingTest.ts
index f952998f0aad..fdd305baf88c 100644
--- a/src/libs/E2E/reactNativeLaunchingTest.ts
+++ b/src/libs/E2E/reactNativeLaunchingTest.ts
@@ -32,7 +32,7 @@ if (!appInstanceId) {
// import your test here, define its name and config first in e2e/config.js
const tests: Tests = {
[E2EConfig.TEST_NAMES.AppStartTime]: require('./tests/appStartTimeTest.e2e').default,
- [E2EConfig.TEST_NAMES.OpenChatFinderPage]: require('./tests/openChatFinderPageTest.e2e').default,
+ [E2EConfig.TEST_NAMES.OpenSearchRouter]: require('./tests/openSearchRouterTest.e2e').default,
[E2EConfig.TEST_NAMES.ChatOpening]: require('./tests/chatOpeningTest.e2e').default,
[E2EConfig.TEST_NAMES.ReportTyping]: require('./tests/reportTypingTest.e2e').default,
[E2EConfig.TEST_NAMES.Linking]: require('./tests/linkingTest.e2e').default,
diff --git a/src/libs/E2E/tests/openChatFinderPageTest.e2e.ts b/src/libs/E2E/tests/openSearchRouterTest.e2e.ts
similarity index 72%
rename from src/libs/E2E/tests/openChatFinderPageTest.e2e.ts
rename to src/libs/E2E/tests/openSearchRouterTest.e2e.ts
index 2c2f2eda4efe..840af5acc2c9 100644
--- a/src/libs/E2E/tests/openChatFinderPageTest.e2e.ts
+++ b/src/libs/E2E/tests/openSearchRouterTest.e2e.ts
@@ -3,14 +3,12 @@ import E2ELogin from '@libs/E2E/actions/e2eLogin';
import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded';
import E2EClient from '@libs/E2E/client';
import getPromiseWithResolve from '@libs/E2E/utils/getPromiseWithResolve';
-import Navigation from '@libs/Navigation/Navigation';
import Performance from '@libs/Performance';
import CONST from '@src/CONST';
-import ROUTES from '@src/ROUTES';
const test = () => {
// check for login (if already logged in the action will simply resolve)
- console.debug('[E2E] Logging in for chat finder');
+ console.debug('[E2E] Logging in for new search router');
E2ELogin().then((neededLogin: boolean): Promise | undefined => {
if (neededLogin) {
@@ -20,36 +18,29 @@ const test = () => {
);
}
- console.debug('[E2E] Logged in, getting chat finder metrics and submitting them…');
+ console.debug('[E2E] Logged in, getting search router metrics and submitting them…');
- const [openSearchPagePromise, openSearchPageResolve] = getPromiseWithResolve();
+ const [openSearchRouterPromise, openSearchRouterResolve] = getPromiseWithResolve();
const [loadSearchOptionsPromise, loadSearchOptionsResolve] = getPromiseWithResolve();
- Promise.all([openSearchPagePromise, loadSearchOptionsPromise]).then(() => {
+ Promise.all([openSearchRouterPromise, loadSearchOptionsPromise]).then(() => {
console.debug(`[E2E] Submitting!`);
E2EClient.submitTestDone();
});
Performance.subscribeToMeasurements((entry) => {
- if (entry.name === CONST.TIMING.SIDEBAR_LOADED) {
- console.debug(`[E2E] Sidebar loaded, navigating to chat finder route…`);
- Performance.markStart(CONST.TIMING.CHAT_FINDER_RENDER);
- Navigation.navigate(ROUTES.CHAT_FINDER);
- return;
- }
-
console.debug(`[E2E] Entry: ${JSON.stringify(entry)}`);
- if (entry.name === CONST.TIMING.CHAT_FINDER_RENDER) {
+ if (entry.name === CONST.TIMING.SEARCH_ROUTER_RENDER) {
E2EClient.submitTestResults({
branch: Config.E2E_BRANCH,
- name: 'Open Chat Finder Page TTI',
+ name: 'Open Search Router TTI',
metric: entry.duration,
unit: 'ms',
})
.then(() => {
- openSearchPageResolve();
+ openSearchRouterResolve();
console.debug('[E2E] Done with search, exiting…');
})
.catch((err) => {
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index 21bb35c70006..7b8589c81e7f 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -7,6 +7,7 @@ import ActiveGuidesEventListener from '@components/ActiveGuidesEventListener';
import ComposeProviders from '@components/ComposeProviders';
import OptionsListContextProvider from '@components/OptionListContextProvider';
import {SearchContextProvider} from '@components/Search/SearchContext';
+import {useSearchRouterContext} from '@components/Search/SearchRouter/SearchRouterContext';
import SearchRouterModal from '@components/Search/SearchRouter/SearchRouterModal';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useOnboardingFlowRouter from '@hooks/useOnboardingFlow';
@@ -21,6 +22,7 @@ import Log from '@libs/Log';
import getCurrentUrl from '@libs/Navigation/currentUrl';
import getOnboardingModalScreenOptions from '@libs/Navigation/getOnboardingModalScreenOptions';
import Navigation from '@libs/Navigation/Navigation';
+import shouldOpenOnAdminRoom from '@libs/Navigation/shouldOpenOnAdminRoom';
import type {AuthScreensParamList, CentralPaneName, CentralPaneScreensParamList} from '@libs/Navigation/types';
import NetworkConnection from '@libs/NetworkConnection';
import onyxSubscribe from '@libs/onyxSubscribe';
@@ -89,11 +91,6 @@ const loadReportAvatar = () => require('../../../pages/Rep
const loadReceiptView = () => require('../../../pages/TransactionReceiptPage').default;
const loadWorkspaceJoinUser = () => require('@pages/workspace/WorkspaceJoinUserPage').default;
-function shouldOpenOnAdminRoom() {
- const url = getCurrentUrl();
- return url ? new URL(url).searchParams.get('openOnAdminRoom') === 'true' : false;
-}
-
function getCentralPaneScreenInitialParams(screenName: CentralPaneName, initialReportID?: string): Partial> {
if (screenName === SCREENS.SEARCH.CENTRAL_PANE) {
// Generate default query string with buildSearchQueryString without argument.
@@ -155,7 +152,7 @@ Onyx.connect({
Onyx.connect({
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (value) => {
- if (!value || timezone) {
+ if (!value || !isEmptyObject(timezone)) {
return;
}
@@ -232,6 +229,8 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie
const screenOptions = getRootNavigatorScreenOptions(shouldUseNarrowLayout, styles, StyleUtils);
const {canUseDefaultRooms} = usePermissions();
const {activeWorkspaceID} = useActiveWorkspace();
+ const {toggleSearchRouter} = useSearchRouterContext();
+
const onboardingModalScreenOptions = useMemo(() => screenOptions.onboardingModalNavigator(onboardingIsMediumOrLargerScreenWidth), [screenOptions, onboardingIsMediumOrLargerScreenWidth]);
const onboardingScreenOptions = useMemo(
() => getOnboardingModalScreenOptions(shouldUseNarrowLayout, styles, StyleUtils, onboardingIsMediumOrLargerScreenWidth),
@@ -240,6 +239,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie
const modal = useRef({});
const [didPusherInit, setDidPusherInit] = useState(false);
const {isOnboardingCompleted} = useOnboardingFlowRouter();
+
let initialReportID: string | undefined;
const isInitialRender = useRef(true);
if (isInitialRender.current) {
@@ -350,16 +350,14 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie
);
// Listen for the key K being pressed so that focus can be given to
- // the chat switcher, or new group chat
+ // Search Router, or new group chat
// based on the key modifiers pressed and the operating system
const unsubscribeSearchShortcut = KeyboardShortcut.subscribe(
searchShortcutConfig.shortcutKey,
() => {
- Modal.close(
- Session.checkIfActionIsAllowed(() => Navigation.navigate(ROUTES.CHAT_FINDER)),
- true,
- true,
- );
+ Session.checkIfActionIsAllowed(() => {
+ toggleSearchRouter();
+ })();
},
shortcutsOverviewShortcutConfig.descriptionKey,
shortcutsOverviewShortcutConfig.modifiers,
@@ -589,6 +587,10 @@ AuthScreens.displayName = 'AuthScreens';
const AuthScreensMemoized = memo(AuthScreens, () => true);
+// Migration to useOnyx cause re-login if logout from deeplinked report in desktop app
+// Further analysis required and more details can be seen here:
+// https://github.com/Expensify/App/issues/50560
+// eslint-disable-next-line
export default withOnyx({
session: {
key: ONYXKEYS.SESSION,
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index 6c209b5309f7..fabf7fb78591 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -312,6 +312,10 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_EXPORT_PREFERRED_EXPORTER]: () =>
require('../../../../pages/workspace/accounting/qbo/export/QuickbooksPreferredExporterConfigurationPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_DATE_SELECT]: () =>
+ require('../../../../pages/workspace/accounting/qbd/export/QuickbooksDesktopExportDateSelectPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_PREFERRED_EXPORTER]: () =>
+ require('../../../../pages/workspace/accounting/qbd/export/QuickbooksDesktopPreferredExporterConfigurationPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT]: () =>
require('../../../../pages/workspace/accounting/qbd/export/QuickbooksDesktopOutOfPocketExpenseAccountSelectPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES]: () =>
@@ -319,12 +323,23 @@ const SettingsModalStackNavigator = createModalStackNavigator
require('../../../../pages/workspace/accounting/qbd/export/QuickbooksDesktopOutOfPocketExpenseEntitySelectPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT]: () => require('../../../../pages/workspace/accounting/qbd/export/QuickbooksDesktopExportPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_ADVANCED]: () =>
+ require('../../../../pages/workspace/accounting/qbd/advanced/QuickbooksDesktopAdvancedPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_SETUP_MODAL]: () => require('../../../../pages/workspace/accounting/qbd/QuickBooksDesktopSetupPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_SETUP_REQUIRED_DEVICE_MODAL]: () =>
require('../../../../pages/workspace/accounting/qbd/RequireQuickBooksDesktopPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_TRIGGER_FIRST_SYNC]: () =>
require('../../../../pages/workspace/accounting/qbd/QuickBooksDesktopSetupFlowSyncPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_IMPORT]: () => require('../../../../pages/workspace/accounting/qbd/import/QuickbooksDesktopImportPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CHART_OF_ACCOUNTS]: () =>
+ require('../../../../pages/workspace/accounting/qbd/import/QuickbooksDesktopChartOfAccountsPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CLASSES]: () => require('../../../../pages/workspace/accounting/qbd/import/QuickbooksDesktopClassesPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CLASSES_DISPLAYED_AS]: () =>
+ require('../../../../pages/workspace/accounting/qbd/import/QuickbooksDesktopClassesDisplayedAsPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS]: () =>
+ require('../../../../pages/workspace/accounting/qbd/import/QuickbooksDesktopCustomersPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS]: () =>
+ require('../../../../pages/workspace/accounting/qbd/import/QuickbooksDesktopCustomersDisplayedAsPage').default,
[SCREENS.REIMBURSEMENT_ACCOUNT]: () => require('../../../../pages/ReimbursementAccount/ReimbursementAccountPage').default,
[SCREENS.GET_ASSISTANCE]: () => require('../../../../pages/GetAssistancePage').default,
[SCREENS.SETTINGS.TWO_FACTOR_AUTH]: () => require('../../../../pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage').default,
diff --git a/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx
index 077bdce94545..50439c19845e 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx
@@ -14,7 +14,6 @@ import Overlay from './Overlay';
type LeftModalNavigatorProps = StackScreenProps;
-const loadChatFinder = () => require('../../../../pages/ChatFinderPage').default;
const loadWorkspaceSwitcherPage = () => require('../../../../pages/WorkspaceSwitcherPage').default;
const Stack = createStackNavigator();
@@ -37,10 +36,6 @@ function LeftModalNavigator({navigation}: LeftModalNavigatorProps) {
screenOptions={screenOptions}
id={NAVIGATORS.LEFT_MODAL_NAVIGATOR}
>
- sessionValue && {authTokenType: sessionValue.authTokenType}});
@@ -63,7 +56,7 @@ function TopBar({breadcrumbLabel, activeWorkspaceID, shouldDisplaySearch = true,
{displaySignIn && }
- {isCustomSearchQuery && (
+ {shouldDisplayCancelSearch && (
{translate('common.cancel')}
)}
- {shouldDisplaySearchRouter && }
- {displaySearch && (
-
- {
- Timing.start(CONST.TIMING.CHAT_FINDER_RENDER);
- Performance.markStart(CONST.TIMING.CHAT_FINDER_RENDER);
- Navigation.navigate(ROUTES.CHAT_FINDER);
- })}
- >
-
-
-
- )}
+ {displaySearch && }
);
diff --git a/src/libs/Navigation/AppNavigator/createCustomPlatformStackBottomTabNavigator/TopBar.tsx b/src/libs/Navigation/AppNavigator/createCustomPlatformStackBottomTabNavigator/TopBar.tsx
index 4684eb9637be..8967486165f8 100644
--- a/src/libs/Navigation/AppNavigator/createCustomPlatformStackBottomTabNavigator/TopBar.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomPlatformStackBottomTabNavigator/TopBar.tsx
@@ -2,32 +2,25 @@ import React from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import Breadcrumbs from '@components/Breadcrumbs';
-import Icon from '@components/Icon';
-import * as Expensicons from '@components/Icon/Expensicons';
import {PressableWithoutFeedback} from '@components/Pressable';
import SearchButton from '@components/Search/SearchRouter/SearchButton';
import Text from '@components/Text';
-import Tooltip from '@components/Tooltip';
import WorkspaceSwitcherButton from '@components/WorkspaceSwitcherButton';
import useLocalize from '@hooks/useLocalize';
import usePolicy from '@hooks/usePolicy';
-import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
-import Performance from '@libs/Performance';
import * as SearchUtils from '@libs/SearchUtils';
import SignInButton from '@pages/home/sidebar/SignInButton';
import * as Session from '@userActions/Session';
-import Timing from '@userActions/Timing';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-type TopBarProps = {breadcrumbLabel: string; activeWorkspaceID?: string; shouldDisplaySearch?: boolean; isCustomSearchQuery?: boolean; shouldDisplaySearchRouter?: boolean};
+type TopBarProps = {breadcrumbLabel: string; activeWorkspaceID?: string; shouldDisplaySearch?: boolean; shouldDisplayCancelSearch?: boolean};
-function TopBar({breadcrumbLabel, activeWorkspaceID, shouldDisplaySearch = true, isCustomSearchQuery = false, shouldDisplaySearchRouter = false}: TopBarProps) {
+function TopBar({breadcrumbLabel, activeWorkspaceID, shouldDisplaySearch = true, shouldDisplayCancelSearch = false}: TopBarProps) {
const styles = useThemeStyles();
- const theme = useTheme();
const {translate} = useLocalize();
const policy = usePolicy(activeWorkspaceID);
const [session] = useOnyx(ONYXKEYS.SESSION, {selector: (sessionValue) => sessionValue && {authTokenType: sessionValue.authTokenType}});
@@ -63,7 +56,7 @@ function TopBar({breadcrumbLabel, activeWorkspaceID, shouldDisplaySearch = true,
{displaySignIn && }
- {isCustomSearchQuery && (
+ {shouldDisplayCancelSearch && (
{translate('common.cancel')}
)}
- {shouldDisplaySearchRouter && }
- {displaySearch && (
-
- {
- Timing.start(CONST.TIMING.CHAT_FINDER_RENDER);
- Performance.markStart(CONST.TIMING.CHAT_FINDER_RENDER);
- Navigation.navigate(ROUTES.CHAT_FINDER);
- })}
- >
-
-
-
- )}
+ {displaySearch && }
);
diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts
index 411cbf1d26b0..8156425fa904 100644
--- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts
+++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts
@@ -13,6 +13,7 @@ import * as Welcome from '@userActions/Welcome';
import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
import SCREENS from '@src/SCREENS';
+import syncBrowserHistory from './syncBrowserHistory';
import type {ResponsiveStackNavigatorRouterOptions} from './types';
function insertRootRoute(state: State, routeToInsert: NavigationPartialRoute) {
@@ -114,11 +115,10 @@ function shouldPreventReset(state: StackNavigationState, action:
// We want to prevent the user from navigating back to a non-onboarding screen if they are currently on an onboarding screen
if (isOnboardingFlowName(currentFocusedRoute?.name) && !isOnboardingFlowName(targetFocusedRoute?.name)) {
Welcome.setOnboardingErrorMessage(Localize.translateLocal('onboarding.purpose.errorBackButton'));
- // We reset the URL as the browser sets it in a way that doesn't match the navigation state
- // eslint-disable-next-line no-restricted-globals
- history.replaceState({}, '', getPathFromState(state, linkingConfig.config));
return true;
}
+
+ return false;
}
function CustomRouter(options: ResponsiveStackNavigatorRouterOptions) {
@@ -133,6 +133,7 @@ function CustomRouter(options: ResponsiveStackNavigatorRouterOptions) {
},
getStateForAction(state: StackNavigationState, action: CommonActions.Action | StackActionType, configOptions: RouterConfigOptions) {
if (shouldPreventReset(state, action)) {
+ syncBrowserHistory(state);
return state;
}
return stackRouter.getStateForAction(state, action, configOptions);
diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/syncBrowserHistory/index.ts b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/syncBrowserHistory/index.ts
new file mode 100644
index 000000000000..612c23238619
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/syncBrowserHistory/index.ts
@@ -0,0 +1,5 @@
+import noop from 'lodash/noop';
+
+const syncBrowserHistory = noop;
+
+export default syncBrowserHistory;
diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/syncBrowserHistory/index.web.ts b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/syncBrowserHistory/index.web.ts
new file mode 100644
index 000000000000..e85ffded64c1
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/syncBrowserHistory/index.web.ts
@@ -0,0 +1,11 @@
+import type {ParamListBase, StackNavigationState} from '@react-navigation/native';
+import {getPathFromState} from '@react-navigation/native';
+import linkingConfig from '@libs/Navigation/linkingConfig';
+
+function syncBrowserHistory(state: StackNavigationState) {
+ // We reset the URL as the browser sets it in a way that doesn't match the navigation state
+ // eslint-disable-next-line no-restricted-globals
+ history.replaceState({}, '', getPathFromState(state, linkingConfig.config));
+}
+
+export default syncBrowserHistory;
diff --git a/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/CustomRouter.ts b/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/CustomRouter.ts
index b57d8c3d9faa..5098365879be 100644
--- a/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/CustomRouter.ts
+++ b/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/CustomRouter.ts
@@ -3,6 +3,7 @@ import {findFocusedRoute, getPathFromState, StackRouter} from '@react-navigation
import type {ParamListBase} from '@react-navigation/routers';
import getIsNarrowLayout from '@libs/getIsNarrowLayout';
import * as Localize from '@libs/Localize';
+import syncBrowserHistory from '@libs/Navigation/AppNavigator/createCustomStackNavigator/syncBrowserHistory';
import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute';
import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute';
import linkingConfig from '@libs/Navigation/linkingConfig';
@@ -114,11 +115,10 @@ function shouldPreventReset(state: StackNavigationState, action:
// We want to prevent the user from navigating back to a non-onboarding screen if they are currently on an onboarding screen
if (isOnboardingFlowName(currentFocusedRoute?.name) && !isOnboardingFlowName(targetFocusedRoute?.name)) {
Welcome.setOnboardingErrorMessage(Localize.translateLocal('onboarding.purpose.errorBackButton'));
- // We reset the URL as the browser sets it in a way that doesn't match the navigation state
- // eslint-disable-next-line no-restricted-globals
- history.replaceState({}, '', getPathFromState(state, linkingConfig.config));
return true;
}
+
+ return false;
}
function CustomRouter(options: PlatformStackRouterOptions) {
@@ -133,6 +133,7 @@ function CustomRouter(options: PlatformStackRouterOptions) {
},
getStateForAction(state: StackNavigationState, action: CommonActions.Action | StackActionType, configOptions: RouterConfigOptions) {
if (shouldPreventReset(state, action)) {
+ syncBrowserHistory(state);
return state;
}
return stackRouter.getStateForAction(state, action, configOptions);
diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx
index a6a77403f148..0e7dfd4a0a0b 100644
--- a/src/libs/Navigation/OnyxTabNavigator.tsx
+++ b/src/libs/Navigation/OnyxTabNavigator.tsx
@@ -123,8 +123,12 @@ function OnyxTabNavigator({
const state = event.data.state;
const index = state.index;
const routeNames = state.routeNames;
- Tab.setSelectedTab(id, routeNames.at(index) as SelectedTabRequest);
- onTabSelected(routeNames.at(index) as IOURequestType);
+ const newSelectedTab = routeNames.at(index);
+ if (selectedTab === newSelectedTab) {
+ return;
+ }
+ Tab.setSelectedTab(id, newSelectedTab as SelectedTabRequest);
+ onTabSelected(newSelectedTab as IOURequestType);
},
...(screenListeners ?? {}),
}}
diff --git a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts
index 6151f8f38996..cec9e86c5be4 100755
--- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts
@@ -38,7 +38,6 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial> =
SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD,
SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS,
SCREENS.SETTINGS.WALLET.VERIFY_ACCOUNT,
- SCREENS.SETTINGS.ADD_BANK_ACCOUNT,
],
[SCREENS.SETTINGS.SECURITY]: [
SCREENS.SETTINGS.TWO_FACTOR_AUTH,
diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
index 4ff7383b4075..fb13ecdf8459 100755
--- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
@@ -45,6 +45,9 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_ADVANCED,
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_ACCOUNT_SELECTOR,
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR,
+ SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_ADVANCED,
+ SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_DATE_SELECT,
+ SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_PREFERRED_EXPORTER,
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES,
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT,
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT,
@@ -53,6 +56,11 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_SETUP_REQUIRED_DEVICE_MODAL,
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_TRIGGER_FIRST_SYNC,
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_IMPORT,
+ SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CHART_OF_ACCOUNTS,
+ SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CLASSES,
+ SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CLASSES_DISPLAYED_AS,
+ SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS,
+ SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS,
SCREENS.WORKSPACE.ACCOUNTING.XERO_IMPORT,
SCREENS.WORKSPACE.ACCOUNTING.XERO_CHART_OF_ACCOUNTS,
SCREENS.WORKSPACE.ACCOUNTING.XERO_ORGANIZATION,
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 00a617e64d2f..65103945746a 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -76,7 +76,6 @@ const config: LinkingOptions['config'] = {
[SCREENS.NOT_FOUND]: '*',
[NAVIGATORS.LEFT_MODAL_NAVIGATOR]: {
screens: {
- [SCREENS.LEFT_MODAL.CHAT_FINDER]: ROUTES.CHAT_FINDER,
[SCREENS.LEFT_MODAL.WORKSPACE_SWITCHER]: {
path: ROUTES.WORKSPACE_SWITCHER,
},
@@ -393,6 +392,11 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR]: {
path: ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR.route,
},
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_ADVANCED]: {
+ path: ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_DESKTOP_ADVANCED.route,
+ },
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_DATE_SELECT]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT_DATE_SELECT.route},
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_PREFERRED_EXPORTER]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_PREFERRED_EXPORTER.route},
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT]: {
path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT.route,
},
@@ -413,6 +417,11 @@ const config: LinkingOptions['config'] = {
path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_TRIGGER_FIRST_SYNC.route,
},
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_IMPORT]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_IMPORT.route},
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CHART_OF_ACCOUNTS]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CHART_OF_ACCOUNTS.route},
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CLASSES]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CLASSES.route},
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CLASSES_DISPLAYED_AS]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CLASSES_DISPLAYED_AS.route},
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CUSTOMERS.route},
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS.route},
[SCREENS.WORKSPACE.ACCOUNTING.XERO_IMPORT]: {path: ROUTES.POLICY_ACCOUNTING_XERO_IMPORT.route},
[SCREENS.WORKSPACE.ACCOUNTING.XERO_CHART_OF_ACCOUNTS]: {path: ROUTES.POLICY_ACCOUNTING_XERO_CHART_OF_ACCOUNTS.route},
[SCREENS.WORKSPACE.ACCOUNTING.XERO_ORGANIZATION]: {path: ROUTES.POLICY_ACCOUNTING_XERO_ORGANIZATION.route},
diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts
index 218661632896..a9ce45214e5f 100644
--- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts
+++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts
@@ -2,6 +2,7 @@ import type {NavigationState, PartialState, Route} from '@react-navigation/nativ
import {findFocusedRoute, getStateFromPath} from '@react-navigation/native';
import pick from 'lodash/pick';
import type {TupleToUnion} from 'type-fest';
+import type {TopTabScreen} from '@components/FocusTrap/TOP_TAB_SCREENS';
import {isAnonymousUser} from '@libs/actions/Session';
import getIsNarrowLayout from '@libs/getIsNarrowLayout';
import type {BottomTabName, CentralPaneName, FullScreenName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types';
@@ -29,7 +30,10 @@ const RHP_SCREENS_OPENED_FROM_LHN = [
SCREENS.SETTINGS.EXIT_SURVEY.REASON,
SCREENS.SETTINGS.EXIT_SURVEY.RESPONSE,
SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM,
-] satisfies Screen[];
+ CONST.TAB_REQUEST.DISTANCE,
+ CONST.TAB_REQUEST.MANUAL,
+ CONST.TAB_REQUEST.SCAN,
+] satisfies Array;
type RHPScreenOpenedFromLHN = TupleToUnion;
diff --git a/src/libs/Navigation/shouldOpenOnAdminRoom.ts b/src/libs/Navigation/shouldOpenOnAdminRoom.ts
new file mode 100644
index 000000000000..a593e8c22768
--- /dev/null
+++ b/src/libs/Navigation/shouldOpenOnAdminRoom.ts
@@ -0,0 +1,6 @@
+import getCurrentUrl from './currentUrl';
+
+export default function shouldOpenOnAdminRoom() {
+ const url = getCurrentUrl();
+ return url ? new URL(url).searchParams.get('openOnAdminRoom') === 'true' : false;
+}
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index c48786ca1428..ae1621f700d9 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -436,6 +436,15 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_EXPORT_PREFERRED_EXPORTER]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_ADVANCED]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_DATE_SELECT]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_PREFERRED_EXPORTER]: {
+ policyID: string;
+ };
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT]: {
policyID: string;
};
@@ -457,6 +466,21 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_IMPORT]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CHART_OF_ACCOUNTS]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CLASSES]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CLASSES_DISPLAYED_AS]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS]: {
+ policyID: string;
+ };
[SCREENS.WORKSPACE.ACCOUNTING.XERO_IMPORT]: {
policyID: string;
};
@@ -1265,7 +1289,6 @@ type TransactionDuplicateNavigatorParamList = {
};
type LeftModalNavigatorParamList = {
- [SCREENS.LEFT_MODAL.CHAT_FINDER]: undefined;
[SCREENS.LEFT_MODAL.WORKSPACE_SWITCHER]: undefined;
};
@@ -1505,6 +1528,7 @@ type AuthScreensParamList = CentralPaneScreensParamList &
reportID: string;
transactionID: string;
readonly?: boolean;
+ isFromReviewDuplicates?: boolean;
};
[SCREENS.CONNECTION_COMPLETE]: undefined;
};
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index fbf2f3b94c7c..142a299f3d74 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -41,7 +41,6 @@ import type DeepValueOf from '@src/types/utils/DeepValueOf';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import times from '@src/utils/times';
import Timing from './actions/Timing';
-import * as ErrorUtils from './ErrorUtils';
import filterArrayByMatch from './filterArrayByMatch';
import localeCompare from './LocaleCompare';
import * as LocalePhoneNumber from './LocalePhoneNumber';
@@ -343,26 +342,6 @@ Onyx.connect({
},
});
-let allTransactions: OnyxCollection = {};
-Onyx.connect({
- key: ONYXKEYS.COLLECTION.TRANSACTION,
- waitForCollectionCallback: true,
- callback: (value) => {
- if (!value) {
- return;
- }
-
- allTransactions = Object.keys(value)
- .filter((key) => !!value[key])
- .reduce((result: OnyxCollection, key) => {
- if (result) {
- // eslint-disable-next-line no-param-reassign
- result[key] = value[key];
- }
- return result;
- }, {});
- },
-});
let activePolicyID: OnyxEntry;
Onyx.connect({
key: ONYXKEYS.NVP_ACTIVE_POLICY_ID,
@@ -481,78 +460,6 @@ function uniqFast(items: string[]): string[] {
return result;
}
-type ReportErrorsAndReportActionThatRequiresAttention = {
- errors: OnyxCommon.ErrorFields;
- reportAction?: OnyxEntry;
-};
-
-function getAllReportActionsErrorsAndReportActionThatRequiresAttention(report: OnyxEntry, reportActions: OnyxEntry): ReportErrorsAndReportActionThatRequiresAttention {
- const reportActionsArray = Object.values(reportActions ?? {});
- const reportActionErrors: OnyxCommon.ErrorFields = {};
- let reportAction: OnyxEntry;
-
- for (const action of reportActionsArray) {
- if (action && !isEmptyObject(action.errors)) {
- Object.assign(reportActionErrors, action.errors);
-
- if (!reportAction) {
- reportAction = action;
- }
- }
- }
- const parentReportAction: OnyxEntry =
- !report?.parentReportID || !report?.parentReportActionID ? undefined : allReportActions?.[report.parentReportID ?? '-1']?.[report.parentReportActionID ?? '-1'];
-
- if (ReportActionUtils.wasActionTakenByCurrentUser(parentReportAction) && ReportActionUtils.isTransactionThread(parentReportAction)) {
- const transactionID = ReportActionUtils.isMoneyRequestAction(parentReportAction) ? ReportActionUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID : null;
- const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
- if (TransactionUtils.hasMissingSmartscanFields(transaction ?? null) && !ReportUtils.isSettled(transaction?.reportID)) {
- reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericSmartscanFailureMessage');
- reportAction = undefined;
- }
- } else if ((ReportUtils.isIOUReport(report) || ReportUtils.isExpenseReport(report)) && report?.ownerAccountID === currentUserAccountID) {
- if (ReportUtils.shouldShowRBRForMissingSmartscanFields(report?.reportID ?? '-1') && !ReportUtils.isSettled(report?.reportID)) {
- reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericSmartscanFailureMessage');
- reportAction = ReportUtils.getReportActionWithMissingSmartscanFields(report?.reportID ?? '-1');
- }
- } else if (ReportUtils.hasSmartscanError(reportActionsArray)) {
- reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericSmartscanFailureMessage');
- reportAction = ReportUtils.getReportActionWithSmartscanError(reportActionsArray);
- }
-
- return {
- errors: reportActionErrors,
- reportAction,
- };
-}
-
-/**
- * Get an object of error messages keyed by microtime by combining all error objects related to the report.
- */
-function getAllReportErrors(report: OnyxEntry, reportActions: OnyxEntry): OnyxCommon.Errors {
- const reportErrors = report?.errors ?? {};
- const reportErrorFields = report?.errorFields ?? {};
- const {errors: reportActionErrors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions);
-
- // All error objects related to the report. Each object in the sources contains error messages keyed by microtime
- const errorSources = {
- reportErrors,
- ...reportErrorFields,
- ...reportActionErrors,
- };
-
- // Combine all error messages keyed by microtime into one object
- const errorSourcesArray = Object.values(errorSources ?? {});
- const allReportErrors = {};
-
- for (const errors of errorSourcesArray) {
- if (!isEmptyObject(errors)) {
- Object.assign(allReportErrors, errors);
- }
- }
- return allReportErrors;
-}
-
/**
* Get the last actor display name from last actor details.
*/
@@ -749,7 +656,7 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails
}
function hasReportErrors(report: Report, reportActions: OnyxEntry) {
- return !isEmptyObject(getAllReportErrors(report, reportActions));
+ return !isEmptyObject(ReportUtils.getAllReportErrors(report, reportActions));
}
/**
@@ -817,7 +724,7 @@ function createOption(
result.shouldShowSubscript = ReportUtils.shouldReportShowSubscript(report);
result.isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report);
result.isOwnPolicyExpenseChat = report.isOwnPolicyExpenseChat ?? false;
- result.allReportErrors = getAllReportErrors(report, reportActions);
+ result.allReportErrors = ReportUtils.getAllReportErrors(report, reportActions);
result.brickRoadIndicator = hasReportErrors(report, reportActions) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '';
result.pendingAction = report.pendingFields ? report.pendingFields.addWorkspaceRoom ?? report.pendingFields.createChat : undefined;
result.ownerAccountID = report.ownerAccountID;
@@ -1771,23 +1678,6 @@ function getUserToInviteOption({
return userToInvite;
}
-/**
- * Check whether report has violations
- */
-function shouldShowViolations(report: Report, transactionViolations: OnyxCollection) {
- const {parentReportID, parentReportActionID} = report ?? {};
- const canGetParentReport = parentReportID && parentReportActionID && allReportActions;
- if (!canGetParentReport) {
- return false;
- }
- const parentReportActions = allReportActions ? allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`] ?? {} : {};
- const parentReportAction = parentReportActions[parentReportActionID] ?? null;
- if (!parentReportAction) {
- return false;
- }
- return ReportUtils.shouldDisplayTransactionThreadViolations(report, transactionViolations, parentReportAction);
-}
-
/**
* filter options based on specific conditions
*/
@@ -1898,7 +1788,7 @@ function getOptions(
// Filter out all the reports that shouldn't be displayed
const filteredReportOptions = options.reports.filter((option) => {
const report = option.item;
- const doesReportHaveViolations = shouldShowViolations(report, transactionViolations);
+ const doesReportHaveViolations = ReportUtils.shouldShowViolations(report, transactionViolations);
return ReportUtils.shouldReportBeInOptionList({
report,
@@ -2629,7 +2519,6 @@ export {
getPersonalDetailsForAccountIDs,
getIOUConfirmationOptionsFromPayeePersonalDetail,
isSearchStringMatchUserDetails,
- getAllReportErrors,
getPolicyExpenseReportOption,
getIOUReportIDOfLastAction,
getParticipantsOption,
@@ -2655,13 +2544,11 @@ export {
getFirstKeyForList,
canCreateOptimisticPersonalDetailOption,
getUserToInviteOption,
- shouldShowViolations,
getPersonalDetailSearchTerms,
getCurrentUserSearchTerms,
getEmptyOptions,
shouldUseBoldText,
getAlternateText,
- getAllReportActionsErrorsAndReportActionThatRequiresAttention,
hasReportErrors,
};
diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts
index de3afbabadc2..24de2e612208 100644
--- a/src/libs/Permissions.ts
+++ b/src/libs/Permissions.ts
@@ -2,7 +2,6 @@ import type {OnyxEntry} from 'react-native-onyx';
import CONST from '@src/CONST';
import type {IOUType} from '@src/CONST';
import type Beta from '@src/types/onyx/Beta';
-import * as Environment from './Environment/Environment';
function canUseAllBetas(betas: OnyxEntry): boolean {
return !!betas?.includes(CONST.BETAS.ALL);
@@ -58,17 +57,6 @@ function canUseNewDotQBD(betas: OnyxEntry): boolean {
return !!betas?.includes(CONST.BETAS.NEW_DOT_QBD) || canUseAllBetas(betas);
}
-/**
- * New Search Router is under construction and for now should be displayed only in dev to allow developers to work on it.
- * We are not using BETA for this feature, as betas are heavier to cleanup,
- * and the development of new router is expected to take 2-3 weeks at most
- *
- * After everything is implemented this function can be removed, as we will always use SearchRouter in the App.
- */
-function canUseNewSearchRouter() {
- return Environment.isDevelopment();
-}
-
/**
* Link previews are temporarily disabled.
*/
@@ -88,7 +76,6 @@ export default {
canUseNewDotCopilot,
canUseWorkspaceRules,
canUseCombinedTrackSubmit,
- canUseNewSearchRouter,
canUseCategoryAndTagApprovers,
canUseNewDotQBD,
};
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index 6a1a61afa05d..fc7c1c43afee 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -194,6 +194,11 @@ function isExpensifyTeam(email: string | undefined): boolean {
return emailDomain === CONST.EXPENSIFY_PARTNER_NAME || emailDomain === CONST.EMAIL.GUIDES_DOMAIN;
}
+/**
+ * Checks if the user with login is an admin of the policy.
+ */
+const isUserPolicyAdmin = (policy: OnyxInputOrEntry, login?: string) => !!(policy && policy.employeeList && login && policy.employeeList[login]?.role === CONST.POLICY.ROLE.ADMIN);
+
/**
* Checks if the current user is an admin of the policy.
*/
@@ -1088,6 +1093,7 @@ export {
getCorrectedAutoReportingFrequency,
isPaidGroupPolicy,
isPendingDeletePolicy,
+ isUserPolicyAdmin,
isPolicyAdmin,
isPolicyUser,
isPolicyAuditor,
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index 3b5e0a8eeaa3..31b44ac8f916 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -344,18 +344,6 @@ function isThreadParentMessage(reportAction: OnyxEntry, reportID:
return childType === CONST.REPORT.TYPE.CHAT && (childVisibleActionCount > 0 || String(childReportID) === reportID);
}
-/**
- * Returns the parentReportAction if the given report is a thread/task.
- *
- * @deprecated Use Onyx.connect() or withOnyx() instead
- */
-function getParentReportAction(report: OnyxInputOrEntry): OnyxEntry {
- if (!report?.parentReportID || !report.parentReportActionID) {
- return undefined;
- }
- return allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID];
-}
-
/**
* Determines if the given report action is sent money report action by checking for 'pay' type and presence of IOUDetails object.
*/
@@ -1073,7 +1061,7 @@ function getOneTransactionThreadReportID(reportID: string, reportActions: OnyxEn
// If there's only one IOU request action associated with the report but it's been deleted, then we don't consider this a oneTransaction report
// and want to display it using the standard view
- if ((originalMessage?.deleted ?? '') !== '' && isMoneyRequestAction(singleAction)) {
+ if (((originalMessage?.deleted ?? '') !== '' || isDeletedAction(singleAction)) && isMoneyRequestAction(singleAction)) {
return;
}
@@ -1807,8 +1795,6 @@ export {
getNumberOfMoneyRequests,
getOneTransactionThreadReportID,
getOriginalMessage,
- // eslint-disable-next-line deprecation/deprecation
- getParentReportAction,
getRemovedFromApprovalChainMessage,
getReportAction,
getReportActionHtml,
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 2623fab86a05..bf687c973abb 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -48,7 +48,7 @@ import type {Participant} from '@src/types/onyx/IOU';
import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft';
import type {OriginalMessageExportedToIntegration} from '@src/types/onyx/OldDotAction';
import type Onboarding from '@src/types/onyx/Onboarding';
-import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon';
+import type {ErrorFields, Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon';
import type {OriginalMessageChangeLog, PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import type {Status} from '@src/types/onyx/PersonalDetails';
import type {ConnectionName} from '@src/types/onyx/Policy';
@@ -64,8 +64,8 @@ import * as SessionUtils from './actions/Session';
import * as CurrencyUtils from './CurrencyUtils';
import DateUtils from './DateUtils';
import {hasValidDraftComment} from './DraftCommentUtils';
+import * as ErrorUtils from './ErrorUtils';
import getAttachmentDetails from './fileDownload/getAttachmentDetails';
-import getIsSmallScreenWidth from './getIsSmallScreenWidth';
import isReportMessageAttachment from './isReportMessageAttachment';
import localeCompare from './LocaleCompare';
import * as LocalePhoneNumber from './LocalePhoneNumber';
@@ -1252,13 +1252,22 @@ function findSelfDMReportID(): string | undefined {
return selfDMReport?.reportID;
}
+/**
+ * Checks if the supplied report is from a policy or is an invoice report from a policy
+ */
+function isPolicyRelatedReport(report: OnyxEntry, policyID?: string) {
+ return report?.policyID === policyID || !!(report?.invoiceReceiver && 'policyID' in report.invoiceReceiver && report.invoiceReceiver.policyID === policyID);
+}
+
/**
* Checks if the supplied report belongs to workspace based on the provided params. If the report's policyID is _FAKE_ or has no value, it means this report is a DM.
* In this case report and workspace members must be compared to determine whether the report belongs to the workspace.
*/
function doesReportBelongToWorkspace(report: OnyxEntry, policyMemberAccountIDs: number[], policyID?: string) {
- const isPolicyRelatedReport = report?.policyID === policyID || !!(report?.invoiceReceiver && 'policyID' in report.invoiceReceiver && report.invoiceReceiver.policyID === policyID);
- return isConciergeChatReport(report) || (report?.policyID === CONST.POLICY.ID_FAKE || !report?.policyID ? hasParticipantInArray(report, policyMemberAccountIDs) : isPolicyRelatedReport);
+ return (
+ isConciergeChatReport(report) ||
+ (report?.policyID === CONST.POLICY.ID_FAKE || !report?.policyID ? hasParticipantInArray(report, policyMemberAccountIDs) : isPolicyRelatedReport(report, policyID))
+ );
}
/**
@@ -1360,12 +1369,6 @@ function findLastAccessedReport(ignoreDomainRooms: boolean, openOnAdminRoom = fa
});
}
- // if the user hasn't completed the onboarding flow, whether the user should be in the concierge chat or system chat
- // should be consistent with what chat the user will land after onboarding flow
- if (!getIsSmallScreenWidth() && !Array.isArray(onboarding) && !onboarding?.hasCompletedGuidedSetupFlow) {
- return reportsValues.find(isChatUsedForOnboarding);
- }
-
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const shouldFilter = excludeReportID || ignoreDomainRooms;
if (shouldFilter) {
@@ -1375,7 +1378,7 @@ function findLastAccessedReport(ignoreDomainRooms: boolean, openOnAdminRoom = fa
}
// We allow public announce rooms, admins, and announce rooms through since we bypass the default rooms beta for them.
- // Check where ReportUtils.findLastAccessedReport is called in MainDrawerNavigator.js for more context.
+ // Check where findLastAccessedReport is called in MainDrawerNavigator.js for more context.
// Domain rooms are now the only type of default room that are on the defaultRooms beta.
if (ignoreDomainRooms && isDomainRoom(report) && !hasExpensifyGuidesEmails(Object.keys(report?.participants ?? {}).map(Number))) {
return false;
@@ -6258,6 +6261,112 @@ function shouldAdminsRoomBeVisible(report: OnyxEntry): boolean {
return true;
}
+/**
+ * Check whether report has violations
+ */
+function shouldShowViolations(report: Report, transactionViolations: OnyxCollection) {
+ const {parentReportID, parentReportActionID} = report ?? {};
+ const canGetParentReport = parentReportID && parentReportActionID && allReportActions;
+ if (!canGetParentReport) {
+ return false;
+ }
+ const parentReportActions = allReportActions ? allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`] ?? {} : {};
+ const parentReportAction = parentReportActions[parentReportActionID] ?? null;
+ if (!parentReportAction) {
+ return false;
+ }
+ return shouldDisplayTransactionThreadViolations(report, transactionViolations, parentReportAction);
+}
+
+type ReportErrorsAndReportActionThatRequiresAttention = {
+ errors: ErrorFields;
+ reportAction?: OnyxEntry;
+};
+
+function getAllReportActionsErrorsAndReportActionThatRequiresAttention(report: OnyxEntry, reportActions: OnyxEntry): ReportErrorsAndReportActionThatRequiresAttention {
+ const reportActionsArray = Object.values(reportActions ?? {});
+ const reportActionErrors: ErrorFields = {};
+ let reportAction: OnyxEntry;
+
+ for (const action of reportActionsArray) {
+ if (action && !isEmptyObject(action.errors)) {
+ Object.assign(reportActionErrors, action.errors);
+
+ if (!reportAction) {
+ reportAction = action;
+ }
+ }
+ }
+ const parentReportAction: OnyxEntry =
+ !report?.parentReportID || !report?.parentReportActionID ? undefined : allReportActions?.[report.parentReportID ?? '-1']?.[report.parentReportActionID ?? '-1'];
+
+ if (ReportActionsUtils.wasActionTakenByCurrentUser(parentReportAction) && ReportActionsUtils.isTransactionThread(parentReportAction)) {
+ const transactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID : null;
+ const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
+ if (TransactionUtils.hasMissingSmartscanFields(transaction ?? null) && !isSettled(transaction?.reportID)) {
+ reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericSmartscanFailureMessage');
+ reportAction = undefined;
+ }
+ } else if ((isIOUReport(report) || isExpenseReport(report)) && report?.ownerAccountID === currentUserAccountID) {
+ if (shouldShowRBRForMissingSmartscanFields(report?.reportID ?? '-1') && !isSettled(report?.reportID)) {
+ reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericSmartscanFailureMessage');
+ reportAction = getReportActionWithMissingSmartscanFields(report?.reportID ?? '-1');
+ }
+ } else if (hasSmartscanError(reportActionsArray)) {
+ reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericSmartscanFailureMessage');
+ reportAction = getReportActionWithSmartscanError(reportActionsArray);
+ }
+
+ return {
+ errors: reportActionErrors,
+ reportAction,
+ };
+}
+
+/**
+ * Get an object of error messages keyed by microtime by combining all error objects related to the report.
+ */
+function getAllReportErrors(report: OnyxEntry, reportActions: OnyxEntry): Errors {
+ const reportErrors = report?.errors ?? {};
+ const reportErrorFields = report?.errorFields ?? {};
+ const {errors: reportActionErrors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions);
+
+ // All error objects related to the report. Each object in the sources contains error messages keyed by microtime
+ const errorSources = {
+ reportErrors,
+ ...reportErrorFields,
+ ...reportActionErrors,
+ };
+
+ // Combine all error messages keyed by microtime into one object
+ const errorSourcesArray = Object.values(errorSources ?? {});
+ const allReportErrors = {};
+
+ for (const errors of errorSourcesArray) {
+ if (!isEmptyObject(errors)) {
+ Object.assign(allReportErrors, errors);
+ }
+ }
+ return allReportErrors;
+}
+
+function hasReportErrorsOtherThanFailedReceipt(report: Report, doesReportHaveViolations: boolean, transactionViolations: OnyxCollection) {
+ const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? {};
+ const allReportErrors = getAllReportErrors(report, reportActions) ?? {};
+ const transactionReportActions = ReportActionsUtils.getAllReportActions(report.reportID);
+ const oneTransactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, transactionReportActions, undefined);
+ let doesTransactionThreadReportHasViolations = false;
+ if (oneTransactionThreadReportID) {
+ const transactionReport = getReport(oneTransactionThreadReportID);
+ doesTransactionThreadReportHasViolations = !!transactionReport && shouldShowViolations(transactionReport, transactionViolations);
+ }
+ return (
+ doesTransactionThreadReportHasViolations ||
+ doesReportHaveViolations ||
+ Object.values(allReportErrors).some((error) => error?.[0] !== Localize.translateLocal('iou.error.genericSmartscanFailureMessage'))
+ );
+}
+
type ShouldReportBeInOptionListParams = {
report: OnyxEntry;
currentReportId: string;
@@ -8470,6 +8579,11 @@ export {
hasMissingInvoiceBankAccount,
reasonForReportToBeInOptionList,
getReasonAndReportActionThatRequiresAttention,
+ isPolicyRelatedReport,
+ hasReportErrorsOtherThanFailedReceipt,
+ shouldShowViolations,
+ getAllReportErrors,
+ getAllReportActionsErrorsAndReportActionThatRequiresAttention,
};
export type {
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index eb5b3c58cdef..944f703e96cb 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -105,23 +105,11 @@ function getOrderedReportIDs(
if ((Object.values(CONST.REPORT.UNSUPPORTED_TYPE) as string[]).includes(report?.type ?? '')) {
return;
}
- const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? {};
const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '-1', report?.parentReportActionID ?? '-1');
- const doesReportHaveViolations = OptionsListUtils.shouldShowViolations(report, transactionViolations);
+ const doesReportHaveViolations = ReportUtils.shouldShowViolations(report, transactionViolations);
const isHidden = ReportUtils.getReportNotificationPreference(report) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
const isFocused = report.reportID === currentReportId;
- const allReportErrors = OptionsListUtils.getAllReportErrors(report, reportActions) ?? {};
- const transactionReportActions = ReportActionsUtils.getAllReportActions(report.reportID);
- const oneTransactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, transactionReportActions, undefined);
- let doesTransactionThreadReportHasViolations = false;
- if (oneTransactionThreadReportID) {
- const transactionReport = ReportUtils.getReport(oneTransactionThreadReportID);
- doesTransactionThreadReportHasViolations = !!transactionReport && OptionsListUtils.shouldShowViolations(transactionReport, transactionViolations);
- }
- const hasErrorsOtherThanFailedReceipt =
- doesTransactionThreadReportHasViolations ||
- doesReportHaveViolations ||
- Object.values(allReportErrors).some((error) => error?.[0] !== Localize.translateLocal('iou.error.genericSmartscanFailureMessage'));
+ const hasErrorsOtherThanFailedReceipt = ReportUtils.hasReportErrorsOtherThanFailedReceipt(report, doesReportHaveViolations, transactionViolations);
const isReportInAccessible = report?.errorFields?.notFound;
if (ReportUtils.isOneTransactionThread(report.reportID, report.parentReportID ?? '0', parentReportAction)) {
return;
@@ -235,7 +223,7 @@ function getOrderedReportIDs(
}
function shouldShowRedBrickRoad(report: Report, reportActions: OnyxEntry, hasViolations: boolean, transactionViolations?: OnyxCollection) {
- const hasErrors = Object.keys(OptionsListUtils.getAllReportErrors(report, reportActions)).length !== 0;
+ const hasErrors = Object.keys(ReportUtils.getAllReportErrors(report, reportActions)).length !== 0;
const oneTransactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, ReportActionsUtils.getAllReportActions(report.reportID));
if (oneTransactionThreadReportID) {
@@ -291,7 +279,7 @@ function getOptionData({
const result: ReportUtils.OptionData = {
text: '',
alternateText: undefined,
- allReportErrors: OptionsListUtils.getAllReportErrors(report, reportActions),
+ allReportErrors: ReportUtils.getAllReportErrors(report, reportActions),
brickRoadIndicator: null,
tooltipText: null,
subtitle: undefined,
diff --git a/src/libs/StringUtils.ts b/src/libs/StringUtils.ts
index b3fcd247284e..d13c38700e18 100644
--- a/src/libs/StringUtils.ts
+++ b/src/libs/StringUtils.ts
@@ -34,20 +34,19 @@ function removeInvisibleCharacters(value: string): string {
// Remove spaces:
// - \u200B: zero-width space
- // - \u00A0: non-breaking space
// - \u2060: word joiner
- result = result.replace(/[\u200B\u00A0\u2060]/g, '');
-
- // Temporarily replace all newlines with non-breaking spaces
- // It is necessary because the next step removes all newlines because they are in the (Cc) category
- result = result.replace(/\n/g, '\u00A0');
-
- // Remove all characters from the 'Other' (C) category except for format characters (Cf)
- // because some of them are used for emojis
- result = result.replace(/[\p{Cc}\p{Cs}\p{Co}\p{Cn}]/gu, '');
-
- // Replace all non-breaking spaces with newlines
- result = result.replace(/\u00A0/g, '\n');
+ result = result.replace(/[\u200B\u2060]/g, '');
+
+ // The control unicode (Cc) regex removes all newlines,
+ // so we first split the string by newline and rejoin it afterward to retain the original line breaks.
+ result = result
+ .split('\n')
+ .map((part) =>
+ // Remove all characters from the 'Other' (C) category except for format characters (Cf)
+ // because some of them are used for emojis
+ part.replace(/[\p{Cc}\p{Cs}\p{Co}\p{Cn}]/gu, ''),
+ )
+ .join('\n');
// Remove characters from the (Cf) category that are not used for emojis
result = result.replace(/[\u200E-\u200F]/g, '');
diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts
index 6eafec9f9528..cf4d76c721b3 100644
--- a/src/libs/Violations/ViolationsUtils.ts
+++ b/src/libs/Violations/ViolationsUtils.ts
@@ -80,7 +80,7 @@ function getTagViolationsForDependentTags(policyTagList: PolicyTagLists, transac
*/
function getTagViolationForIndependentTags(policyTagList: PolicyTagLists, transactionViolations: TransactionViolation[], transaction: Transaction) {
const policyTagKeys = getSortedTagKeys(policyTagList);
- const selectedTags = transaction.tag?.split(CONST.COLON) ?? [];
+ const selectedTags = TransactionUtils.getTagArrayFromName(transaction?.tag ?? '');
let newTransactionViolations = [...transactionViolations];
newTransactionViolations = newTransactionViolations.filter(
diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts
index d8cd2ff00828..2be641035be7 100644
--- a/src/libs/WorkspacesSettingsUtils.ts
+++ b/src/libs/WorkspacesSettingsUtils.ts
@@ -9,7 +9,6 @@ import type {Policy, ReimbursementAccount, Report, ReportAction, ReportActions,
import type {PolicyConnectionSyncProgress, Unit} from '@src/types/onyx/Policy';
import {isConnectionInProgress} from './actions/connections';
import * as CurrencyUtils from './CurrencyUtils';
-import * as OptionsListUtils from './OptionsListUtils';
import {hasCustomUnitsError, hasEmployeeListError, hasPolicyError, hasSyncError, hasTaxRateError} from './PolicyUtils';
import * as ReportActionsUtils from './ReportActionsUtils';
import * as ReportConnection from './ReportConnection';
@@ -60,7 +59,7 @@ Onyx.connect({
*/
const getBrickRoadForPolicy = (report: Report, altReportActions?: OnyxCollection): BrickRoad => {
const reportActions = (altReportActions ?? allReportActions)?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? {};
- const reportErrors = OptionsListUtils.getAllReportErrors(report, reportActions);
+ const reportErrors = ReportUtils.getAllReportErrors(report, reportActions);
const oneTransactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, reportActions);
let doesReportContainErrors = Object.keys(reportErrors ?? {}).length !== 0 ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined;
diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts
index 3007bd2cb88c..6679a6e4b9ea 100644
--- a/src/libs/actions/BankAccounts.ts
+++ b/src/libs/actions/BankAccounts.ts
@@ -73,11 +73,14 @@ function setPlaidEvent(eventName: string | null) {
/**
* Open the personal bank account setup flow, with an optional exitReportID to redirect to once the flow is finished.
*/
-function openPersonalBankAccountSetupView(exitReportID?: string) {
+function openPersonalBankAccountSetupView(exitReportID?: string, isUserValidated = true) {
clearPlaid().then(() => {
if (exitReportID) {
Onyx.merge(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {exitReportID});
}
+ if (!isUserValidated) {
+ Navigation.navigate(ROUTES.SETTINGS_WALLET_VERIFY_ACCOUNT.getRoute(ROUTES.SETTINGS_ADD_BANK_ACCOUNT));
+ }
Navigation.navigate(ROUTES.SETTINGS_ADD_BANK_ACCOUNT);
});
}
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index de896e6f72f5..0f974566a98b 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -633,18 +633,6 @@ function buildOnyxDataForMoneyRequest(
});
}
- if (!isOneOnOneSplit) {
- optimisticData.push({
- onyxMethod: Onyx.METHOD.SET,
- key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE,
- value: {
- action: newQuickAction,
- chatReportID: chatReport?.reportID,
- isFirstQuickAction: isEmptyObject(quickAction),
- },
- });
- }
-
if (optimisticPolicyRecentlyUsedCategories.length) {
optimisticData.push({
onyxMethod: Onyx.METHOD.SET,
@@ -875,6 +863,23 @@ function buildOnyxDataForMoneyRequest(
},
];
+ if (!isOneOnOneSplit) {
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE,
+ value: {
+ action: newQuickAction,
+ chatReportID: chatReport?.reportID,
+ isFirstQuickAction: isEmptyObject(quickAction),
+ },
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE,
+ value: quickAction ?? null,
+ });
+ }
+
if (!isEmptyObject(transactionThreadCreatedReportAction)) {
failureData.push({
onyxMethod: Onyx.METHOD.MERGE,
@@ -4103,6 +4108,11 @@ function createSplitsAndOnyxData(
pendingFields: null,
},
},
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE,
+ value: quickAction ?? null,
+ },
];
if (existingSplitChatReport) {
@@ -4673,6 +4683,11 @@ function startSplitBill({
errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'),
},
},
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE,
+ value: quickAction ?? null,
+ },
];
if (existingSplitChatReport) {
@@ -6353,6 +6368,11 @@ function getSendMoneyParams(
},
},
},
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE,
+ value: quickAction ?? null,
+ },
];
// Now, let's add the data we need just when we are creating a new chat report
@@ -6476,7 +6496,7 @@ function getReportFromHoldRequestsOnyxData(
chatReport.reportID,
chatReport.policyID ?? iouReport?.policyID ?? '',
recipient.accountID ?? 1,
- holdTransactions.reduce((acc, transaction) => acc + transaction.amount, 0) * (ReportUtils.isIOUReport(iouReport) ? 1 : -1),
+ holdTransactions.reduce((acc, transaction) => acc + TransactionUtils.getAmount(transaction), 0),
getCurrency(firstHoldTransaction),
false,
newParentReportActionID,
@@ -6550,7 +6570,10 @@ function getReportFromHoldRequestsOnyxData(
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticExpenseReport.reportID}`,
- value: optimisticExpenseReport,
+ value: {
+ ...optimisticExpenseReport,
+ unheldTotal: 0,
+ },
},
// add preview report action to main chat
{
diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts
index ae776bdf080d..8baef3006a5b 100644
--- a/src/libs/actions/Policy/Policy.ts
+++ b/src/libs/actions/Policy/Policy.ts
@@ -310,7 +310,7 @@ function deleteWorkspace(policyID: string, policyName: string) {
];
const reportsToArchive = Object.values(ReportConnection.getAllReports() ?? {}).filter(
- (report) => report?.policyID === policyID && (ReportUtils.isChatRoom(report) || ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isTaskReport(report)),
+ (report) => ReportUtils.isPolicyRelatedReport(report, policyID) && (ReportUtils.isChatRoom(report) || ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isTaskReport(report)),
);
const finallyData: OnyxUpdate[] = [];
const currentTime = DateUtils.getDBTime();
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index 03ec44dd6646..7071c96f8612 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -496,7 +496,7 @@ function addActions(reportID: string, text = '', file?: FileObject) {
if (shouldUpdateNotificationPrefernece) {
optimisticReport.participants = {
- [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS},
+ [currentUserAccountID]: {notificationPreference: ReportUtils.getDefaultNotificationPreferenceForReport(report)},
};
}
@@ -552,19 +552,6 @@ function addActions(reportID: string, text = '', file?: FileObject) {
},
];
- if (shouldUpdateNotificationPrefernece) {
- // optimisticReport.notificationPreference = CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS;
- successData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
- value: {
- participants: {
- [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS},
- },
- },
- });
- }
-
let failureReport: Partial = {
lastMessageTranslationKey: '',
lastMessageText: '',
@@ -841,8 +828,8 @@ function openReport(
accountIDList: participantAccountIDList ? participantAccountIDList.join(',') : '',
parentReportActionID,
};
-
- if (ReportUtils.isGroupChat(newReportObject)) {
+ const isGroupChat = ReportUtils.isGroupChat(newReportObject);
+ if (isGroupChat) {
parameters.chatType = CONST.REPORT.CHAT_TYPE.GROUP;
parameters.groupChatAdminLogins = currentUserEmail;
parameters.optimisticAccountIDList = Object.keys(newReportObject?.participants ?? {}).join(',');
@@ -874,6 +861,7 @@ function openReport(
...newReportObject,
pendingFields: {
createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ ...(isGroupChat && {reportName: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}),
},
isOptimisticReport: true,
};
@@ -927,6 +915,7 @@ function openReport(
participants: redundantParticipants,
pendingFields: {
createChat: null,
+ reportName: null,
},
errorFields: {
createChat: null,
@@ -2761,7 +2750,7 @@ function joinRoom(report: OnyxEntry) {
updateNotificationPreference(
report.reportID,
ReportUtils.getReportNotificationPreference(report),
- CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS,
+ ReportUtils.getDefaultNotificationPreferenceForReport(report),
report.parentReportID,
report.parentReportActionID,
);
diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts
index bb96c98100a2..b35a2a413429 100644
--- a/src/libs/actions/Task.ts
+++ b/src/libs/actions/Task.ts
@@ -243,6 +243,11 @@ function createTaskAndNavigate(
targetAccountID: assigneeAccountID,
},
});
+ failureData.push({
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE,
+ value: quickAction ?? null,
+ });
// If needed, update optimistic data for parent report action of the parent report.
const optimisticParentReportData = ReportUtils.getOptimisticDataForParentReportAction(parentReportID, currentTime, CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts
index 52a8b8e143b8..e1586f9cb24b 100644
--- a/src/libs/actions/TaxRate.ts
+++ b/src/libs/actions/TaxRate.ts
@@ -1,4 +1,4 @@
-import type {OnyxCollection} from 'react-native-onyx';
+import type {NullishDeep, OnyxCollection} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {FormOnyxValues} from '@components/Form/types';
import * as API from '@libs/API';
@@ -288,6 +288,11 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) {
const firstTaxID = Object.keys(policyTaxRates ?? {})
.sort((a, b) => a.localeCompare(b))
.at(0);
+ const customUnits = policy?.customUnits ?? {};
+ const customUnitID = Object.keys(customUnits).at(0) ?? '-1';
+ const ratesToUpdate = Object.values(customUnits?.[customUnitID]?.rates ?? {}).filter(
+ (rate) => !!rate.attributes?.taxRateExternalID && taxesToDelete.includes(rate.attributes?.taxRateExternalID),
+ );
if (!policyTaxRates) {
console.debug('Policy or tax rates not found');
@@ -296,6 +301,33 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) {
const isForeignTaxRemoved = foreignTaxDefault && taxesToDelete.includes(foreignTaxDefault);
+ const optimisticRates: Record> = {};
+ const successRates: Record = {};
+ const failureRates: Record = {};
+
+ ratesToUpdate.forEach((rate) => {
+ const rateID = rate.customUnitRateID ?? '';
+ optimisticRates[rateID] = {
+ attributes: {
+ taxRateExternalID: null,
+ taxClaimablePercentage: null,
+ },
+ pendingFields: {
+ taxRateExternalID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ taxClaimablePercentage: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ };
+ successRates[rateID] = {pendingFields: {taxRateExternalID: null, taxClaimablePercentage: null}};
+ failureRates[rateID] = {
+ attributes: {...rate?.attributes},
+ pendingFields: {taxRateExternalID: null, taxClaimablePercentage: null},
+ errorFields: {
+ taxRateExternalID: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
+ taxClaimablePercentage: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
+ },
+ };
+ });
+
const onyxData: OnyxData = {
optimisticData: [
{
@@ -310,6 +342,11 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) {
return acc;
}, {}),
},
+ customUnits: customUnits && {
+ [customUnitID]: {
+ rates: optimisticRates,
+ },
+ },
},
},
],
@@ -325,6 +362,11 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) {
return acc;
}, {}),
},
+ customUnits: customUnits && {
+ [customUnitID]: {
+ rates: successRates,
+ },
+ },
},
},
],
@@ -344,6 +386,11 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) {
return acc;
}, {}),
},
+ customUnits: customUnits && {
+ [customUnitID]: {
+ rates: failureRates,
+ },
+ },
},
},
],
diff --git a/src/libs/actions/connections/QuickbooksDesktop.ts b/src/libs/actions/connections/QuickbooksDesktop.ts
index 78f99b5c6106..381143679431 100644
--- a/src/libs/actions/connections/QuickbooksDesktop.ts
+++ b/src/libs/actions/connections/QuickbooksDesktop.ts
@@ -158,6 +158,87 @@ function buildOnyxDataForQuickbooksExportConfiguration(
+ policyID: string,
+ settingName: TSettingName,
+ settingValue: Partial,
+ oldSettingValue?: Partial,
+) {
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ connections: {
+ [CONST.POLICY.CONNECTIONS.NAME.QBD]: {
+ config: {
+ mappings: {
+ [settingName]: settingValue ?? null,
+ },
+ pendingFields: {
+ [settingName]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ errorFields: {
+ [settingName]: null,
+ },
+ },
+ },
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ connections: {
+ [CONST.POLICY.CONNECTIONS.NAME.QBD]: {
+ config: {
+ mappings: {
+ [settingName]: oldSettingValue ?? null,
+ },
+ pendingFields: {
+ [settingName]: null,
+ },
+ errorFields: {
+ [settingName]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
+ },
+ },
+ },
+ },
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ connections: {
+ [CONST.POLICY.CONNECTIONS.NAME.QBD]: {
+ config: {
+ pendingFields: {
+ [settingName]: null,
+ },
+ errorFields: {
+ [settingName]: null,
+ },
+ },
+ },
+ },
+ },
+ },
+ ];
+ return {
+ optimisticData,
+ failureData,
+ successData,
+ };
+}
+
function buildOnyxDataForQuickbooksConfiguration(
policyID: string,
settingName: TSettingName,
@@ -260,6 +341,21 @@ function updateQuickbooksDesktopExpensesExportDestination(
+ policyID: string,
+ settingValue: TSettingValue,
+) {
+ const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.SHOULD_AUTO_CREATE_VENDOR, settingValue, !settingValue);
+
+ const parameters: UpdateQuickbooksDesktopGenericTypeParams = {
+ policyID,
+ settingValue: JSON.stringify(settingValue),
+ idempotencyKey: String(CONST.QUICKBOOKS_DESKTOP_CONFIG.SHOULD_AUTO_CREATE_VENDOR),
+ };
+
+ API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_AUTO_CREATE_VENDOR, parameters, onyxData);
+}
+
function updateQuickbooksDesktopMarkChecksToBePrinted(
policyID: string,
settingValue: TSettingValue,
@@ -289,9 +385,96 @@ function updateQuickbooksDesktopReimbursableExpensesAccount(policyID: string, settingValue: TSettingValue) {
+ const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.ENABLE_NEW_CATEGORIES, settingValue, !settingValue);
+
+ const parameters: UpdateQuickbooksDesktopGenericTypeParams = {
+ policyID,
+ settingValue: JSON.stringify(settingValue),
+ idempotencyKey: String(CONST.QUICKBOOKS_DESKTOP_CONFIG.ENABLE_NEW_CATEGORIES),
+ };
+ API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_ENABLE_NEW_CATEGORIES, parameters, onyxData);
+}
+
+function updateQuickbooksDesktopSyncClasses(
+ policyID: string,
+ settingValue: TSettingValue,
+ oldSettingValue?: TSettingValue,
+) {
+ const onyxData = buildOnyxDataForQuickbooksDesktopMappingsConfiguration(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CLASSES, settingValue, oldSettingValue);
+ const parameters: UpdateQuickbooksDesktopGenericTypeParams = {
+ policyID,
+ settingValue,
+ idempotencyKey: String(CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CLASSES),
+ };
+ API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_SYNC_CLASSES, parameters, onyxData);
+}
+
+function updateQuickbooksDesktopSyncCustomers(
+ policyID: string,
+ settingValue: TSettingValue,
+ oldSettingValue?: TSettingValue,
+) {
+ const onyxData = buildOnyxDataForQuickbooksDesktopMappingsConfiguration(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CUSTOMERS, settingValue, oldSettingValue);
+ const parameters: UpdateQuickbooksDesktopGenericTypeParams = {
+ policyID,
+ settingValue,
+ idempotencyKey: String(CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CUSTOMERS),
+ };
+ API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_SYNC_CUSTOMERS, parameters, onyxData);
+}
+
+function updateQuickbooksDesktopPreferredExporter(
+ policyID: string,
+ settingValue: TSettingValue,
+ oldSettingValue?: TSettingValue,
+) {
+ const onyxData = buildOnyxDataForQuickbooksExportConfiguration(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.EXPORTER, settingValue, oldSettingValue);
+
+ const parameters: UpdateQuickbooksDesktopGenericTypeParams = {
+ policyID,
+ settingValue,
+ idempotencyKey: String(CONST.QUICKBOOKS_DESKTOP_CONFIG.EXPORTER),
+ };
+ API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_EXPORT, parameters, onyxData);
+}
+
+function updateQuickbooksDesktopExportDate(
+ policyID: string,
+ settingValue: TSettingValue,
+ oldSettingValue?: TSettingValue,
+) {
+ const onyxData = buildOnyxDataForQuickbooksExportConfiguration(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.EXPORT_DATE, settingValue, oldSettingValue);
+
+ const parameters: UpdateQuickbooksDesktopGenericTypeParams = {
+ policyID,
+ settingValue,
+ idempotencyKey: String(CONST.QUICKBOOKS_DESKTOP_CONFIG.EXPORT_DATE),
+ };
+ API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_EXPORT_DATE, parameters, onyxData);
+}
+
+function updateQuickbooksDesktopAutoSync(policyID: string, settingValue: TSettingValue) {
+ const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.AUTO_SYNC, {enabled: settingValue}, {enabled: !settingValue});
+
+ const parameters: UpdateQuickbooksDesktopGenericTypeParams = {
+ policyID,
+ settingValue: JSON.stringify(settingValue),
+ idempotencyKey: String(CONST.QUICKBOOKS_DESKTOP_CONFIG.AUTO_SYNC),
+ };
+ API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_AUTO_SYNC, parameters, onyxData);
+}
+
export {
+ updateQuickbooksDesktopAutoSync,
+ updateQuickbooksDesktopPreferredExporter,
updateQuickbooksDesktopMarkChecksToBePrinted,
+ updateQuickbooksDesktopShouldAutoCreateVendor,
updateQuickbooksDesktopExpensesExportDestination,
updateQuickbooksDesktopReimbursableExpensesAccount,
getQuickbooksDesktopCodatSetupLink,
+ updateQuickbooksDesktopEnableNewCategories,
+ updateQuickbooksDesktopExportDate,
+ updateQuickbooksDesktopSyncClasses,
+ updateQuickbooksDesktopSyncCustomers,
};
diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts
new file mode 100644
index 000000000000..d84927988b5c
--- /dev/null
+++ b/src/libs/navigateAfterOnboarding.ts
@@ -0,0 +1,40 @@
+import ROUTES from '@src/ROUTES';
+import * as Report from './actions/Report';
+import Navigation from './Navigation/Navigation';
+import shouldOpenOnAdminRoom from './Navigation/shouldOpenOnAdminRoom';
+import * as ReportUtils from './ReportUtils';
+
+const navigateAfterOnboarding = (
+ isSmallScreenWidth: boolean,
+ shouldUseNarrowLayout: boolean,
+ canUseDefaultRooms: boolean | undefined,
+ onboardingPolicyID?: string,
+ activeWorkspaceID?: string,
+ backTo?: string,
+) => {
+ Navigation.dismissModal();
+
+ // When hasCompletedGuidedSetupFlow is true, OnboardingModalNavigator in AuthScreen is removed from the navigation stack.
+ // On small screens, this removal redirects navigation to HOME. Dismissing the modal doesn't work properly,
+ // so we need to specifically navigate to the last accessed report.
+ if (!isSmallScreenWidth) {
+ return;
+ }
+
+ const lastAccessedReport = ReportUtils.findLastAccessedReport(!canUseDefaultRooms, shouldOpenOnAdminRoom(), activeWorkspaceID);
+ const lastAccessedReportID = lastAccessedReport?.reportID;
+ // we don't want to navigate to newly creaded workspace after onboarding completed.
+ if (!lastAccessedReportID || lastAccessedReport.policyID === onboardingPolicyID) {
+ // Only navigate to concierge chat when central pane is visible
+ // Otherwise stay on the chats screen.
+ if (!shouldUseNarrowLayout && !backTo) {
+ Report.navigateToConciergeChat();
+ }
+ return;
+ }
+
+ const lastAccessedReportRoute = ROUTES.REPORT_WITH_ID.getRoute(lastAccessedReportID ?? '-1');
+ Navigation.navigate(lastAccessedReportRoute);
+};
+
+export default navigateAfterOnboarding;
diff --git a/src/pages/AddPersonalBankAccountPage.tsx b/src/pages/AddPersonalBankAccountPage.tsx
index fdc200f45ad9..7b0a76e59922 100644
--- a/src/pages/AddPersonalBankAccountPage.tsx
+++ b/src/pages/AddPersonalBankAccountPage.tsx
@@ -15,6 +15,7 @@ import * as BankAccounts from '@userActions/BankAccounts';
import * as PaymentMethods from '@userActions/PaymentMethods';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import SCREENS from '@src/SCREENS';
import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
function AddPersonalBankAccountPage() {
@@ -25,6 +26,21 @@ function AddPersonalBankAccountPage() {
const [personalBankAccount] = useOnyx(ONYXKEYS.PERSONAL_BANK_ACCOUNT);
const [plaidData] = useOnyx(ONYXKEYS.PLAID_DATA);
const shouldShowSuccess = personalBankAccount?.shouldShowSuccess ?? false;
+ const topMostCentralPane = Navigation.getTopMostCentralPaneRouteFromRootState();
+
+ const goBack = useCallback(() => {
+ switch (topMostCentralPane?.name) {
+ case SCREENS.SETTINGS.WALLET.ROOT:
+ Navigation.goBack(ROUTES.SETTINGS_WALLET, true);
+ break;
+ case SCREENS.REPORT:
+ Navigation.closeRHPFlow();
+ break;
+ default:
+ Navigation.goBack();
+ break;
+ }
+ }, [topMostCentralPane]);
const submitBankAccountForm = useCallback(() => {
const bankAccounts = plaidData?.bankAccounts ?? [];
@@ -45,10 +61,10 @@ function AddPersonalBankAccountPage() {
} else if (shouldContinue && onSuccessFallbackRoute) {
PaymentMethods.continueSetup(onSuccessFallbackRoute);
} else {
- Navigation.navigate(ROUTES.SETTINGS_WALLET);
+ goBack();
}
},
- [personalBankAccount],
+ [personalBankAccount, goBack],
);
useEffect(() => BankAccounts.clearPersonalBankAccount, []);
@@ -90,7 +106,7 @@ function AddPersonalBankAccountPage() {
text={translate('walletPage.chooseAccountBody')}
plaidData={plaidData}
isDisplayedInWalletFlow
- onExitPlaid={() => Navigation.navigate(ROUTES.SETTINGS_WALLET)}
+ onExitPlaid={goBack}
receivedRedirectURI={getPlaidOAuthReceivedRedirectURI()}
selectedPlaidAccountID={selectedPlaidAccountId}
/>
diff --git a/src/pages/ChatFinderPage/ChatFinderPageFooter.tsx b/src/pages/ChatFinderPage/ChatFinderPageFooter.tsx
deleted file mode 100644
index 4c006abacfc7..000000000000
--- a/src/pages/ChatFinderPage/ChatFinderPageFooter.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import React from 'react';
-import ReferralProgramCTA from '@components/ReferralProgramCTA';
-import CONST from '@src/CONST';
-
-function ChatFinderPageFooter() {
- return ;
-}
-
-ChatFinderPageFooter.displayName = 'ChatFinderPageFooter';
-
-export default ChatFinderPageFooter;
diff --git a/src/pages/ChatFinderPage/index.tsx b/src/pages/ChatFinderPage/index.tsx
deleted file mode 100644
index aabf881a8bed..000000000000
--- a/src/pages/ChatFinderPage/index.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-import type {StackScreenProps} from '@react-navigation/stack';
-import isEmpty from 'lodash/isEmpty';
-import React, {useCallback, useEffect, useMemo, useState} from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import {useOptionsList} from '@components/OptionListContextProvider';
-import ScreenWrapper from '@components/ScreenWrapper';
-import SelectionList from '@components/SelectionList';
-import UserListItem from '@components/SelectionList/UserListItem';
-import useCancelSearchOnModalClose from '@hooks/useCancelSearchOnModalClose';
-import useDebouncedState from '@hooks/useDebouncedState';
-import useDismissedReferralBanners from '@hooks/useDismissedReferralBanners';
-import useLocalize from '@hooks/useLocalize';
-import useNetwork from '@hooks/useNetwork';
-import Navigation from '@libs/Navigation/Navigation';
-import type {RootStackParamList} from '@libs/Navigation/types';
-import * as OptionsListUtils from '@libs/OptionsListUtils';
-import Performance from '@libs/Performance';
-import type {OptionData} from '@libs/ReportUtils';
-import * as Report from '@userActions/Report';
-import Timing from '@userActions/Timing';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
-import type SCREENS from '@src/SCREENS';
-import type * as OnyxTypes from '@src/types/onyx';
-import ChatFinderPageFooter from './ChatFinderPageFooter';
-
-type ChatFinderPageOnyxProps = {
- /** Beta features list */
- betas: OnyxEntry;
-
- /** Whether or not we are searching for reports on the server */
- isSearchingForReports: OnyxEntry;
-};
-
-type ChatFinderPageProps = ChatFinderPageOnyxProps & StackScreenProps;
-
-type ChatFinderPageSectionItem = {
- data: OptionData[];
- shouldShow: boolean;
-};
-
-type ChatFinderPageSectionList = ChatFinderPageSectionItem[];
-
-const setPerformanceTimersEnd = () => {
- Timing.end(CONST.TIMING.CHAT_FINDER_RENDER);
- Performance.markEnd(CONST.TIMING.CHAT_FINDER_RENDER);
-};
-
-const ChatFinderPageFooterInstance = ;
-
-function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPageProps) {
- const [isScreenTransitionEnd, setIsScreenTransitionEnd] = useState(false);
- const {translate} = useLocalize();
- const {isOffline} = useNetwork();
- const {options, areOptionsInitialized} = useOptionsList({
- shouldInitialize: isScreenTransitionEnd,
- });
-
- const offlineMessage: string = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : '';
-
- const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState('');
- const [, debouncedSearchValueInServer, setSearchValueInServer] = useDebouncedState('', 500);
- const updateSearchValue = useCallback(
- (value: string) => {
- setSearchValue(value);
- setSearchValueInServer(value);
- },
- [setSearchValue, setSearchValueInServer],
- );
- useCancelSearchOnModalClose();
-
- useEffect(() => {
- Report.searchInServer(debouncedSearchValueInServer.trim());
- }, [debouncedSearchValueInServer]);
-
- const searchOptions = useMemo(() => {
- if (!areOptionsInitialized || !isScreenTransitionEnd) {
- return {
- recentReports: [],
- personalDetails: [],
- userToInvite: null,
- currentUserOption: null,
- categoryOptions: [],
- tagOptions: [],
- taxRatesOptions: [],
- headerMessage: '',
- };
- }
- const optionList = OptionsListUtils.getSearchOptions(options, '', betas ?? []);
- const header = OptionsListUtils.getHeaderMessage(optionList.recentReports.length + optionList.personalDetails.length !== 0, !!optionList.userToInvite, '');
- return {...optionList, headerMessage: header};
- }, [areOptionsInitialized, betas, isScreenTransitionEnd, options]);
-
- const filteredOptions = useMemo(() => {
- if (debouncedSearchValue.trim() === '') {
- return {
- recentReports: [],
- personalDetails: [],
- userToInvite: null,
- headerMessage: '',
- };
- }
-
- Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS);
- const newOptions = OptionsListUtils.filterOptions(searchOptions, debouncedSearchValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true});
- Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS);
-
- const header = OptionsListUtils.getHeaderMessage(newOptions.recentReports.length + Number(!!newOptions.userToInvite) > 0, false, debouncedSearchValue);
- return {
- recentReports: newOptions.recentReports,
- personalDetails: newOptions.personalDetails,
- userToInvite: newOptions.userToInvite,
- headerMessage: header,
- };
- }, [debouncedSearchValue, searchOptions]);
-
- const {recentReports, personalDetails: localPersonalDetails, userToInvite, headerMessage} = debouncedSearchValue.trim() !== '' ? filteredOptions : searchOptions;
-
- const sections = useMemo((): ChatFinderPageSectionList => {
- const newSections: ChatFinderPageSectionList = [];
-
- if (recentReports?.length > 0) {
- newSections.push({
- data: recentReports,
- shouldShow: true,
- });
- }
-
- if (localPersonalDetails.length > 0) {
- newSections.push({
- data: localPersonalDetails,
- shouldShow: true,
- });
- }
-
- if (!isEmpty(userToInvite)) {
- newSections.push({
- data: [userToInvite],
- shouldShow: true,
- });
- }
-
- return newSections;
- }, [localPersonalDetails, recentReports, userToInvite]);
-
- const selectReport = (option: OptionData) => {
- if (!option) {
- return;
- }
-
- if (option.reportID) {
- Navigation.closeAndNavigate(ROUTES.REPORT_WITH_ID.getRoute(option.reportID));
- } else {
- Report.navigateToAndOpenReport(option.login ? [option.login] : []);
- }
- };
-
- const handleScreenTransitionEnd = () => {
- setIsScreenTransitionEnd(true);
- };
-
- const {isDismissed} = useDismissedReferralBanners({referralContentType: CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND});
-
- return (
-
-
-
- sections={areOptionsInitialized ? sections : CONST.EMPTY_ARRAY}
- ListItem={UserListItem}
- textInputValue={searchValue}
- textInputLabel={translate('selectionList.nameEmailOrPhoneNumber')}
- textInputHint={offlineMessage}
- onChangeText={updateSearchValue}
- headerMessage={headerMessage}
- onLayout={setPerformanceTimersEnd}
- onSelectRow={selectReport}
- shouldSingleExecuteRowSelect
- showLoadingPlaceholder={!areOptionsInitialized || !isScreenTransitionEnd}
- footerContent={!isDismissed && ChatFinderPageFooterInstance}
- isLoadingNewOptions={!!isSearchingForReports}
- shouldDelayFocus={false}
- />
-
- );
-}
-
-ChatFinderPage.displayName = 'ChatFinderPage';
-
-export default withOnyx({
- betas: {
- key: ONYXKEYS.BETAS,
- },
- isSearchingForReports: {
- key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS,
- initWithStoredValues: false,
- },
-})(ChatFinderPage);
diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx
index 530b4b5f4aec..28f4ddf3dc34 100644
--- a/src/pages/Debug/Report/DebugReportPage.tsx
+++ b/src/pages/Debug/Report/DebugReportPage.tsx
@@ -59,12 +59,12 @@ function DebugReportPage({
return [];
}
- const reasonLHN = DebugUtils.getReasonForShowingRowInLHN(report);
- const {reason: reasonGBR, reportAction: reportActionGBR} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(report) ?? {};
- const reportActionRBR = DebugUtils.getRBRReportAction(report, reportActions);
const shouldDisplayViolations = ReportUtils.shouldDisplayTransactionThreadViolations(report, transactionViolations, parentReportAction);
const shouldDisplayReportViolations = ReportUtils.isReportOwner(report) && ReportUtils.hasReportViolations(reportID);
const hasRBR = SidebarUtils.shouldShowRedBrickRoad(report, reportActions, !!shouldDisplayViolations || shouldDisplayReportViolations, transactionViolations);
+ const reasonLHN = DebugUtils.getReasonForShowingRowInLHN(report, hasRBR);
+ const {reason: reasonGBR, reportAction: reportActionGBR} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(report) ?? {};
+ const reportActionRBR = DebugUtils.getRBRReportAction(report, reportActions);
const hasGBR = !hasRBR && !!reasonGBR;
return [
diff --git a/src/pages/EnablePayments/AddBankAccount/AddBankAccount.tsx b/src/pages/EnablePayments/AddBankAccount/AddBankAccount.tsx
index a274990ea6a7..55f19f8c35b9 100644
--- a/src/pages/EnablePayments/AddBankAccount/AddBankAccount.tsx
+++ b/src/pages/EnablePayments/AddBankAccount/AddBankAccount.tsx
@@ -1,7 +1,6 @@
import React, {useCallback} from 'react';
import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
import ScreenWrapper from '@components/ScreenWrapper';
@@ -16,26 +15,15 @@ import * as Wallet from '@userActions/Wallet';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {PersonalBankAccountForm} from '@src/types/form';
-import type {PersonalBankAccount, PlaidData} from '@src/types/onyx';
import SetupMethod from './SetupMethod';
import Confirmation from './substeps/ConfirmationStep';
import Plaid from './substeps/PlaidStep';
-type AddPersonalBankAccountPageWithOnyxProps = {
- /** Contains plaid data */
- plaidData: OnyxEntry;
-
- /** The details about the Personal bank account we are adding saved in Onyx */
- personalBankAccount: OnyxEntry;
-
- /** The draft values of the bank account being setup */
- personalBankAccountDraft: OnyxEntry;
-};
-
const plaidSubsteps: Array> = [Plaid, Confirmation];
-
-function AddBankAccount({personalBankAccount, plaidData, personalBankAccountDraft}: AddPersonalBankAccountPageWithOnyxProps) {
+function AddBankAccount() {
+ const [plaidData] = useOnyx(ONYXKEYS.PLAID_DATA);
+ const [personalBankAccount] = useOnyx(ONYXKEYS.PERSONAL_BANK_ACCOUNT);
+ const [personalBankAccountDraft] = useOnyx(ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM_DRAFT);
const {translate} = useLocalize();
const styles = useThemeStyles();
@@ -64,7 +52,7 @@ function AddBankAccount({personalBankAccount, plaidData, personalBankAccountDraf
PaymentMethods.continueSetup(onSuccessFallbackRoute);
return;
}
- Navigation.goBack();
+ Navigation.goBack(ROUTES.SETTINGS_WALLET, true);
};
const handleBackButtonPress = () => {
@@ -75,7 +63,7 @@ function AddBankAccount({personalBankAccount, plaidData, personalBankAccountDraf
if (screenIndex === 0) {
BankAccounts.clearPersonalBankAccount();
Wallet.updateCurrentStep(null);
- Navigation.goBack(ROUTES.SETTINGS_WALLET);
+ Navigation.goBack(ROUTES.SETTINGS_WALLET, true);
return;
}
prevScreen();
@@ -118,15 +106,4 @@ function AddBankAccount({personalBankAccount, plaidData, personalBankAccountDraf
AddBankAccount.displayName = 'AddBankAccountPage';
-export default withOnyx({
- plaidData: {
- key: ONYXKEYS.PLAID_DATA,
- },
- // @ts-expect-error: ONYXKEYS.PERSONAL_BANK_ACCOUNT is conflicting with ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM
- personalBankAccount: {
- key: ONYXKEYS.PERSONAL_BANK_ACCOUNT,
- },
- personalBankAccountDraft: {
- key: ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM_DRAFT,
- },
-})(AddBankAccount);
+export default AddBankAccount;
diff --git a/src/pages/EnablePayments/EnablePayments.tsx b/src/pages/EnablePayments/EnablePayments.tsx
index 742202e43bb3..357a2be9b1e0 100644
--- a/src/pages/EnablePayments/EnablePayments.tsx
+++ b/src/pages/EnablePayments/EnablePayments.tsx
@@ -46,7 +46,7 @@ function EnablePaymentsPage() {
>
Navigation.goBack(ROUTES.SETTINGS_WALLET)}
+ onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_WALLET, true)}
/>
diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx
index 527c33bd08e4..c406f7f3058c 100755
--- a/src/pages/NewChatPage.tsx
+++ b/src/pages/NewChatPage.tsx
@@ -259,9 +259,12 @@ function NewChatPage({isGroupChat}: NewChatPageProps) {
disabled={item.isDisabled}
role={CONST.ROLE.BUTTON}
accessibilityLabel={CONST.ROLE.BUTTON}
- style={[styles.flexRow, styles.alignItemsCenter, styles.ml3]}
+ style={[styles.flexRow, styles.alignItemsCenter, styles.ml5, styles.optionSelectCircle]}
>
-
+
);
}
diff --git a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx
index 0530d618d661..ad7e5d38698f 100644
--- a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx
+++ b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx
@@ -10,12 +10,14 @@ import SelectionList from '@components/SelectionList';
import RadioListItem from '@components/SelectionList/RadioListItem';
import type {ListItem} from '@components/SelectionList/types';
import Text from '@components/Text';
+import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
-import Navigation from '@libs/Navigation/Navigation';
+import navigateAfterOnboarding from '@libs/navigateAfterOnboarding';
import variables from '@styles/variables';
import * as Report from '@userActions/Report';
import * as Welcome from '@userActions/Welcome';
@@ -35,11 +37,13 @@ function BaseOnboardingAccounting({shouldUseNativeStyles, route}: BaseOnboarding
const theme = useTheme();
const StyleUtils = useStyleUtils();
const {translate} = useLocalize();
- const {onboardingIsMediumOrLargerScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
+ const {onboardingIsMediumOrLargerScreenWidth, isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED);
const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID);
const [onboardingAdminsChatReportID] = useOnyx(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID);
const [onboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE);
+ const {canUseDefaultRooms} = usePermissions();
+ const {activeWorkspaceID} = useActiveWorkspace();
const [userReportedIntegration, setUserReportedIntegration] = useState(undefined);
const [error, setError] = useState('');
@@ -144,13 +148,7 @@ function BaseOnboardingAccounting({shouldUseNativeStyles, route}: BaseOnboarding
Welcome.setOnboardingAdminsChatReportID();
Welcome.setOnboardingPolicyID();
- Navigation.dismissModal();
-
- // Only navigate to concierge chat when central pane is visible
- // Otherwise stay on the chats screen.
- if (!shouldUseNarrowLayout && !route.params?.backTo) {
- Report.navigateToConciergeChat();
- }
+ navigateAfterOnboarding(isSmallScreenWidth, shouldUseNarrowLayout, canUseDefaultRooms, onboardingPolicyID, activeWorkspaceID, route.params?.backTo);
}}
pressOnEnter
/>
diff --git a/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx b/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx
index 480ad51f6810..0fcccd723543 100644
--- a/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx
+++ b/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx
@@ -12,6 +12,7 @@ import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
+import * as Policy from '@userActions/Policy/Policy';
import * as Welcome from '@userActions/Welcome';
import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow';
import CONST from '@src/CONST';
@@ -28,6 +29,7 @@ function BaseOnboardingEmployees({shouldUseNativeStyles, route}: BaseOnboardingE
const {translate} = useLocalize();
const [onboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE);
const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED);
+ const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID);
const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
const [selectedCompanySize, setSelectedCompanySize] = useState(onboardingCompanySize);
const [error, setError] = useState('');
@@ -61,6 +63,13 @@ function BaseOnboardingEmployees({shouldUseNativeStyles, route}: BaseOnboardingE
return;
}
Welcome.setOnboardingCompanySize(selectedCompanySize);
+
+ if (!onboardingPolicyID) {
+ const {adminsChatReportID, policyID} = Policy.createWorkspace(undefined, true, '', Policy.generatePolicyID(), CONST.ONBOARDING_CHOICES.MANAGE_TEAM);
+ Welcome.setOnboardingAdminsChatReportID(adminsChatReportID);
+ Welcome.setOnboardingPolicyID(policyID);
+ }
+
Navigation.navigate(ROUTES.ONBOARDING_ACCOUNTING.getRoute(route.params?.backTo));
}}
pressOnEnter
diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx
index eb6d61770be0..f1c79d7aa76b 100644
--- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx
+++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx
@@ -10,13 +10,15 @@ import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
+import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
+import usePermissions from '@hooks/usePermissions';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import * as ErrorUtils from '@libs/ErrorUtils';
-import Navigation from '@libs/Navigation/Navigation';
+import navigateAfterOnboarding from '@libs/navigateAfterOnboarding';
import * as ValidationUtils from '@libs/ValidationUtils';
import * as PersonalDetails from '@userActions/PersonalDetails';
import * as Report from '@userActions/Report';
@@ -33,10 +35,12 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat
const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED);
const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID);
const [onboardingAdminsChatReportID] = useOnyx(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID);
- const {shouldUseNarrowLayout, isSmallScreenWidth, onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
+ const {onboardingIsMediumOrLargerScreenWidth, isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
const {inputCallbackRef} = useAutoFocusInput();
const [shouldValidateOnChange, setShouldValidateOnChange] = useState(false);
const {isOffline} = useNetwork();
+ const {canUseDefaultRooms} = usePermissions();
+ const {activeWorkspaceID} = useActiveWorkspace();
useEffect(() => {
Welcome.setOnboardingErrorMessage('');
@@ -65,15 +69,9 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat
Welcome.setOnboardingAdminsChatReportID();
Welcome.setOnboardingPolicyID();
- Navigation.dismissModal();
-
- // Only navigate to concierge chat when central pane is visible
- // Otherwise stay on the chats screen.
- if (!shouldUseNarrowLayout && !route.params?.backTo) {
- Report.navigateToConciergeChat();
- }
+ navigateAfterOnboarding(isSmallScreenWidth, shouldUseNarrowLayout, canUseDefaultRooms, onboardingPolicyID, activeWorkspaceID, route.params?.backTo);
},
- [onboardingPurposeSelected, onboardingAdminsChatReportID, onboardingPolicyID, shouldUseNarrowLayout, route.params?.backTo],
+ [onboardingPurposeSelected, onboardingAdminsChatReportID, onboardingPolicyID, route.params?.backTo, activeWorkspaceID, canUseDefaultRooms, isSmallScreenWidth, shouldUseNarrowLayout],
);
const validate = (values: FormOnyxValues<'onboardingPersonalDetailsForm'>) => {
diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx
index 2bdd601b4b59..a59042c572a1 100644
--- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx
+++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx
@@ -20,8 +20,6 @@ import Navigation from '@libs/Navigation/Navigation';
import OnboardingRefManager from '@libs/OnboardingRefManager';
import type {TOnboardingRef} from '@libs/OnboardingRefManager';
import variables from '@styles/variables';
-import * as Policy from '@userActions/Policy/Policy';
-import {generatePolicyID} from '@userActions/Policy/Policy';
import * as Welcome from '@userActions/Welcome';
import CONST from '@src/CONST';
import type {OnboardingPurposeType} from '@src/CONST';
@@ -51,7 +49,6 @@ const menuIcons = {
function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, route}: BaseOnboardingPurposeProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
- const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID);
const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
const {windowHeight} = useWindowDimensions();
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to show offline indicator on small screen only
@@ -84,11 +81,6 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro
Welcome.setOnboardingErrorMessage('');
if (choice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM) {
- if (!onboardingPolicyID) {
- const {adminsChatReportID, policyID} = Policy.createWorkspace(undefined, true, '', generatePolicyID(), choice);
- Welcome.setOnboardingAdminsChatReportID(adminsChatReportID);
- Welcome.setOnboardingPolicyID(policyID);
- }
Navigation.navigate(ROUTES.ONBOARDING_EMPLOYEES.getRoute(route.params?.backTo));
return;
}
diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx
index 5292e5ec8bf0..18304878447d 100644
--- a/src/pages/ReportDetailsPage.tsx
+++ b/src/pages/ReportDetailsPage.tsx
@@ -11,6 +11,7 @@ import DecisionModal from '@components/DecisionModal';
import DelegateNoAccessModal from '@components/DelegateNoAccessModal';
import DisplayNames from '@components/DisplayNames';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext';
import * as Expensicons from '@components/Icon/Expensicons';
import MenuItem from '@components/MenuItem';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
@@ -775,6 +776,9 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) {
isTransactionDeleted.current = true;
}, [caseID, iouTransactionID, moneyRequestReport?.reportID, report, requestParentReportAction, isSingleTransactionView]);
+
+ const mentionReportContextValue = useMemo(() => ({currentReportID: report.reportID, exactlyMatch: true}), [report.reportID]);
+
return (
@@ -794,17 +798,19 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) {
{shouldShowReportDescription && (
- Navigation.navigate(ROUTES.REPORT_DESCRIPTION.getRoute(report.reportID, Navigation.getActiveRoute()))}
- />
+
+ Navigation.navigate(ROUTES.REPORT_DESCRIPTION.getRoute(report.reportID, Navigation.getActiveRoute()))}
+ />
+
)}
diff --git a/src/pages/RoomMemberDetailsPage.tsx b/src/pages/RoomMemberDetailsPage.tsx
index 475cf37a8847..3a9d51a251a1 100644
--- a/src/pages/RoomMemberDetailsPage.tsx
+++ b/src/pages/RoomMemberDetailsPage.tsx
@@ -12,10 +12,13 @@ import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
+import usePolicy from '@hooks/usePolicy';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import * as Report from '@libs/actions/Report';
import type {RoomMembersNavigatorParamList} from '@libs/Navigation/types';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import * as ReportUtils from '@libs/ReportUtils';
import Navigation from '@navigation/Navigation';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -34,6 +37,7 @@ function RoomMemberDetailsPage({report, route}: RoomMemberDetailsPagePageProps)
const StyleUtils = useStyleUtils();
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
+ const policy = usePolicy(report?.policyID);
const [isRemoveMemberConfirmModalVisible, setIsRemoveMemberConfirmModalVisible] = React.useState(false);
@@ -45,6 +49,8 @@ function RoomMemberDetailsPage({report, route}: RoomMemberDetailsPagePageProps)
const fallbackIcon = details.fallbackIcon ?? '';
const displayName = details.displayName ?? '';
const isSelectedMemberCurrentUser = accountID === currentUserPersonalDetails?.accountID;
+ const isSelectedMemberOwner = accountID === report.ownerAccountID;
+ const shouldDisableRemoveUser = (ReportUtils.isPolicyExpenseChat(report) && PolicyUtils.isUserPolicyAdmin(policy, details.login)) || isSelectedMemberCurrentUser || isSelectedMemberOwner;
const removeUser = useCallback(() => {
setIsRemoveMemberConfirmModalVisible(false);
Report.removeFromRoom(report?.reportID, [accountID]);
@@ -88,7 +94,7 @@ function RoomMemberDetailsPage({report, route}: RoomMemberDetailsPagePageProps)
);
}
diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx
index fe07dcc8c99b..42ab49e2ed50 100644
--- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx
+++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx
@@ -20,6 +20,7 @@ import * as LoginUtils from '@libs/LoginUtils';
import Navigation from '@libs/Navigation/Navigation';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
import * as UserUtils from '@libs/UserUtils';
+import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
import * as User from '@userActions/User';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -40,6 +41,8 @@ function NewContactMethodPage({route}: NewContactMethodPageProps) {
const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST);
const loginData = loginList?.[pendingContactAction?.contactMethod ?? contactMethod];
const validateLoginError = ErrorUtils.getEarliestErrorField(loginData, 'validateLogin');
+ const [account] = useOnyx(ONYXKEYS.ACCOUNT);
+ const isActingAsDelegate = !!account?.delegatedAccess?.delegate;
const navigateBackTo = route?.params?.backTo ?? ROUTES.SETTINGS_PROFILE;
@@ -100,56 +103,58 @@ function NewContactMethodPage({route}: NewContactMethodPageProps) {
}, [navigateBackTo]);
return (
- loginInputRef.current?.focus()}
- includeSafeAreaPaddingBottom={false}
- shouldEnableMaxHeight
- testID={NewContactMethodPage.displayName}
- >
-
-
+ loginInputRef.current?.focus()}
+ includeSafeAreaPaddingBottom={false}
+ shouldEnableMaxHeight
+ testID={NewContactMethodPage.displayName}
>
- {translate('common.pleaseEnterEmailOrPhoneNumber')}
-
-
-
- {hasFailedToSendVerificationCode && (
-
- )}
-
- User.clearContactMethodErrors(contactMethod, 'validateLogin')}
- onClose={() => setIsValidateCodeActionModalVisible(false)}
- isVisible={isValidateCodeActionModalVisible}
- title={contactMethod}
- description={translate('contacts.enterMagicCode', {contactMethod})}
- />
-
+
+
+ {translate('common.pleaseEnterEmailOrPhoneNumber')}
+
+
+
+ {hasFailedToSendVerificationCode && (
+
+ )}
+
+ User.clearContactMethodErrors(contactMethod, 'validateLogin')}
+ onClose={() => setIsValidateCodeActionModalVisible(false)}
+ isVisible={isValidateCodeActionModalVisible}
+ title={contactMethod}
+ description={translate('contacts.enterMagicCode', {contactMethod})}
+ />
+
+
);
}
diff --git a/src/pages/signin/LoginForm/BaseLoginForm.tsx b/src/pages/signin/LoginForm/BaseLoginForm.tsx
index f3df5ebfb0b5..2e762224f904 100644
--- a/src/pages/signin/LoginForm/BaseLoginForm.tsx
+++ b/src/pages/signin/LoginForm/BaseLoginForm.tsx
@@ -3,8 +3,7 @@ import {Str} from 'expensify-common';
import type {ForwardedRef} from 'react';
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {InteractionManager, View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import DotIndicatorMessage from '@components/DotIndicatorMessage';
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import AppleSignIn from '@components/SignInButtons/AppleSignIn';
@@ -34,24 +33,16 @@ import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {CloseAccountForm} from '@src/types/form';
-import type {Account} from '@src/types/onyx';
import htmlDivElementRef from '@src/types/utils/htmlDivElementRef';
import viewRef from '@src/types/utils/viewRef';
import type LoginFormProps from './types';
import type {InputHandle} from './types';
-type BaseLoginFormOnyxProps = {
- /** The details about the account that the user is signing in with */
- account: OnyxEntry;
+type BaseLoginFormProps = WithToggleVisibilityViewProps & LoginFormProps;
- /** Message to display when user successfully closed their account */
- closeAccount: OnyxEntry;
-};
-
-type BaseLoginFormProps = WithToggleVisibilityViewProps & BaseLoginFormOnyxProps & LoginFormProps;
-
-function BaseLoginForm({account, login, onLoginChanged, closeAccount, blurOnSubmit = false, isVisible}: BaseLoginFormProps, ref: ForwardedRef) {
+function BaseLoginForm({login, onLoginChanged, blurOnSubmit = false, isVisible}: BaseLoginFormProps, ref: ForwardedRef) {
+ const [account] = useOnyx(ONYXKEYS.ACCOUNT);
+ const [closeAccount] = useOnyx(ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM);
const styles = useThemeStyles();
const {isOffline} = useNetwork();
const {translate} = useLocalize();
@@ -62,6 +53,7 @@ function BaseLoginForm({account, login, onLoginChanged, closeAccount, blurOnSubm
const isFocused = useIsFocused();
const isLoading = useRef(false);
const {shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout();
+ const accountMessage = account?.message === 'unlinkLoginForm.succesfullyUnlinkedLogin' ? translate(account.message) : account?.message ?? '';
/**
* Validate the input value and set the error for formError
@@ -276,7 +268,7 @@ function BaseLoginForm({account, login, onLoginChanged, closeAccount, blurOnSubm
style={[styles.mv2]}
type="success"
// eslint-disable-next-line @typescript-eslint/naming-convention,@typescript-eslint/prefer-nullish-coalescing
- messages={{0: closeAccount?.success ? closeAccount.success : account?.message || ''}}
+ messages={{0: closeAccount?.success ? closeAccount.success : accountMessage}}
/>
)}
{
@@ -331,9 +323,4 @@ function BaseLoginForm({account, login, onLoginChanged, closeAccount, blurOnSubm
BaseLoginForm.displayName = 'BaseLoginForm';
-export default withToggleVisibilityView(
- withOnyx({
- account: {key: ONYXKEYS.ACCOUNT},
- closeAccount: {key: ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM},
- })(forwardRef(BaseLoginForm)),
-);
+export default withToggleVisibilityView(forwardRef(BaseLoginForm));
diff --git a/src/pages/signin/LoginForm/index.native.tsx b/src/pages/signin/LoginForm/index.native.tsx
index 6d8f771810e7..9a9730639b21 100644
--- a/src/pages/signin/LoginForm/index.native.tsx
+++ b/src/pages/signin/LoginForm/index.native.tsx
@@ -6,7 +6,7 @@ import type {InputHandle} from './types';
import type LoginFormProps from './types';
function LoginForm({scrollPageToTop, ...rest}: LoginFormProps, ref: ForwardedRef) {
- const loginFormRef = useRef();
+ const loginFormRef = useRef(null);
useImperativeHandle(ref, () => ({
isInputFocused: loginFormRef.current ? loginFormRef.current.isInputFocused : () => false,
diff --git a/src/pages/signin/SignInModal.tsx b/src/pages/signin/SignInModal.tsx
index bad93b29f8af..8cfa3ba3dcc8 100644
--- a/src/pages/signin/SignInModal.tsx
+++ b/src/pages/signin/SignInModal.tsx
@@ -1,36 +1,37 @@
import React, {useEffect, useRef} from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import {useSession} from '@components/OnyxProvider';
import ScreenWrapper from '@components/ScreenWrapper';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import Navigation from '@libs/Navigation/Navigation';
+import {waitForIdle} from '@libs/Network/SequentialQueue';
import * as App from '@userActions/App';
import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
import SCREENS from '@src/SCREENS';
-import type {Session} from '@src/types/onyx';
import SignInPage from './SignInPage';
import type {SignInPageRef} from './SignInPage';
-type SignInModalOnyxProps = {
- session: OnyxEntry;
-};
-
-type SignInModalProps = SignInModalOnyxProps;
-
-function SignInModal({session}: SignInModalProps) {
+function SignInModal() {
const theme = useTheme();
const StyleUtils = useStyleUtils();
const siginPageRef = useRef(null);
+ const session = useSession();
useEffect(() => {
const isAnonymousUser = session?.authTokenType === CONST.AUTH_TOKEN_TYPES.ANONYMOUS;
if (!isAnonymousUser) {
// Signing in RHP is only for anonymous users
- Navigation.isNavigationReady().then(() => Navigation.dismissModal());
- App.openApp();
+ Navigation.isNavigationReady().then(() => {
+ Navigation.dismissModal();
+ });
+
+ // To prevent deadlock when OpenReport and OpenApp overlap, wait for the queue to be idle before calling openApp.
+ // This ensures that any communication gaps between the client and server during OpenReport processing do not cause the queue to pause,
+ // which would prevent us from processing or clearing the queue.
+ waitForIdle().then(() => {
+ App.openApp();
+ });
}
}, [session?.authTokenType]);
@@ -61,6 +62,4 @@ function SignInModal({session}: SignInModalProps) {
SignInModal.displayName = 'SignInModal';
-export default withOnyx({
- session: {key: ONYXKEYS.SESSION},
-})(SignInModal);
+export default SignInModal;
diff --git a/src/pages/signin/SignUpWelcomeForm.tsx b/src/pages/signin/SignUpWelcomeForm.tsx
index 68bb7d10796a..be16475cdfc7 100644
--- a/src/pages/signin/SignUpWelcomeForm.tsx
+++ b/src/pages/signin/SignUpWelcomeForm.tsx
@@ -1,28 +1,23 @@
-import React from 'react';
+import React, {useMemo} from 'react';
import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
+import FormHelpMessage from '@components/FormHelpMessage';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as ErrorUtils from '@libs/ErrorUtils';
import * as Session from '@userActions/Session';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {Account} from '@src/types/onyx';
import ChangeExpensifyLoginLink from './ChangeExpensifyLoginLink';
import Terms from './Terms';
-type SignUpWelcomeFormOnyxProps = {
- /** State for the account */
- account: OnyxEntry;
-};
-
-type SignUpWelcomeFormProps = SignUpWelcomeFormOnyxProps;
-
-function SignUpWelcomeForm({account}: SignUpWelcomeFormProps) {
+function SignUpWelcomeForm() {
const network = useNetwork();
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const [account] = useOnyx(ONYXKEYS.ACCOUNT);
+ const serverErrorText = useMemo(() => (account ? ErrorUtils.getLatestErrorMessage(account) : ''), [account]);
return (
<>
@@ -37,6 +32,12 @@ function SignUpWelcomeForm({account}: SignUpWelcomeFormProps) {
pressOnEnter
style={[styles.mb2]}
/>
+ {serverErrorText && (
+
+ )}
Session.clearSignInData()} />
@@ -47,6 +48,4 @@ function SignUpWelcomeForm({account}: SignUpWelcomeFormProps) {
}
SignUpWelcomeForm.displayName = 'SignUpWelcomeForm';
-export default withOnyx({
- account: {key: ONYXKEYS.ACCOUNT},
-})(SignUpWelcomeForm);
+export default SignUpWelcomeForm;
diff --git a/src/pages/workspace/AccessOrNotFoundWrapper.tsx b/src/pages/workspace/AccessOrNotFoundWrapper.tsx
index 5deae769531d..b83c703fbe77 100644
--- a/src/pages/workspace/AccessOrNotFoundWrapper.tsx
+++ b/src/pages/workspace/AccessOrNotFoundWrapper.tsx
@@ -6,6 +6,7 @@ import type {FullPageNotFoundViewProps} from '@components/BlockingViews/FullPage
import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useNetwork from '@hooks/useNetwork';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
import * as IOUUtils from '@libs/IOUUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
@@ -86,6 +87,7 @@ type AccessOrNotFoundWrapperProps = {
type PageNotFoundFallbackProps = Pick & {shouldShowFullScreenFallback: boolean; isMoneyRequest: boolean};
function PageNotFoundFallback({policyID, shouldShowFullScreenFallback, fullPageNotFoundViewProps, isMoneyRequest}: PageNotFoundFallbackProps) {
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
return (
);
}
diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx
index 0f02e350d91a..99979e359c20 100644
--- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx
+++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx
@@ -107,7 +107,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) {
const overflowMenu: ThreeDotsMenuProps['menuItems'] = useMemo(
() => [
- ...(shouldShowEnterCredentials && (connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT || connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.NETSUITE)
+ ...(shouldShowEnterCredentials
? [
{
icon: Expensicons.Key,
diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomSegmentPage.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomSegmentPage.tsx
index eb22f8b3dec9..1e711a2b082e 100644
--- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomSegmentPage.tsx
+++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomSegmentPage.tsx
@@ -203,7 +203,7 @@ function NetSuiteImportAddCustomSegmentPage({policy}: WithPolicyConnectionsProps
stepNames={CONST.NETSUITE_CONFIG.NETSUITE_ADD_CUSTOM_SEGMENT_STEP_NAMES}
/>
-
+
{screenIndex === CONST.NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES.CUSTOM_SEGMENTS.SEGMENT_TYPE ? (
renderSubStepContent
) : (
diff --git a/src/pages/workspace/accounting/qbd/advanced/QuickbooksDesktopAdvancedPage.tsx b/src/pages/workspace/accounting/qbd/advanced/QuickbooksDesktopAdvancedPage.tsx
new file mode 100644
index 000000000000..cd54127d3a44
--- /dev/null
+++ b/src/pages/workspace/accounting/qbd/advanced/QuickbooksDesktopAdvancedPage.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import ConnectionLayout from '@components/ConnectionLayout';
+import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as QuickbooksDesktop from '@libs/actions/connections/QuickbooksDesktop';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import {settingsPendingAction} from '@libs/PolicyUtils';
+import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections';
+import withPolicyConnections from '@pages/workspace/withPolicyConnections';
+import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow';
+import {clearQBDErrorField} from '@userActions/Policy/Policy';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+
+function QuickbooksDesktopAdvancedPage({policy}: WithPolicyConnectionsProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const policyID = policy?.id ?? '-1';
+ const qbdConfig = policy?.connections?.quickbooksDesktop?.config;
+ const {canUseNewDotQBD} = usePermissions();
+
+ const qbdToggleSettingItems = [
+ {
+ title: translate('workspace.accounting.autoSync'),
+ subtitle: translate('workspace.qbd.advancedConfig.autoSyncDescription'),
+ switchAccessibilityLabel: translate('workspace.qbd.advancedConfig.autoSyncDescription'),
+ isActive: !!qbdConfig?.autoSync?.enabled,
+ onToggle: (isOn: boolean) => QuickbooksDesktop.updateQuickbooksDesktopAutoSync(policyID, isOn),
+ subscribedSetting: CONST.QUICKBOOKS_DESKTOP_CONFIG.AUTO_SYNC,
+ errors: ErrorUtils.getLatestErrorField(qbdConfig, CONST.QUICKBOOKS_DESKTOP_CONFIG.AUTO_SYNC),
+ pendingAction: settingsPendingAction([CONST.QUICKBOOKS_DESKTOP_CONFIG.AUTO_SYNC], qbdConfig?.pendingFields),
+ },
+ {
+ title: translate('workspace.qbd.advancedConfig.createEntities'),
+ subtitle: translate('workspace.qbd.advancedConfig.createEntitiesDescription'),
+ switchAccessibilityLabel: translate('workspace.qbd.advancedConfig.createEntitiesDescription'),
+ isActive: !!qbdConfig?.shouldAutoCreateVendor,
+ onToggle: (isOn: boolean) => {
+ QuickbooksDesktop.updateQuickbooksDesktopShouldAutoCreateVendor(policyID, isOn);
+ },
+ subscribedSetting: CONST.QUICKBOOKS_DESKTOP_CONFIG.SHOULD_AUTO_CREATE_VENDOR,
+ errors: ErrorUtils.getLatestErrorField(qbdConfig, CONST.QUICKBOOKS_DESKTOP_CONFIG.SHOULD_AUTO_CREATE_VENDOR),
+ pendingAction: settingsPendingAction([CONST.QUICKBOOKS_DESKTOP_CONFIG.SHOULD_AUTO_CREATE_VENDOR], qbdConfig?.pendingFields),
+ },
+ ];
+
+ return (
+ Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID))}
+ >
+ {qbdToggleSettingItems.map((item) => (
+ clearQBDErrorField(policyID, item.subscribedSetting)}
+ />
+ ))}
+
+ );
+}
+
+QuickbooksDesktopAdvancedPage.displayName = 'QuickbooksDesktopAdvancedPage';
+
+export default withPolicyConnections(QuickbooksDesktopAdvancedPage);
diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopExportDateSelectPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopExportDateSelectPage.tsx
new file mode 100644
index 000000000000..c4f648283338
--- /dev/null
+++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopExportDateSelectPage.tsx
@@ -0,0 +1,80 @@
+import React, {useCallback, useMemo} from 'react';
+import type {ValueOf} from 'type-fest';
+import RadioListItem from '@components/SelectionList/RadioListItem';
+import type {ListItem} from '@components/SelectionList/types';
+import SelectionScreen from '@components/SelectionScreen';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as QuickbooksDesktop from '@libs/actions/connections/QuickbooksDesktop';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import Navigation from '@navigation/Navigation';
+import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections';
+import withPolicyConnections from '@pages/workspace/withPolicyConnections';
+import {clearQBDErrorField} from '@userActions/Policy/Policy';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+
+type CardListItem = ListItem & {
+ value: ValueOf;
+};
+function QuickbooksDesktopExportDateSelectPage({policy}: WithPolicyConnectionsProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const policyID = policy?.id ?? '-1';
+ const qbdConfig = policy?.connections?.quickbooksDesktop?.config;
+ const exportDate = qbdConfig?.export?.exportDate;
+
+ const data: CardListItem[] = useMemo(
+ () =>
+ Object.values(CONST.QUICKBOOKS_EXPORT_DATE).map((dateType) => ({
+ value: dateType,
+ text: translate(`workspace.qbd.exportDate.values.${dateType}.label`),
+ alternateText: translate(`workspace.qbd.exportDate.values.${dateType}.description`),
+ keyForList: dateType,
+ isSelected: exportDate === dateType,
+ })),
+ [exportDate, translate],
+ );
+
+ const {canUseNewDotQBD} = usePermissions();
+
+ const selectExportDate = useCallback(
+ (row: CardListItem) => {
+ if (row.value !== exportDate) {
+ QuickbooksDesktop.updateQuickbooksDesktopExportDate(policyID, row.value, exportDate);
+ }
+ Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT_DATE_SELECT.getRoute(policyID));
+ },
+ [policyID, exportDate],
+ );
+
+ return (
+ {translate('workspace.qbd.exportDate.description')}}
+ onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.getRoute(policyID))}
+ onSelectRow={selectExportDate}
+ initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList}
+ title="workspace.qbd.exportDate.label"
+ shouldBeBlocked={!canUseNewDotQBD} // TODO: remove it once the QBD beta is done
+ connectionName={CONST.POLICY.CONNECTIONS.NAME.QBD}
+ pendingAction={PolicyUtils.settingsPendingAction([CONST.QUICKBOOKS_DESKTOP_CONFIG.EXPORT_DATE], qbdConfig?.pendingFields)}
+ errors={ErrorUtils.getLatestErrorField(qbdConfig, CONST.QUICKBOOKS_DESKTOP_CONFIG.EXPORT_DATE)}
+ errorRowStyles={[styles.ph5, styles.pv3]}
+ onClose={() => clearQBDErrorField(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.EXPORT_DATE)}
+ shouldSingleExecuteRowSelect
+ />
+ );
+}
+
+QuickbooksDesktopExportDateSelectPage.displayName = 'QuickbooksDesktopExportDateSelectPage';
+
+export default withPolicyConnections(QuickbooksDesktopExportDateSelectPage);
diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopExportPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopExportPage.tsx
index c65b3fd8b4ea..dbd07f8b87a2 100644
--- a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopExportPage.tsx
+++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopExportPage.tsx
@@ -32,15 +32,15 @@ function QuickbooksDesktopExportPage({policy}: WithPolicyConnectionsProps) {
const menuItems = [
{
description: translate('workspace.accounting.preferredExporter'),
- onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_PREFERRED_EXPORTER.getRoute(policyID)), // TODO: [QBD] should be updated to use new routes
+ onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_PREFERRED_EXPORTER.getRoute(policyID)),
title: qbdConfig?.export?.exporter ?? policyOwner,
- subscribedSettings: [CONST.QUICKBOOKS_CONFIG.EXPORT],
+ subscribedSettings: [CONST.QUICKBOOKS_DESKTOP_CONFIG.EXPORTER],
},
{
description: translate('workspace.qbd.date'),
- onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_DATE_SELECT.getRoute(policyID)), // TODO: [QBD] should be updated to use new routes
- title: qbdConfig?.export.exportDate ? translate(`workspace.qbd.exportDate.values.${qbdConfig?.export.exportDate}.label`) : undefined,
- subscribedSettings: [CONST.QUICKBOOKS_CONFIG.EXPORT_DATE],
+ onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT_DATE_SELECT.getRoute(policyID)),
+ title: qbdConfig?.export?.exportDate ? translate(`workspace.qbd.exportDate.values.${qbdConfig?.export.exportDate}.label`) : undefined,
+ subscribedSettings: [CONST.QUICKBOOKS_DESKTOP_CONFIG.EXPORT_DATE],
},
{
description: translate('workspace.accounting.exportOutOfPocket'),
diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopPreferredExporterConfigurationPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopPreferredExporterConfigurationPage.tsx
new file mode 100644
index 000000000000..00b57ca4fd9c
--- /dev/null
+++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopPreferredExporterConfigurationPage.tsx
@@ -0,0 +1,103 @@
+import React, {useCallback, useMemo} from 'react';
+import RadioListItem from '@components/SelectionList/RadioListItem';
+import type {ListItem} from '@components/SelectionList/types';
+import SelectionScreen from '@components/SelectionScreen';
+import Text from '@components/Text';
+import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
+import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as QuickbooksDesktop from '@libs/actions/connections/QuickbooksDesktop';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import {getAdminEmployees} from '@libs/PolicyUtils';
+import Navigation from '@navigation/Navigation';
+import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections';
+import withPolicyConnections from '@pages/workspace/withPolicyConnections';
+import {clearQBDErrorField} from '@userActions/Policy/Policy';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+
+type CardListItem = ListItem & {
+ value: string;
+};
+
+function QuickbooksDesktopPreferredExporterConfigurationPage({policy}: WithPolicyConnectionsProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const qbdConfig = policy?.connections?.quickbooksDesktop?.config;
+ const exporters = getAdminEmployees(policy);
+ const {login: currentUserLogin} = useCurrentUserPersonalDetails();
+ const {canUseNewDotQBD} = usePermissions();
+ const currentExporter = qbdConfig?.export?.exporter;
+
+ const policyID = policy?.id ?? '-1';
+ const data: CardListItem[] = useMemo(
+ () =>
+ exporters?.reduce((options, exporter) => {
+ if (!exporter.email) {
+ return options;
+ }
+
+ // Don't show guides if the current user is not a guide themselves or an Expensify employee
+ if (PolicyUtils.isExpensifyTeam(exporter.email) && !PolicyUtils.isExpensifyTeam(policy?.owner) && !PolicyUtils.isExpensifyTeam(currentUserLogin)) {
+ return options;
+ }
+ options.push({
+ value: exporter.email,
+ text: exporter.email,
+ keyForList: exporter.email,
+ isSelected: (currentExporter ?? policy?.owner) === exporter.email,
+ });
+ return options;
+ }, []),
+ [exporters, policy?.owner, currentUserLogin, currentExporter],
+ );
+
+ const selectExporter = useCallback(
+ (row: CardListItem) => {
+ if (row.value !== currentExporter) {
+ QuickbooksDesktop.updateQuickbooksDesktopPreferredExporter(policyID, row.value, currentExporter);
+ }
+ Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_PREFERRED_EXPORTER.getRoute(policyID));
+ },
+ [currentExporter, policyID],
+ );
+
+ const headerContent = useMemo(
+ () => (
+ <>
+ {translate('workspace.accounting.exportPreferredExporterNote')}
+ {translate('workspace.accounting.exportPreferredExporterSubNote')}
+ >
+ ),
+ [translate, styles.ph5, styles.pb5],
+ );
+
+ return (
+ Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.getRoute(policyID))}
+ onSelectRow={selectExporter}
+ shouldSingleExecuteRowSelect
+ initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList}
+ title="workspace.accounting.preferredExporter"
+ shouldBeBlocked={!canUseNewDotQBD} // TODO: [QBD] Remove it once the QBD beta is done
+ connectionName={CONST.POLICY.CONNECTIONS.NAME.QBD}
+ pendingAction={PolicyUtils.settingsPendingAction([CONST.QUICKBOOKS_DESKTOP_CONFIG.EXPORTER], qbdConfig?.pendingFields)}
+ errors={ErrorUtils.getLatestErrorField(qbdConfig, CONST.QUICKBOOKS_DESKTOP_CONFIG.EXPORTER)}
+ errorRowStyles={[styles.ph5, styles.pv3]}
+ onClose={() => clearQBDErrorField(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.EXPORTER)}
+ />
+ );
+}
+
+QuickbooksDesktopPreferredExporterConfigurationPage.displayName = 'QuickbooksDesktopPreferredExporterConfigurationPage';
+
+export default withPolicyConnections(QuickbooksDesktopPreferredExporterConfigurationPage);
diff --git a/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopChartOfAccountsPage.tsx b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopChartOfAccountsPage.tsx
new file mode 100644
index 000000000000..8e6cd895c435
--- /dev/null
+++ b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopChartOfAccountsPage.tsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import ConnectionLayout from '@components/ConnectionLayout';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as QuickbooksDesktop from '@libs/actions/connections/QuickbooksDesktop';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import {settingsPendingAction} from '@libs/PolicyUtils';
+import type {WithPolicyProps} from '@pages/workspace/withPolicy';
+import withPolicyConnections from '@pages/workspace/withPolicyConnections';
+import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow';
+import {clearQBDErrorField} from '@userActions/Policy/Policy';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+
+function QuickbooksDesktopChartOfAccountsPage({policy}: WithPolicyProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const policyID = policy?.id ?? '-1';
+ const {canUseNewDotQBD} = usePermissions();
+ const qbdConfig = policy?.connections?.quickbooksDesktop?.config;
+
+ return (
+ Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_IMPORT.getRoute(policyID))}
+ >
+ {}}
+ disabled
+ showLockIcon
+ />
+
+ {translate('workspace.qbd.accountsSwitchTitle')}
+ QuickbooksDesktop.updateQuickbooksDesktopEnableNewCategories(policyID, !qbdConfig?.enableNewCategories)}
+ pendingAction={settingsPendingAction([CONST.QUICKBOOKS_DESKTOP_CONFIG.ENABLE_NEW_CATEGORIES], qbdConfig?.pendingFields)}
+ errors={ErrorUtils.getLatestErrorField(qbdConfig, CONST.QUICKBOOKS_DESKTOP_CONFIG.ENABLE_NEW_CATEGORIES)}
+ onCloseError={() => clearQBDErrorField(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.ENABLE_NEW_CATEGORIES)}
+ />
+
+ );
+}
+
+QuickbooksDesktopChartOfAccountsPage.displayName = 'QuickbooksDesktopChartOfAccountsPage';
+
+export default withPolicyConnections(QuickbooksDesktopChartOfAccountsPage);
diff --git a/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopClassesDisplayedAsPage.tsx b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopClassesDisplayedAsPage.tsx
new file mode 100644
index 000000000000..159e4e948f6a
--- /dev/null
+++ b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopClassesDisplayedAsPage.tsx
@@ -0,0 +1,81 @@
+import React, {useCallback} from 'react';
+import RadioListItem from '@components/SelectionList/RadioListItem';
+import type {ListItem} from '@components/SelectionList/types';
+import SelectionScreen from '@components/SelectionScreen';
+import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as QuickbooksDesktop from '@libs/actions/connections/QuickbooksDesktop';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import Navigation from '@navigation/Navigation';
+import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections';
+import withPolicyConnections from '@pages/workspace/withPolicyConnections';
+import {clearQBDErrorField} from '@userActions/Policy/Policy';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+
+type CardListItem = ListItem & {
+ value: keyof typeof CONST.INTEGRATION_ENTITY_MAP_TYPES;
+};
+
+function QuickbooksDesktopClassesDisplayedAsPage({policy}: WithPolicyConnectionsProps) {
+ const {translate} = useLocalize();
+ const {canUseNewDotQBD} = usePermissions();
+ const styles = useThemeStyles();
+ const policyID = policy?.id ?? '-1';
+ const qbdConfig = policy?.connections?.quickbooksDesktop?.config;
+
+ const data: CardListItem[] = [
+ {
+ value: CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG,
+ text: translate('workspace.common.tags'),
+ alternateText: translate('workspace.qbd.tagsDisplayedAsDescription'),
+ keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG,
+ isSelected: qbdConfig?.mappings?.classes === CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG,
+ },
+ {
+ value: CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD,
+ text: translate('workspace.common.reportFields'),
+ alternateText: translate('workspace.qbd.reportFieldsDisplayedAsDescription'),
+ keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD,
+ isSelected: qbdConfig?.mappings?.classes === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD,
+ },
+ ];
+
+ const selectDisplayedAs = useCallback(
+ (row: CardListItem) => {
+ if (row.value !== qbdConfig?.mappings?.classes) {
+ QuickbooksDesktop.updateQuickbooksDesktopSyncClasses(policyID, row.value, qbdConfig?.mappings?.classes);
+ }
+ Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CLASSES.getRoute(policyID));
+ },
+ [qbdConfig?.mappings?.classes, policyID],
+ );
+
+ return (
+ Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CLASSES.getRoute(policyID))}
+ onSelectRow={selectDisplayedAs}
+ initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList}
+ title="workspace.common.displayedAs"
+ shouldBeBlocked={!canUseNewDotQBD} // TODO: [QBD] remove it once the QBD beta is done
+ connectionName={CONST.POLICY.CONNECTIONS.NAME.QBD}
+ pendingAction={PolicyUtils.settingsPendingAction([CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CLASSES], qbdConfig?.pendingFields)}
+ errors={ErrorUtils.getLatestErrorField(qbdConfig, CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CLASSES)}
+ errorRowStyles={[styles.ph5, styles.pv3]}
+ onClose={() => clearQBDErrorField(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CLASSES)}
+ shouldSingleExecuteRowSelect
+ />
+ );
+}
+
+QuickbooksDesktopClassesDisplayedAsPage.displayName = 'QuickbooksDesktopClassesDisplayedAsPage';
+
+export default withPolicyConnections(QuickbooksDesktopClassesDisplayedAsPage);
diff --git a/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopClassesPage.tsx b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopClassesPage.tsx
new file mode 100644
index 000000000000..b480b442bd88
--- /dev/null
+++ b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopClassesPage.tsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import ConnectionLayout from '@components/ConnectionLayout';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as QuickbooksDesktop from '@libs/actions/connections/QuickbooksDesktop';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import type {WithPolicyProps} from '@pages/workspace/withPolicy';
+import withPolicyConnections from '@pages/workspace/withPolicyConnections';
+import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow';
+import {clearQBDErrorField} from '@userActions/Policy/Policy';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+
+function QuickbooksDesktopClassesPage({policy}: WithPolicyProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const {canUseNewDotQBD} = usePermissions();
+ const policyID = policy?.id ?? '-1';
+ const qbdConfig = policy?.connections?.quickbooksDesktop?.config;
+ const isSwitchOn = !!(qbdConfig?.mappings?.classes && qbdConfig.mappings.classes !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE);
+ const isReportFieldsSelected = qbdConfig?.mappings?.classes === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD;
+
+ return (
+ Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_IMPORT.getRoute(policyID))}
+ >
+
+ QuickbooksDesktop.updateQuickbooksDesktopSyncClasses(
+ policyID,
+ isSwitchOn ? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE : CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG,
+ qbdConfig?.mappings?.classes,
+ )
+ }
+ pendingAction={PolicyUtils.settingsPendingAction([CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CLASSES], qbdConfig?.pendingFields)}
+ errors={ErrorUtils.getLatestErrorField(qbdConfig, CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CLASSES)}
+ onCloseError={() => clearQBDErrorField(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CLASSES)}
+ />
+ {isSwitchOn && (
+
+ Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CLASSES_DISPLAYED_AS.getRoute(policyID))}
+ brickRoadIndicator={
+ PolicyUtils.areSettingsInErrorFields([CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CLASSES], qbdConfig?.errorFields)
+ ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR
+ : undefined
+ }
+ />
+
+ )}
+
+ );
+}
+
+QuickbooksDesktopClassesPage.displayName = 'QuickbooksDesktopClassesPage';
+
+export default withPolicyConnections(QuickbooksDesktopClassesPage);
diff --git a/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopCustomersDisplayedAsPage.tsx b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopCustomersDisplayedAsPage.tsx
new file mode 100644
index 000000000000..a4b0a94bfd5e
--- /dev/null
+++ b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopCustomersDisplayedAsPage.tsx
@@ -0,0 +1,81 @@
+import React, {useCallback} from 'react';
+import RadioListItem from '@components/SelectionList/RadioListItem';
+import type {ListItem} from '@components/SelectionList/types';
+import SelectionScreen from '@components/SelectionScreen';
+import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as QuickbooksDesktop from '@libs/actions/connections/QuickbooksDesktop';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import Navigation from '@navigation/Navigation';
+import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections';
+import withPolicyConnections from '@pages/workspace/withPolicyConnections';
+import {clearQBDErrorField} from '@userActions/Policy/Policy';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+
+type CardListItem = ListItem & {
+ value: keyof typeof CONST.INTEGRATION_ENTITY_MAP_TYPES;
+};
+
+function QuickbooksDesktopCustomersDisplayedAsPage({policy}: WithPolicyConnectionsProps) {
+ const {translate} = useLocalize();
+ const {canUseNewDotQBD} = usePermissions();
+ const styles = useThemeStyles();
+ const policyID = policy?.id ?? '-1';
+ const qbdConfig = policy?.connections?.quickbooksDesktop?.config;
+
+ const data: CardListItem[] = [
+ {
+ value: CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG,
+ text: translate('workspace.common.tags'),
+ alternateText: translate('workspace.qbd.tagsDisplayedAsDescription'),
+ keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG,
+ isSelected: qbdConfig?.mappings?.customers === CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG,
+ },
+ {
+ value: CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD,
+ text: translate('workspace.common.reportFields'),
+ alternateText: translate('workspace.qbd.reportFieldsDisplayedAsDescription'),
+ keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD,
+ isSelected: qbdConfig?.mappings?.customers === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD,
+ },
+ ];
+
+ const selectDisplayedAs = useCallback(
+ (row: CardListItem) => {
+ if (row.value !== qbdConfig?.mappings?.customers) {
+ QuickbooksDesktop.updateQuickbooksDesktopSyncCustomers(policyID, row.value, qbdConfig?.mappings?.customers);
+ }
+ Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CUSTOMERS.getRoute(policyID));
+ },
+ [qbdConfig?.mappings?.customers, policyID],
+ );
+
+ return (
+ Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CUSTOMERS.getRoute(policyID))}
+ onSelectRow={selectDisplayedAs}
+ initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList}
+ title="workspace.common.displayedAs"
+ shouldBeBlocked={!canUseNewDotQBD} // TODO: [QBD] remove it once the QBD beta is done
+ connectionName={CONST.POLICY.CONNECTIONS.NAME.QBD}
+ pendingAction={PolicyUtils.settingsPendingAction([CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CUSTOMERS], qbdConfig?.pendingFields)}
+ errors={ErrorUtils.getLatestErrorField(qbdConfig, CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CUSTOMERS)}
+ errorRowStyles={[styles.ph5, styles.pv3]}
+ onClose={() => clearQBDErrorField(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CUSTOMERS)}
+ shouldSingleExecuteRowSelect
+ />
+ );
+}
+
+QuickbooksDesktopCustomersDisplayedAsPage.displayName = 'QuickbooksDesktopCustomersDisplayedAsPage';
+
+export default withPolicyConnections(QuickbooksDesktopCustomersDisplayedAsPage);
diff --git a/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopCustomersPage.tsx b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopCustomersPage.tsx
new file mode 100644
index 000000000000..e58826a12703
--- /dev/null
+++ b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopCustomersPage.tsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import ConnectionLayout from '@components/ConnectionLayout';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as QuickbooksDesktop from '@libs/actions/connections/QuickbooksDesktop';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import type {WithPolicyProps} from '@pages/workspace/withPolicy';
+import withPolicyConnections from '@pages/workspace/withPolicyConnections';
+import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow';
+import {clearQBDErrorField} from '@userActions/Policy/Policy';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+
+function QuickbooksDesktopCustomersPage({policy}: WithPolicyProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const {canUseNewDotQBD} = usePermissions();
+ const policyID = policy?.id ?? '-1';
+ const qbdConfig = policy?.connections?.quickbooksDesktop?.config;
+ const isSwitchOn = !!(qbdConfig?.mappings?.customers && qbdConfig.mappings.customers !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE);
+ const isReportFieldsSelected = qbdConfig?.mappings?.customers === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD;
+
+ return (
+ Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_IMPORT.getRoute(policyID))}
+ >
+
+ QuickbooksDesktop.updateQuickbooksDesktopSyncCustomers(
+ policyID,
+ isSwitchOn ? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE : CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG,
+ qbdConfig?.mappings?.classes,
+ )
+ }
+ pendingAction={PolicyUtils.settingsPendingAction([CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CUSTOMERS], qbdConfig?.pendingFields)}
+ errors={ErrorUtils.getLatestErrorField(qbdConfig, CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CUSTOMERS)}
+ onCloseError={() => clearQBDErrorField(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CUSTOMERS)}
+ />
+ {isSwitchOn && (
+
+ Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS.getRoute(policyID))}
+ brickRoadIndicator={
+ PolicyUtils.areSettingsInErrorFields([CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CUSTOMERS], qbdConfig?.errorFields)
+ ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR
+ : undefined
+ }
+ />
+
+ )}
+
+ );
+}
+
+QuickbooksDesktopCustomersPage.displayName = 'QuickbooksDesktopCustomersPage';
+
+export default withPolicyConnections(QuickbooksDesktopCustomersPage);
diff --git a/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopImportPage.tsx b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopImportPage.tsx
index f20975a25648..3697dfeb0b22 100644
--- a/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopImportPage.tsx
+++ b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopImportPage.tsx
@@ -29,21 +29,21 @@ function QuickbooksDesktopImportPage({policy}: WithPolicyProps) {
const sections: QBDSectionType[] = [
{
description: translate('workspace.accounting.accounts'),
- action: () => {}, // TODO: [QBD] will be implemented in https://github.com/Expensify/App/issues/49703
+ action: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CHART_OF_ACCOUNTS.getRoute(policyID)),
title: translate('workspace.accounting.importAsCategory'),
- subscribedSettings: [CONST.QUICKBOOKS_CONFIG.ENABLE_NEW_CATEGORIES],
+ subscribedSettings: [CONST.QUICKBOOKS_DESKTOP_CONFIG.ENABLE_NEW_CATEGORIES],
},
{
description: translate('workspace.qbd.classes'),
- action: () => {}, // TODO: [QBD] will be implemented in https://github.com/Expensify/App/issues/49704
+ action: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CLASSES.getRoute(policyID)),
title: translate(`workspace.accounting.importTypes.${mappings?.classes ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE}`),
- subscribedSettings: [CONST.QUICKBOOKS_CONFIG.SYNC_CLASSES],
+ subscribedSettings: [CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CLASSES],
},
{
description: translate('workspace.qbd.customers'),
- action: () => {}, // TODO: [QBD] will be implemented in https://github.com/Expensify/App/issues/49705
+ action: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CUSTOMERS.getRoute(policyID)),
title: translate(`workspace.accounting.importTypes.${mappings?.customers ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE}`),
- subscribedSettings: [CONST.QUICKBOOKS_CONFIG.SYNC_CUSTOMERS],
+ subscribedSettings: [CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CUSTOMERS],
},
{
description: translate('workspace.qbd.items'),
diff --git a/src/pages/workspace/accounting/utils.tsx b/src/pages/workspace/accounting/utils.tsx
index 2afa6ddd282d..153dc52b688a 100644
--- a/src/pages/workspace/accounting/utils.tsx
+++ b/src/pages/workspace/accounting/utils.tsx
@@ -259,14 +259,21 @@ function getAccountingIntegrationData(
onImportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_IMPORT.getRoute(policyID)),
onExportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.getRoute(policyID)),
onCardReconciliationPagePress: () => {},
- onAdvancedPagePress: () => {},
- subscribedImportSettings: [],
+ onAdvancedPagePress: () => Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_DESKTOP_ADVANCED.getRoute(policyID)),
+ // TODO: [QBD] Make sure all values are passed to subscribedSettings
+ subscribedImportSettings: [
+ CONST.QUICKBOOKS_DESKTOP_CONFIG.ENABLE_NEW_CATEGORIES,
+ CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CLASSES,
+ CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CUSTOMERS,
+ ],
subscribedExportSettings: [
+ CONST.QUICKBOOKS_DESKTOP_CONFIG.EXPORT_DATE,
+ CONST.QUICKBOOKS_DESKTOP_CONFIG.EXPORTER,
CONST.QUICKBOOKS_DESKTOP_CONFIG.REIMBURSABLE,
CONST.QUICKBOOKS_DESKTOP_CONFIG.REIMBURSABLE_ACCOUNT,
CONST.QUICKBOOKS_DESKTOP_CONFIG.MARK_CHECKS_TO_BE_PRINTED,
],
- subscribedAdvancedSettings: [],
+ subscribedAdvancedSettings: [CONST.QUICKBOOKS_DESKTOP_CONFIG.SHOULD_AUTO_CREATE_VENDOR, CONST.QUICKBOOKS_DESKTOP_CONFIG.AUTO_SYNC],
};
default:
return undefined;
diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx
index 7ad3d7fd47b1..25f1487381ab 100644
--- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx
+++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx
@@ -155,7 +155,7 @@ function WorkspaceCompanyCardPage({route}: WorkspaceCompanyCardPageProps) {
)}
{!isLoading && (
;
+ case CONST.COMPANY_CARDS.STEP.SELECT_FEED_TYPE:
+ return ;
case CONST.COMPANY_CARDS.STEP.CARD_TYPE:
return ;
case CONST.COMPANY_CARDS.STEP.CARD_INSTRUCTIONS:
@@ -28,6 +32,8 @@ function AddNewCardPage() {
return ;
case CONST.COMPANY_CARDS.STEP.CARD_DETAILS:
return ;
+ case CONST.COMPANY_CARDS.STEP.AMEX_CUSTOM_FEED:
+ return ;
default:
return ;
}
diff --git a/src/pages/workspace/companyCards/addNew/AmexCustomFeed.tsx b/src/pages/workspace/companyCards/addNew/AmexCustomFeed.tsx
new file mode 100644
index 000000000000..f168c72924ea
--- /dev/null
+++ b/src/pages/workspace/companyCards/addNew/AmexCustomFeed.tsx
@@ -0,0 +1,113 @@
+import React, {useEffect, useState} from 'react';
+import {View} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
+import FormHelpMessage from '@components/FormHelpMessage';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
+import Text from '@components/Text';
+import TextLink from '@components/TextLink';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as CompanyCards from '@userActions/CompanyCards';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+function AmexCustomFeed() {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD);
+ const [typeSelected, setTypeSelected] = useState>();
+ const [hasError, setHasError] = useState(false);
+
+ const submit = () => {
+ if (!typeSelected) {
+ setHasError(true);
+ return;
+ }
+ CompanyCards.setAddNewCompanyCardStepAndData({
+ step: typeSelected === CONST.COMPANY_CARDS.AMEX_CUSTOM_FEED.CORPORATE ? CONST.COMPANY_CARDS.STEP.CARD_INSTRUCTIONS : CONST.COMPANY_CARDS.STEP.BANK_CONNECTION,
+ data: {
+ cardType: CONST.COMPANY_CARDS.CARD_TYPE.AMEX,
+ selectedAmexCustomFeed: typeSelected,
+ },
+ });
+ };
+
+ useEffect(() => {
+ setTypeSelected(addNewCard?.data.selectedAmexCustomFeed);
+ }, [addNewCard?.data.selectedAmexCustomFeed]);
+
+ const handleBackButtonPress = () => {
+ CompanyCards.setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_BANK});
+ };
+
+ const data = [
+ {
+ value: CONST.COMPANY_CARDS.AMEX_CUSTOM_FEED.CORPORATE,
+ text: CONST.COMPANY_CARDS.AMEX_CUSTOM_FEED.CORPORATE,
+ alternateText: translate('workspace.companyCards.addNewCard.amexCorporate'),
+ keyForList: CONST.COMPANY_CARDS.AMEX_CUSTOM_FEED.CORPORATE,
+ isSelected: typeSelected === CONST.COMPANY_CARDS.AMEX_CUSTOM_FEED.CORPORATE,
+ },
+ {
+ value: CONST.COMPANY_CARDS.AMEX_CUSTOM_FEED.BUSINESS,
+ text: CONST.COMPANY_CARDS.AMEX_CUSTOM_FEED.BUSINESS,
+ alternateText: translate('workspace.companyCards.addNewCard.amexBusiness'),
+ keyForList: CONST.COMPANY_CARDS.AMEX_CUSTOM_FEED.BUSINESS,
+ isSelected: typeSelected === CONST.COMPANY_CARDS.AMEX_CUSTOM_FEED.BUSINESS,
+ },
+ ];
+
+ return (
+
+
+
+ {translate('workspace.companyCards.addNewCard.howDoYouWantToConnect')}
+
+ {`${translate('workspace.companyCards.addNewCard.learnMoreAboutConnections.text')}`}
+ {`${translate('workspace.companyCards.addNewCard.learnMoreAboutConnections.linkText')}`}
+
+
+ {
+ setTypeSelected(value);
+ setHasError(false);
+ }}
+ sections={[{data}]}
+ shouldSingleExecuteRowSelect
+ isAlternateTextMultilineSupported
+ alternateTextNumberOfLines={3}
+ initiallyFocusedOptionKey={addNewCard?.data.selectedAmexCustomFeed}
+ shouldUpdateFocusedIndex
+ showConfirmButton
+ confirmButtonText={translate('common.next')}
+ onConfirm={submit}
+ >
+ {hasError && (
+
+
+
+ )}
+
+
+ );
+}
+
+AmexCustomFeed.displayName = 'AmexCustomFeed';
+
+export default AmexCustomFeed;
diff --git a/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx b/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx
index 7338f0df5046..762e64020935 100644
--- a/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx
+++ b/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx
@@ -10,6 +10,7 @@ import ScrollView from '@components/ScrollView';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
+import usePermissions from '@hooks/usePermissions';
import useThemeStyles from '@hooks/useThemeStyles';
import Parser from '@libs/Parser';
import * as CompanyCards from '@userActions/CompanyCards';
@@ -20,19 +21,27 @@ function CardInstructionsStep() {
const {translate} = useLocalize();
const styles = useThemeStyles();
const {isOffline} = useNetwork();
+ const {canUseDirectFeeds} = usePermissions();
const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD);
const data = addNewCard?.data;
const feedProvider = data?.cardType;
+ const isAmexFeedProvider = feedProvider === CONST.COMPANY_CARDS.CARD_TYPE.AMEX;
const submit = () => {
CompanyCards.setAddNewCompanyCardStepAndData({
- step: feedProvider === CONST.COMPANY_CARDS.CARD_TYPE.AMEX ? CONST.COMPANY_CARDS.STEP.CARD_DETAILS : CONST.COMPANY_CARDS.STEP.CARD_NAME,
+ step: isAmexFeedProvider ? CONST.COMPANY_CARDS.STEP.CARD_DETAILS : CONST.COMPANY_CARDS.STEP.CARD_NAME,
});
};
const handleBackButtonPress = () => {
+ if (canUseDirectFeeds && isAmexFeedProvider) {
+ CompanyCards.setAddNewCompanyCardStepAndData({
+ step: CONST.COMPANY_CARDS.STEP.AMEX_CUSTOM_FEED,
+ });
+ return;
+ }
CompanyCards.setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.CARD_TYPE});
};
diff --git a/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx b/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx
index be0479327832..40c55e185cd8 100644
--- a/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx
+++ b/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx
@@ -29,9 +29,8 @@ function SelectBankStep() {
if (!bankSelected) {
setHasError(true);
} else {
- // TODO: https://github.com/Expensify/App/issues/50447 - update the navigation when new screen exists
CompanyCards.setAddNewCompanyCardStepAndData({
- step: bankSelected === CONST.COMPANY_CARDS.BANKS.OTHER ? CONST.COMPANY_CARDS.STEP.CARD_TYPE : CONST.COMPANY_CARDS.STEP.CARD_TYPE,
+ step: CardUtils.getCorrectStepForSelectedBank(bankSelected),
data: {
selectedBank: bankSelected,
},
diff --git a/src/pages/workspace/companyCards/addNew/SelectFeedType.tsx b/src/pages/workspace/companyCards/addNew/SelectFeedType.tsx
new file mode 100644
index 000000000000..113ca1b7e051
--- /dev/null
+++ b/src/pages/workspace/companyCards/addNew/SelectFeedType.tsx
@@ -0,0 +1,107 @@
+import React, {useEffect, useState} from 'react';
+import {View} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
+import FormHelpMessage from '@components/FormHelpMessage';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
+import Text from '@components/Text';
+import TextLink from '@components/TextLink';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as CompanyCards from '@userActions/CompanyCards';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+function SelectFeedType() {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD);
+ const [typeSelected, setTypeSelected] = useState>();
+ const [hasError, setHasError] = useState(false);
+
+ const submit = () => {
+ if (!typeSelected) {
+ setHasError(true);
+ } else {
+ // TODO: https://github.com/Expensify/App/issues/50448 - update the navigation when new screen exists
+ }
+ };
+
+ useEffect(() => {
+ setTypeSelected(addNewCard?.data.selectedFeedType);
+ }, [addNewCard?.data.selectedFeedType]);
+
+ const handleBackButtonPress = () => {
+ CompanyCards.setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_BANK});
+ };
+
+ const data = [
+ {
+ value: CONST.COMPANY_CARDS.FEED_TYPE.CUSTOM,
+ text: translate('workspace.companyCards.customFeed'),
+ alternateText: translate('workspace.companyCards.addNewCard.customFeedDetails'),
+ keyForList: CONST.COMPANY_CARDS.FEED_TYPE.CUSTOM,
+ isSelected: typeSelected === CONST.COMPANY_CARDS.FEED_TYPE.CUSTOM,
+ },
+ {
+ value: CONST.COMPANY_CARDS.FEED_TYPE.DIRECT,
+ text: translate('workspace.companyCards.directFeed'),
+ alternateText: translate('workspace.companyCards.addNewCard.directFeedDetails'),
+ keyForList: CONST.COMPANY_CARDS.FEED_TYPE.DIRECT,
+ isSelected: typeSelected === CONST.COMPANY_CARDS.FEED_TYPE.DIRECT,
+ },
+ ];
+
+ return (
+
+
+
+ {translate('workspace.companyCards.addNewCard.howDoYouWantToConnect')}
+
+ {`${translate('workspace.companyCards.addNewCard.learnMoreAboutConnections.text')}`}
+ {`${translate('workspace.companyCards.addNewCard.learnMoreAboutConnections.linkText')}.`}
+
+
+ {
+ setTypeSelected(value);
+ setHasError(false);
+ }}
+ sections={[{data}]}
+ shouldSingleExecuteRowSelect
+ isAlternateTextMultilineSupported
+ alternateTextNumberOfLines={3}
+ initiallyFocusedOptionKey={addNewCard?.data.selectedFeedType}
+ shouldUpdateFocusedIndex
+ showConfirmButton
+ confirmButtonText={translate('common.next')}
+ onConfirm={submit}
+ >
+ {hasError && (
+
+
+
+ )}
+
+
+ );
+}
+
+SelectFeedType.displayName = 'SelectFeedType';
+
+export default SelectFeedType;
diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx
index c4d033351b37..0b7d925f2ee2 100644
--- a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx
+++ b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx
@@ -1,8 +1,7 @@
import type {StackScreenProps} from '@react-navigation/stack';
import React, {useState} from 'react';
import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import ConfirmModal from '@components/ConfirmModal';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
@@ -26,22 +25,17 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
-import type * as OnyxTypes from '@src/types/onyx';
import type {Rate, TaxRateAttributes} from '@src/types/onyx/Policy';
-type PolicyDistanceRateDetailsPageOnyxProps = {
- /** Policy details */
- policy: OnyxEntry;
-};
+type PolicyDistanceRateDetailsPageProps = StackScreenProps;
-type PolicyDistanceRateDetailsPageProps = PolicyDistanceRateDetailsPageOnyxProps & StackScreenProps;
-
-function PolicyDistanceRateDetailsPage({policy, route}: PolicyDistanceRateDetailsPageProps) {
+function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [isWarningModalVisible, setIsWarningModalVisible] = useState(false);
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const policyID = route.params.policyID;
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`);
const rateID = route.params.rateID;
const customUnits = policy?.customUnits ?? {};
const customUnit = customUnits[Object.keys(customUnits)[0]];
@@ -155,7 +149,7 @@ function PolicyDistanceRateDetailsPage({policy, route}: PolicyDistanceRateDetail
)}
- {isDistanceTrackTaxEnabled && isPolicyTrackTaxEnabled && (
+ {isDistanceTrackTaxEnabled && !!taxRate && isPolicyTrackTaxEnabled && (
({
- policy: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`,
- },
-})(PolicyDistanceRateDetailsPage);
+export default PolicyDistanceRateDetailsPage;
diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx
index 53c1fe14237c..e7199e61c2dd 100644
--- a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx
+++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx
@@ -1,8 +1,7 @@
import type {StackScreenProps} from '@react-navigation/stack';
import React, {useState} from 'react';
import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
@@ -27,26 +26,20 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
-import type * as OnyxTypes from '@src/types/onyx';
import type {CustomUnit} from '@src/types/onyx/Policy';
import CategorySelector from './CategorySelector';
import UnitSelector from './UnitSelector';
-type PolicyDistanceRatesSettingsPageOnyxProps = {
- /** Policy details */
- policy: OnyxEntry;
+type PolicyDistanceRatesSettingsPageProps = StackScreenProps;
- /** Policy categories */
- policyCategories: OnyxEntry;
-};
-
-type PolicyDistanceRatesSettingsPageProps = PolicyDistanceRatesSettingsPageOnyxProps & StackScreenProps;
+function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPageProps) {
+ const policyID = route.params.policyID;
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
+ const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`);
-function PolicyDistanceRatesSettingsPage({policy, policyCategories, route}: PolicyDistanceRatesSettingsPageProps) {
const styles = useThemeStyles();
const [isCategoryPickerVisible, setIsCategoryPickerVisible] = useState(false);
const {translate} = useLocalize();
- const policyID = route.params.policyID;
const customUnits = policy?.customUnits ?? {};
const customUnit = customUnits[Object.keys(customUnits)[0]];
const customUnitID = customUnit?.customUnitID ?? '';
@@ -97,7 +90,10 @@ function PolicyDistanceRatesSettingsPage({policy, policyCategories, route}: Poli
>
-
+
{defaultUnit && (
({
- policy: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`,
- },
- policyCategories: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${route.params.policyID}`,
- },
-})(PolicyDistanceRatesSettingsPage);
+export default PolicyDistanceRatesSettingsPage;
diff --git a/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitTypePage.tsx b/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitTypePage.tsx
index e4f14f6c137a..94a1382b4454 100644
--- a/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitTypePage.tsx
+++ b/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitTypePage.tsx
@@ -23,7 +23,6 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
-import {isEmptyObject} from '@src/types/utils/EmptyObject';
type WorkspaceEditCardLimitTypePageProps = StackScreenProps;
@@ -38,7 +37,7 @@ function WorkspaceEditCardLimitTypePage({route}: WorkspaceEditCardLimitTypePageP
const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`);
const card = cardsList?.[cardID];
- const areApprovalsConfigured = !isEmptyObject(policy?.approver) && policy?.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL;
+ const areApprovalsConfigured = PolicyUtils.getApprovalWorkflow(policy) !== CONST.POLICY.APPROVAL_MODE.OPTIONAL;
const defaultLimitType = areApprovalsConfigured ? CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART : CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY;
const initialLimitType = card?.nameValuePairs?.limitType ?? defaultLimitType;
const promptTranslationKey =
diff --git a/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx
index de4bca070d51..e0ae8954720c 100644
--- a/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx
+++ b/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx
@@ -11,11 +11,11 @@ import RadioListItem from '@components/SelectionList/RadioListItem';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as PolicyUtils from '@libs/PolicyUtils';
import * as Card from '@userActions/Card';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type * as OnyxTypes from '@src/types/onyx';
-import {isEmptyObject} from '@src/types/utils/EmptyObject';
type LimitTypeStepProps = {
// The policy that the card will be issued under
@@ -27,7 +27,7 @@ function LimitTypeStep({policy}: LimitTypeStepProps) {
const styles = useThemeStyles();
const [issueNewCard] = useOnyx(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD);
- const areApprovalsConfigured = !isEmptyObject(policy?.approver) && policy?.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL;
+ const areApprovalsConfigured = PolicyUtils.getApprovalWorkflow(policy) !== CONST.POLICY.APPROVAL_MODE.OPTIONAL;
const defaultType = areApprovalsConfigured ? CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART : CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY;
const [typeSelected, setTypeSelected] = useState(issueNewCard?.data?.limitType ?? defaultType);
diff --git a/src/pages/workspace/rules/RulesAutoApproveReportsUnderPage.tsx b/src/pages/workspace/rules/RulesAutoApproveReportsUnderPage.tsx
index 490db9a80d42..679c8489cd29 100644
--- a/src/pages/workspace/rules/RulesAutoApproveReportsUnderPage.tsx
+++ b/src/pages/workspace/rules/RulesAutoApproveReportsUnderPage.tsx
@@ -52,7 +52,7 @@ function RulesAutoApproveReportsUnderPage({route}: RulesAutoApproveReportsUnderP
onBackButtonPress={() => Navigation.goBack()}
/>
{
PolicyActions.setPolicyAutomaticApprovalLimit(policyID, maxExpenseAutoApprovalAmount);
diff --git a/src/pages/workspace/rules/RulesAutoPayReportsUnderPage.tsx b/src/pages/workspace/rules/RulesAutoPayReportsUnderPage.tsx
index 5218ab1f988b..b21ad1f4a1f3 100644
--- a/src/pages/workspace/rules/RulesAutoPayReportsUnderPage.tsx
+++ b/src/pages/workspace/rules/RulesAutoPayReportsUnderPage.tsx
@@ -61,7 +61,7 @@ function RulesAutoPayReportsUnderPage({route}: RulesAutoPayReportsUnderPageProps
onBackButtonPress={() => Navigation.goBack()}
/>
{
diff --git a/src/pages/workspace/rules/RulesCustomNamePage.tsx b/src/pages/workspace/rules/RulesCustomNamePage.tsx
index 4a142c01e1da..4be433651a78 100644
--- a/src/pages/workspace/rules/RulesCustomNamePage.tsx
+++ b/src/pages/workspace/rules/RulesCustomNamePage.tsx
@@ -66,7 +66,7 @@ function RulesCustomNamePage({route}: RulesCustomNamePageProps) {
title={translate('workspace.rules.expenseReportRules.customNameTitle')}
onBackButtonPress={() => Navigation.goBack()}
/>
-
+
{translate('workspace.rules.expenseReportRules.customNameDescription')}
Navigation.goBack()}
/>
{
PolicyActions.setPolicyMaxExpenseAge(policyID, maxExpenseAge);
diff --git a/src/pages/workspace/rules/RulesMaxExpenseAmountPage.tsx b/src/pages/workspace/rules/RulesMaxExpenseAmountPage.tsx
index dba43789bd34..79ae647ae224 100644
--- a/src/pages/workspace/rules/RulesMaxExpenseAmountPage.tsx
+++ b/src/pages/workspace/rules/RulesMaxExpenseAmountPage.tsx
@@ -55,7 +55,7 @@ function RulesMaxExpenseAmountPage({
onBackButtonPress={() => Navigation.goBack()}
/>
{
PolicyActions.setPolicyMaxExpenseAmount(policyID, maxExpenseAmount);
diff --git a/src/pages/workspace/rules/RulesRandomReportAuditPage.tsx b/src/pages/workspace/rules/RulesRandomReportAuditPage.tsx
index ad5e24191ce9..dcc9a58e65e9 100644
--- a/src/pages/workspace/rules/RulesRandomReportAuditPage.tsx
+++ b/src/pages/workspace/rules/RulesRandomReportAuditPage.tsx
@@ -50,7 +50,7 @@ function RulesRandomReportAuditPage({route}: RulesRandomReportAuditPageProps) {
onBackButtonPress={() => Navigation.goBack()}
/>
{
PolicyActions.setPolicyAutomaticApprovalRate(policyID, auditRatePercentage);
diff --git a/src/pages/workspace/rules/RulesReceiptRequiredAmountPage.tsx b/src/pages/workspace/rules/RulesReceiptRequiredAmountPage.tsx
index 9e7098d45502..96e6be6eeb95 100644
--- a/src/pages/workspace/rules/RulesReceiptRequiredAmountPage.tsx
+++ b/src/pages/workspace/rules/RulesReceiptRequiredAmountPage.tsx
@@ -55,7 +55,7 @@ function RulesReceiptRequiredAmountPage({
onBackButtonPress={() => Navigation.goBack()}
/>
{
PolicyActions.setPolicyMaxExpenseAmountNoReceipt(policyID, maxExpenseAmountNoReceipt);
diff --git a/src/styles/index.ts b/src/styles/index.ts
index ef0d33a5d2a5..015dea9b7ecc 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -1181,7 +1181,6 @@ const styles = (theme: ThemeColors) =>
overflow: 'hidden',
borderBottomWidth: 2,
borderColor: theme.border,
- paddingBottom: 8,
},
optionRowAmountInput: {
@@ -3616,8 +3615,8 @@ const styles = (theme: ThemeColors) =>
searchInputStyle: {
color: theme.textSupporting,
- fontSize: 13,
- lineHeight: 16,
+ fontSize: variables.fontSizeNormal,
+ lineHeight: variables.fontSizeNormalHeight,
},
searchRouterTextInputContainer: {
@@ -3636,6 +3635,7 @@ const styles = (theme: ThemeColors) =>
searchRouterInputResultsFocused: {
borderWidth: 1,
borderColor: theme.success,
+ backgroundColor: theme.appBG,
},
searchTableHeaderActive: {
@@ -5270,6 +5270,7 @@ const styles = (theme: ThemeColors) =>
top: 80,
left: 12,
},
+
qbdSetupLinkBox: {
backgroundColor: theme.hoverComponentBG,
borderRadius: variables.componentBorderRadiusMedium,
diff --git a/src/styles/utils/sizing.ts b/src/styles/utils/sizing.ts
index ed4651bcf2e0..0cf3efd54d61 100644
--- a/src/styles/utils/sizing.ts
+++ b/src/styles/utils/sizing.ts
@@ -118,4 +118,7 @@ export default {
wAuto: {
width: 'auto',
},
+ wFitContent: {
+ width: 'fit-content',
+ },
} satisfies Record;
diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts
index 22bc7cb9bbcb..fd0a3a3cabc7 100644
--- a/src/styles/utils/spacing.ts
+++ b/src/styles/utils/spacing.ts
@@ -55,6 +55,10 @@ export default {
marginHorizontal: 32,
},
+ mhn2: {
+ marginHorizontal: -8,
+ },
+
mhn5: {
marginHorizontal: -20,
},
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index dccee6ed3e53..dc6655791489 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -167,6 +167,7 @@ export default {
modalContentMaxWidth: 360,
listItemHeightNormal: 64,
popoverWidth: 375,
+ searchRouterPopoverWidth: 512,
bankAccountActionPopoverRightSpacing: 32,
bankAccountActionPopoverTopSpacing: 14,
addPaymentPopoverRightSpacing: 23,
diff --git a/src/types/onyx/CardFeeds.ts b/src/types/onyx/CardFeeds.ts
index 32c54824b9ac..f3f916258b50 100644
--- a/src/types/onyx/CardFeeds.ts
+++ b/src/types/onyx/CardFeeds.ts
@@ -52,6 +52,12 @@ type AddNewCardFeedData = {
/** Selected bank */
selectedBank: ValueOf;
+ /** Selected feed type */
+ selectedFeedType: ValueOf;
+
+ /** Selected Amex bank custom feed */
+ selectedAmexCustomFeed: ValueOf;
+
/** Name of the card */
cardTitle: string;
};
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index f914c88a912b..2bd94bce6da4 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -1247,10 +1247,10 @@ type QBDConnectionConfig = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** Configuration of automatic synchronization from QuickBooks Desktop to the app */
autoSync: {
- /** TODO: Will be handled in another issue */
+ /** Job ID of the synchronization */
jobID: string;
- /** Whether changes made in QuickBooks Online should be reflected into the app automatically */
+ /** Whether changes made in QuickBooks Desktop should be reflected into the app automatically */
enabled: boolean;
};
@@ -1293,6 +1293,9 @@ type QBDConnectionConfig = OnyxCommon.OnyxValueWithOfflineFeedback<{
customers: IntegrationEntityMap;
};
+ /** Whether new categories are enabled in chart of accounts */
+ enableNewCategories: boolean;
+
/** Collections of form field errors */
errorFields?: OnyxCommon.ErrorFields;
}>;
diff --git a/tests/actions/EnforceActionExportRestrictions.ts b/tests/actions/EnforceActionExportRestrictions.ts
index 9590f878116e..76991ca24a98 100644
--- a/tests/actions/EnforceActionExportRestrictions.ts
+++ b/tests/actions/EnforceActionExportRestrictions.ts
@@ -54,6 +54,11 @@ describe('Task', () => {
// @ts-expect-error the test is asserting that it's undefined, so the TS error is normal
expect(Task.getParentReport).toBeUndefined();
});
+
+ it('does not export getParentReportAction', () => {
+ // @ts-expect-error the test is asserting that it's undefined, so the TS error is normal
+ expect(Task.getParentReportAction).toBeUndefined();
+ });
});
describe('OptionsListUtils', () => {
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
index a47d9d8e8631..46477c19d725 100644
--- a/tests/e2e/README.md
+++ b/tests/e2e/README.md
@@ -46,7 +46,7 @@ npm run android
```diff
{
"private": true,
-+ "main": "src/libs/E2E/reactNativeEntry.ts"
++ "main": "src/libs/E2E/reactNativeLaunchingTest.ts"
}
```
diff --git a/tests/e2e/config.ts b/tests/e2e/config.ts
index 4d4f1711a628..c8e89721c998 100644
--- a/tests/e2e/config.ts
+++ b/tests/e2e/config.ts
@@ -4,7 +4,7 @@ const OUTPUT_DIR = process.env.WORKING_DIRECTORY || './tests/e2e/results';
// add your test name here …
const TEST_NAMES = {
AppStartTime: 'App start time',
- OpenChatFinderPage: 'Open chat finder page TTI',
+ OpenSearchRouter: 'Open search router TTI',
ReportTyping: 'Report typing',
ChatOpening: 'Chat opening',
Linking: 'Linking',
@@ -73,8 +73,8 @@ export default {
name: TEST_NAMES.AppStartTime,
// ... any additional config you might need
},
- [TEST_NAMES.OpenChatFinderPage]: {
- name: TEST_NAMES.OpenChatFinderPage,
+ [TEST_NAMES.OpenSearchRouter]: {
+ name: TEST_NAMES.OpenSearchRouter,
},
[TEST_NAMES.ReportTyping]: {
name: TEST_NAMES.ReportTyping,
diff --git a/tests/perf-test/ChatFinderPage.perf-test.tsx b/tests/perf-test/SearchRouter.perf-test.tsx
similarity index 66%
rename from tests/perf-test/ChatFinderPage.perf-test.tsx
rename to tests/perf-test/SearchRouter.perf-test.tsx
index 4346977a1cd0..e9154a36a9a1 100644
--- a/tests/perf-test/ChatFinderPage.perf-test.tsx
+++ b/tests/perf-test/SearchRouter.perf-test.tsx
@@ -1,28 +1,23 @@
import type * as NativeNavigation from '@react-navigation/native';
-import type {StackNavigationProp, StackScreenProps} from '@react-navigation/stack';
import {fireEvent, screen} from '@testing-library/react-native';
import React, {useMemo} from 'react';
import type {ComponentType} from 'react';
import Onyx from 'react-native-onyx';
-import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
-import {measurePerformance} from 'reassure';
+import {measureRenders} from 'reassure';
import {LocaleContextProvider} from '@components/LocaleContextProvider';
import OptionListContextProvider, {OptionsListContext} from '@components/OptionListContextProvider';
+import SearchRouter from '@components/Search/SearchRouter/SearchRouter';
import {KeyboardStateProvider} from '@components/withKeyboardState';
import type {WithNavigationFocusProps} from '@components/withNavigationFocus';
-import type {RootStackParamList} from '@libs/Navigation/types';
import {createOptionList} from '@libs/OptionsListUtils';
-import ChatFinderPage from '@pages/ChatFinderPage';
import ComposeProviders from '@src/components/ComposeProviders';
import OnyxProvider from '@src/components/OnyxProvider';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type SCREENS from '@src/SCREENS';
-import type {Beta, PersonalDetails, Report} from '@src/types/onyx';
+import type {PersonalDetails, Report} from '@src/types/onyx';
import createCollection from '../utils/collections/createCollection';
import createPersonalDetails from '../utils/collections/personalDetails';
import createRandomReport from '../utils/collections/reports';
-import createAddListenerMock from '../utils/createAddListenerMock';
import * as TestHelper from '../utils/TestHelper';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates';
@@ -68,6 +63,9 @@ jest.mock('@react-navigation/native', () => {
getCurrentRoute: () => jest.fn(),
getState: () => jest.fn(),
}),
+ useNavigationState: () => ({
+ routes: [],
+ }),
};
});
@@ -86,15 +84,6 @@ jest.mock('@src/components/withNavigationFocus', () => (Component: ComponentType
return WithNavigationFocus;
});
-// mock of useDismissedReferralBanners
-jest.mock('../../src/hooks/useDismissedReferralBanners', () => ({
- // eslint-disable-next-line @typescript-eslint/naming-convention
- __esModule: true,
- default: jest.fn(() => ({
- isDismissed: false,
- setAsDismissed: () => {},
- })),
-}));
const getMockedReports = (length = 100) =>
createCollection(
@@ -134,49 +123,33 @@ afterEach(() => {
Onyx.clear();
});
-type ChatFinderPageProps = StackScreenProps & {
- betas?: OnyxEntry;
- reports?: OnyxCollection;
- isSearchingForReports?: OnyxEntry;
-};
+const mockOnClose = jest.fn();
-function ChatFinderPageWrapper(args: ChatFinderPageProps) {
+function SearchRouterWrapper() {
return (
-
+
);
}
-function ChatFinderPageWithCachedOptions(args: ChatFinderPageProps) {
+function SearchRouterWrapperWithCachedOptions() {
return (
({options: mockedOptions, initializeOptions: () => {}, areOptionsInitialized: true}), [])}>
-
+
);
}
-test('[ChatFinderPage] should render list with cached options', async () => {
- const {addListener} = createAddListenerMock();
-
+test('[SearchRouter] should render chat list with cached options', async () => {
const scenario = async () => {
- await screen.findByTestId('ChatFinderPage');
+ await screen.findByTestId('SearchRouter');
};
- const navigation = {addListener} as unknown as StackNavigationProp;
-
return waitForBatchedUpdates()
.then(() =>
Onyx.multiSet({
@@ -186,31 +159,19 @@ test('[ChatFinderPage] should render list with cached options', async () => {
[ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
}),
)
- .then(() =>
- measurePerformance(
- ,
- {scenario},
- ),
- );
+ .then(() => measureRenders(, {scenario}));
});
-test('[ChatFinderPage] should interact when text input changes', async () => {
- const {addListener} = createAddListenerMock();
-
+test('[SearchRouter] should react to text input changes', async () => {
const scenario = async () => {
- await screen.findByTestId('ChatFinderPage');
+ await screen.findByTestId('SearchRouter');
- const input = screen.getByTestId('selection-list-text-input');
+ const input = screen.getByTestId('search-router-text-input');
fireEvent.changeText(input, 'Email Four');
fireEvent.changeText(input, 'Report');
fireEvent.changeText(input, 'Email Five');
};
- const navigation = {addListener} as unknown as StackNavigationProp;
-
return waitForBatchedUpdates()
.then(() =>
Onyx.multiSet({
@@ -220,13 +181,5 @@ test('[ChatFinderPage] should interact when text input changes', async () => {
[ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
}),
)
- .then(() =>
- measurePerformance(
- ,
- {scenario},
- ),
- );
+ .then(() => measureRenders(, {scenario}));
});
diff --git a/tests/unit/DebugUtilsTest.ts b/tests/unit/DebugUtilsTest.ts
index 34c2ad2bde73..fa44b8972cf3 100644
--- a/tests/unit/DebugUtilsTest.ts
+++ b/tests/unit/DebugUtilsTest.ts
@@ -783,6 +783,134 @@ describe('DebugUtils', () => {
const reason = DebugUtils.getReasonForShowingRowInLHN(baseReport);
expect(reason).toBe('debug.reasonVisibleInLHN.isFocused');
});
+ it('returns correct reason when report has one transaction thread with violations', async () => {
+ const MOCK_TRANSACTION_REPORT: Report = {
+ reportID: '1',
+ ownerAccountID: 12345,
+ type: CONST.REPORT.TYPE.EXPENSE,
+ };
+ const MOCK_REPORTS: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}1` as const]: MOCK_TRANSACTION_REPORT,
+ [`${ONYXKEYS.COLLECTION.REPORT}2` as const]: {
+ reportID: '2',
+ type: CONST.REPORT.TYPE.CHAT,
+ parentReportID: '1',
+ parentReportActionID: '1',
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ },
+ };
+ const MOCK_REPORT_ACTIONS: ReportActionsCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1` as const]: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '1': {
+ reportActionID: '1',
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ actorAccountID: 12345,
+ created: '2024-08-08 18:20:44.171',
+ childReportID: '2',
+ message: {
+ type: CONST.IOU.REPORT_ACTION_TYPE.CREATE,
+ amount: 10,
+ currency: CONST.CURRENCY.USD,
+ IOUReportID: '1',
+ text: 'Vacation expense',
+ IOUTransactionID: '1',
+ },
+ },
+ },
+ };
+ await Onyx.multiSet({
+ ...MOCK_REPORTS,
+ ...MOCK_REPORT_ACTIONS,
+ [ONYXKEYS.SESSION]: {
+ accountID: 12345,
+ },
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}1` as const]: {
+ transactionID: '1',
+ amount: 10,
+ modifiedAmount: 10,
+ reportID: '1',
+ },
+ [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}1` as const]: [
+ {
+ type: CONST.VIOLATION_TYPES.VIOLATION,
+ name: CONST.VIOLATIONS.MISSING_CATEGORY,
+ },
+ ],
+ });
+ const reason = DebugUtils.getReasonForShowingRowInLHN(MOCK_TRANSACTION_REPORT, true);
+ expect(reason).toBe('debug.reasonVisibleInLHN.hasRBR');
+ });
+ it('returns correct reason when report has violations', async () => {
+ const MOCK_EXPENSE_REPORT: Report = {
+ reportID: '1',
+ chatReportID: '2',
+ parentReportID: '2',
+ parentReportActionID: '1',
+ ownerAccountID: 12345,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ type: CONST.REPORT.TYPE.EXPENSE,
+ };
+ const MOCK_REPORTS: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}1` as const]: MOCK_EXPENSE_REPORT,
+ [`${ONYXKEYS.COLLECTION.REPORT}2` as const]: {
+ reportID: '2',
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ },
+ };
+ const MOCK_REPORT_ACTIONS: ReportActionsCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2` as const]: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '1': {
+ reportActionID: '1',
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ actorAccountID: 12345,
+ created: '2024-08-08 18:20:44.171',
+ message: {
+ type: CONST.IOU.REPORT_ACTION_TYPE.CREATE,
+ amount: 10,
+ currency: CONST.CURRENCY.USD,
+ IOUReportID: '1',
+ text: 'Vacation expense',
+ IOUTransactionID: '1',
+ },
+ },
+ },
+ };
+ await Onyx.multiSet({
+ ...MOCK_REPORTS,
+ ...MOCK_REPORT_ACTIONS,
+ [ONYXKEYS.SESSION]: {
+ accountID: 12345,
+ },
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}1` as const]: {
+ transactionID: '1',
+ amount: 10,
+ modifiedAmount: 10,
+ reportID: '1',
+ },
+ [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}1` as const]: [
+ {
+ type: CONST.VIOLATION_TYPES.VIOLATION,
+ name: CONST.VIOLATIONS.MISSING_CATEGORY,
+ },
+ ],
+ });
+ const reason = DebugUtils.getReasonForShowingRowInLHN(MOCK_EXPENSE_REPORT, true);
+ expect(reason).toBe('debug.reasonVisibleInLHN.hasRBR');
+ });
+ it('returns correct reason when report has errors', () => {
+ const reason = DebugUtils.getReasonForShowingRowInLHN(
+ {
+ ...baseReport,
+ errors: {
+ error: 'Something went wrong',
+ },
+ },
+ true,
+ );
+ expect(reason).toBe('debug.reasonVisibleInLHN.hasRBR');
+ });
});
describe('getReasonAndReportActionForGBRInLHNRow', () => {
beforeAll(() => {
@@ -963,8 +1091,7 @@ describe('DebugUtils', () => {
);
expect(reportAction).toBeUndefined();
});
- // TODO: remove '.failing' once the implementation is fixed
- it.failing('returns parentReportAction if it is a transaction thread, the transaction is missing smart scan fields and the report is not settled', async () => {
+ it('returns undefined if it is a transaction thread, the transaction is missing smart scan fields and the report is not settled', async () => {
const MOCK_REPORTS: ReportCollectionDataSet = {
[`${ONYXKEYS.COLLECTION.REPORT}1` as const]: {
reportID: '1',
@@ -1011,7 +1138,7 @@ describe('DebugUtils', () => {
MOCK_REPORTS[`${ONYXKEYS.COLLECTION.REPORT}1`] as Report,
undefined,
);
- expect(reportAction).toBe(1);
+ expect(reportAction).toBe(undefined);
});
describe("Report has missing fields, isn't settled and it's owner is the current user", () => {
describe('Report is IOU', () => {