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..56b8a6ffdcb9 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 1009004901
+ versionName "9.0.49-1"
// 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 b5619323d38a..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.7",
+ "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.8",
- "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.8.tgz",
- "integrity": "sha512-zsrY5P0qFy51NoulD0PUpwL/XkO42ndI6QSgK0pMFRE6e/R8hsajGdybMGpdGW00PPCqv7QvbxMOvXr3qkV9ww==",
+ "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.7",
- "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.7.tgz",
- "integrity": "sha512-pCX7f1vAWsje5KVbMak4Xfy+rq/J4pFsfHhmwsPfdiX1cHsjCEJAwTxI6YyE6pb61VS/pWdOACc9ElD7aZDTUg==",
+ "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.8",
+ "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.8",
- "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.8.tgz",
- "integrity": "sha512-zsrY5P0qFy51NoulD0PUpwL/XkO42ndI6QSgK0pMFRE6e/R8hsajGdybMGpdGW00PPCqv7QvbxMOvXr3qkV9ww==",
+ "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.7",
- "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.7.tgz",
- "integrity": "sha512-pCX7f1vAWsje5KVbMak4Xfy+rq/J4pFsfHhmwsPfdiX1cHsjCEJAwTxI6YyE6pb61VS/pWdOACc9ElD7aZDTUg==",
+ "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.8",
+ "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 967fda939375..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.7",
+ "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/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..8dd29aa08d95 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.1FullStoryOrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index e6a14e30fd04..bbc9daf9ea45 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.1
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index d8f422542d0f..7528abbc3bbf 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.1NSExtensionNSExtensionPointIdentifier
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 68266651661a..fa7bb6467ec1 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
@@ -3173,7 +3186,7 @@ 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
diff --git a/package-lock.json b/package-lock.json
index e08766b5e490..286f04eaf893 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-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.48-0",
+ "version": "9.0.49-1",
"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",
@@ -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": "*",
diff --git a/package.json b/package.json
index b944cd941493..136131d411f6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.48-0",
+ "version": "9.0.49-1",
"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",
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 51da86fbcb7f..2d04b386ce1c 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',
@@ -1481,9 +1487,15 @@ const CONST = {
QUICKBOOKS_ONLINE: 'quickbooksOnline',
QUICKBOOKS_DESKTOP_CONFIG: {
+ EXPORT_DATE: 'exportDate',
+ EXPORTER: 'exporter',
MARK_CHECKS_TO_BE_PRINTED: 'markChecksToBePrinted',
REIMBURSABLE_ACCOUNT: 'reimbursableAccount',
REIMBURSABLE: 'reimbursable',
+ ENABLE_NEW_CATEGORIES: 'enableNewCategories',
+ MAPPINGS: {
+ CLASSES: 'classes',
+ },
},
QUICKBOOKS_CONFIG: {
@@ -2551,16 +2563,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 +2591,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',
@@ -5655,6 +5678,7 @@ const CONST = {
KEYWORD: 'keyword',
IN: 'in',
},
+ EMPTY_VALUE: 'none',
},
REFERRER: {
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 5f7f6b1b7766..49572c14bdea 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -669,6 +669,14 @@ 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,
},
+ 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,
@@ -701,6 +709,18 @@ 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,
+ },
WORKSPACE_PROFILE_NAME: {
route: 'settings/workspaces/:policyID/profile/name',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/profile/name` as const,
@@ -1219,8 +1239,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 e199a69d92ee..d246eec31f66 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -316,6 +316,8 @@ 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_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',
@@ -324,6 +326,9 @@ 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',
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}
+ />
)}
= [];
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/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/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..5dc56caa0ff6 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();
@@ -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/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx
index 84c300bfc895..9830ea4e9506 100644
--- a/src/components/Search/SearchRouter/SearchRouterList.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterList.tsx
@@ -98,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) {
@@ -115,7 +114,7 @@ function SearchRouterList(
});
}
- if (reportForContextualSearch && !currentQuery?.inputQuery?.includes(contextualQuery)) {
+ if (reportForContextualSearch && !currentQuery?.inputQuery) {
sections.push({
data: [
{
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..c2ff45408d3a 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -2452,6 +2452,12 @@ 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',
},
qbo: {
importDescription: 'Choose which coding configurations to import from QuickBooks Online to Expensify.',
@@ -3039,6 +3045,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 +3077,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 +4284,8 @@ const translations = {
current: 'Current',
past: 'Past',
},
+ noCategory: 'No category',
+ noTag: 'No tag',
expenseType: 'Expense type',
recentSearches: 'Recent searches',
recentChats: 'Recent chats',
@@ -4402,7 +4421,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 +5027,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',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index d4615b26a255..400e5e8880f3 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -2476,6 +2476,12 @@ 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',
},
qbo: {
importDescription: 'Elige que configuraciónes de codificación son importadas desde QuickBooks Online a Expensify.',
@@ -3077,6 +3083,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 +3116,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 +4331,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 +4350,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 +4469,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 +5190,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 +5542,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',
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index ee5b06aba6ab..2463fc1cdc0c 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -257,10 +257,14 @@ 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_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',
REMOVE_POLICY_CONNECTION: 'RemovePolicyConnection',
SET_POLICY_TAXES_ENABLED: 'SetPolicyTaxesEnabled',
DELETE_POLICY_TAXES: 'DeletePolicyTaxes',
@@ -692,9 +696,13 @@ 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_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_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/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index fcd9d8e8d129..7b8589c81e7f 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -22,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';
@@ -90,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.
@@ -156,7 +152,7 @@ Onyx.connect({
Onyx.connect({
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (value) => {
- if (!value || timezone) {
+ if (!value || !isEmptyObject(timezone)) {
return;
}
@@ -591,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..c32f63906443 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]: () =>
@@ -325,6 +329,11 @@ const SettingsModalStackNavigator = createModalStackNavigator
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.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/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..552310dceae4 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,8 @@ 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_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 +55,9 @@ 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.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 ac3a6cb0fc21..83b843df62aa 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -392,6 +392,8 @@ 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_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,
},
@@ -412,6 +414,9 @@ 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.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/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 2501ca2e144b..6bd862c82c6c 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -436,6 +436,12 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_EXPORT_PREFERRED_EXPORTER]: {
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 +463,15 @@ 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.XERO_IMPORT]: {
policyID: string;
};
@@ -1504,6 +1519,7 @@ type AuthScreensParamList = CentralPaneScreensParamList &
reportID: string;
transactionID: string;
readonly?: boolean;
+ isFromReviewDuplicates?: boolean;
};
[SCREENS.CONNECTION_COMPLETE]: undefined;
};
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..d7ff4538748c 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -65,7 +65,6 @@ import * as CurrencyUtils from './CurrencyUtils';
import DateUtils from './DateUtils';
import {hasValidDraftComment} from './DraftCommentUtils';
import getAttachmentDetails from './fileDownload/getAttachmentDetails';
-import getIsSmallScreenWidth from './getIsSmallScreenWidth';
import isReportMessageAttachment from './isReportMessageAttachment';
import localeCompare from './LocaleCompare';
import * as LocalePhoneNumber from './LocalePhoneNumber';
@@ -1252,13 +1251,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 +1368,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) {
@@ -8470,6 +8472,7 @@ export {
hasMissingInvoiceBankAccount,
reasonForReportToBeInOptionList,
getReasonAndReportActionThatRequiresAttention,
+ isPolicyRelatedReport,
};
export type {
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 13b14d380758..87bec3bc30ea 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -492,7 +492,7 @@ function addActions(reportID: string, text = '', file?: FileObject) {
if (shouldUpdateNotificationPrefernece) {
optimisticReport.participants = {
- [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS},
+ [currentUserAccountID]: {notificationPreference: ReportUtils.getDefaultNotificationPreferenceForReport(report)},
};
}
@@ -548,19 +548,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: '',
@@ -837,8 +824,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(',');
@@ -870,6 +857,7 @@ function openReport(
...newReportObject,
pendingFields: {
createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ ...(isGroupChat && {reportName: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}),
},
isOptimisticReport: true,
};
@@ -923,6 +911,7 @@ function openReport(
participants: redundantParticipants,
pendingFields: {
createChat: null,
+ reportName: null,
},
errorFields: {
createChat: null,
@@ -2757,7 +2746,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/connections/QuickbooksDesktop.ts b/src/libs/actions/connections/QuickbooksDesktop.ts
index 78f99b5c6106..80f50dce7b8a 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,
@@ -289,9 +370,68 @@ 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 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);
+}
+
export {
+ updateQuickbooksDesktopPreferredExporter,
updateQuickbooksDesktopMarkChecksToBePrinted,
updateQuickbooksDesktopExpensesExportDestination,
updateQuickbooksDesktopReimbursableExpensesAccount,
getQuickbooksDesktopCodatSetupLink,
+ updateQuickbooksDesktopEnableNewCategories,
+ updateQuickbooksDesktopExportDate,
+ updateQuickbooksDesktopSyncClasses,
};
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/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/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx
index f212eb5fd75a..b5d424b7fc57 100644
--- a/src/pages/RoomMembersPage.tsx
+++ b/src/pages/RoomMembersPage.tsx
@@ -161,7 +161,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
/** Add or remove all users passed from the selectedMembers list */
const toggleAllUsers = (memberList: ListItem[]) => {
- const enabledAccounts = memberList.filter((member) => !member.isDisabled);
+ const enabledAccounts = memberList.filter((member) => !member.isDisabled && !member.isDisabledCheckbox);
const everyoneSelected = enabledAccounts.every((member) => {
if (!member.accountID) {
return false;
@@ -217,18 +217,19 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
}
const pendingChatMember = report?.pendingChatMembers?.findLast((member) => member.accountID === accountID.toString());
const isAdmin = !!(policy && policy.employeeList && details.login && policy.employeeList[details.login]?.role === CONST.POLICY.ROLE.ADMIN);
- const isDisabled =
+ const isDisabled = pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || details.isOptimisticPersonalDetail;
+ const isDisabledCheckbox =
(isPolicyExpenseChat && isAdmin) ||
accountID === session?.accountID ||
pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ||
- details.accountID === report.ownerAccountID ||
- details.isOptimisticPersonalDetail;
+ details.accountID === report.ownerAccountID;
result.push({
keyForList: String(accountID),
accountID,
isSelected: selectedMembers.includes(accountID),
isDisabled,
+ isDisabledCheckbox,
text: formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(details)),
alternateText: details?.login ? formatPhoneNumber(details.login) : '',
icons: [
diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx
index 54b62a61724d..6e117a8baaab 100644
--- a/src/pages/Search/AdvancedSearchFilters.tsx
+++ b/src/pages/Search/AdvancedSearchFilters.tsx
@@ -139,6 +139,17 @@ function getFilterParticipantDisplayTitle(accountIDs: string[], personalDetails:
.join(', ');
}
+const sortOptionsWithEmptyValue = (a: string, b: string) => {
+ // Always show `No category` and `No tag` as the first option
+ if (a === CONST.SEARCH.EMPTY_VALUE) {
+ return -1;
+ }
+ if (b === CONST.SEARCH.EMPTY_VALUE) {
+ return 1;
+ }
+ return localeCompare(a, b);
+};
+
function getFilterDisplayTitle(filters: Partial, fieldName: AdvancedFiltersKeys, translate: LocaleContextProps['translate']) {
if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE) {
// the value of date filter is a combination of dateBefore + dateAfter values
@@ -175,14 +186,27 @@ function getFilterDisplayTitle(filters: Partial, fiel
return;
}
- if (
- (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY || fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY || fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG) &&
- filters[fieldName]
- ) {
+ if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY && filters[fieldName]) {
const filterArray = filters[fieldName] ?? [];
return filterArray.sort(localeCompare).join(', ');
}
+ if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY && filters[fieldName]) {
+ const filterArray = filters[fieldName] ?? [];
+ return filterArray
+ .sort(sortOptionsWithEmptyValue)
+ .map((value) => (value === CONST.SEARCH.EMPTY_VALUE ? translate('search.noCategory') : value))
+ .join(', ');
+ }
+
+ if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG && filters[fieldName]) {
+ const filterArray = filters[fieldName] ?? [];
+ return filterArray
+ .sort(sortOptionsWithEmptyValue)
+ .map((value) => (value === CONST.SEARCH.EMPTY_VALUE ? translate('search.noTag') : value))
+ .join(', ');
+ }
+
if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION) {
return filters[fieldName];
}
diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx
index b759e5eaa517..67c8a6c9e09e 100644
--- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx
+++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx
@@ -35,6 +35,7 @@ function SearchFiltersCardPage() {
return {
lastFourPAN: card.lastFourPAN,
+ isVirtual: card?.nameValuePairs?.isVirtual,
text: card.bank,
keyForList: card.cardID.toString(),
isSelected: newCards.includes(card.cardID.toString()),
diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCategoryPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCategoryPage.tsx
index 1e37cdc60296..d92554d42453 100644
--- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCategoryPage.tsx
+++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCategoryPage.tsx
@@ -9,6 +9,7 @@ import useSafePaddingBottomStyle from '@hooks/useSafePaddingBottomStyle';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import * as SearchActions from '@userActions/Search';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
@@ -17,19 +18,27 @@ function SearchFiltersCategoryPage() {
const {translate} = useLocalize();
const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM);
- const selectedCategoriesItems = searchAdvancedFiltersForm?.category?.map((category) => ({name: category, value: category}));
+ const selectedCategoriesItems = searchAdvancedFiltersForm?.category?.map((category) => {
+ if (category === CONST.SEARCH.EMPTY_VALUE) {
+ return {name: translate('search.noCategory'), value: category};
+ }
+ return {name: category, value: category};
+ });
const policyID = searchAdvancedFiltersForm?.policyID ?? '-1';
const [allPolicyIDCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
const singlePolicyCategories = allPolicyIDCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`];
const categoryItems = useMemo(() => {
+ const items = [{name: translate('search.noCategory'), value: CONST.SEARCH.EMPTY_VALUE as string}];
if (!singlePolicyCategories) {
const uniqueCategoryNames = new Set();
Object.values(allPolicyIDCategories ?? {}).map((policyCategories) => Object.values(policyCategories ?? {}).forEach((category) => uniqueCategoryNames.add(category.name)));
- return Array.from(uniqueCategoryNames).map((categoryName) => ({name: categoryName, value: categoryName}));
+ items.push(...Array.from(uniqueCategoryNames).map((categoryName) => ({name: categoryName, value: categoryName})));
+ } else {
+ items.push(...Object.values(singlePolicyCategories ?? {}).map((category) => ({name: category.name, value: category.name})));
}
- return Object.values(singlePolicyCategories ?? {}).map((category) => ({name: category.name, value: category.name}));
- }, [allPolicyIDCategories, singlePolicyCategories]);
+ return items;
+ }, [allPolicyIDCategories, singlePolicyCategories, translate]);
const onSaveSelection = useCallback((values: string[]) => SearchActions.updateAdvancedFilters({category: values}), []);
const safePaddingBottomStyle = useSafePaddingBottomStyle();
@@ -50,7 +59,6 @@ function SearchFiltersCategoryPage() {
/>
({name: tag, value: tag}));
+ const selectedTagsItems = searchAdvancedFiltersForm?.tag?.map((tag) => {
+ if (tag === CONST.SEARCH.EMPTY_VALUE) {
+ return {name: translate('search.noTag'), value: tag};
+ }
+ return {name: tag, value: tag};
+ });
const policyID = searchAdvancedFiltersForm?.policyID ?? '-1';
const [allPoliciesTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
const singlePolicyTagsList: PolicyTagLists | undefined = allPoliciesTagsLists?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`];
const tagItems = useMemo(() => {
+ const items = [{name: translate('search.noTag'), value: CONST.SEARCH.EMPTY_VALUE as string}];
if (!singlePolicyTagsList) {
const uniqueTagNames = new Set();
const tagListsUnpacked = Object.values(allPoliciesTagsLists ?? {}).filter((item) => !!item) as PolicyTagLists[];
@@ -33,10 +40,12 @@ function SearchFiltersTagPage() {
})
.flat()
.forEach((tag) => uniqueTagNames.add(tag));
- return Array.from(uniqueTagNames).map((tagName) => ({name: tagName, value: tagName}));
+ items.push(...Array.from(uniqueTagNames).map((tagName) => ({name: tagName, value: tagName})));
+ } else {
+ items.push(...getTagNamesFromTagsLists(singlePolicyTagsList).map((name) => ({name, value: name})));
}
- return getTagNamesFromTagsLists(singlePolicyTagsList).map((name) => ({name, value: name}));
- }, [allPoliciesTagsLists, singlePolicyTagsList]);
+ return items;
+ }, [allPoliciesTagsLists, singlePolicyTagsList, translate]);
const updateTagFilter = useCallback((values: string[]) => SearchActions.updateAdvancedFilters({tag: values}), []);
@@ -56,7 +65,6 @@ function SearchFiltersTagPage() {
/>
diff --git a/src/pages/TransactionDuplicate/Confirmation.tsx b/src/pages/TransactionDuplicate/Confirmation.tsx
index b26ae615b465..87748a9697a7 100644
--- a/src/pages/TransactionDuplicate/Confirmation.tsx
+++ b/src/pages/TransactionDuplicate/Confirmation.tsx
@@ -118,6 +118,7 @@ function Confirmation() {
report={report}
shouldShowAnimatedBackground={false}
readonly
+ isFromReviewDuplicates
updatedTransaction={transaction as OnyxEntry}
/>
diff --git a/src/pages/TransactionReceiptPage.tsx b/src/pages/TransactionReceiptPage.tsx
index 194759f78ccb..c05d90a180e1 100644
--- a/src/pages/TransactionReceiptPage.tsx
+++ b/src/pages/TransactionReceiptPage.tsx
@@ -28,6 +28,7 @@ function TransactionReceipt({route}: TransactionReceiptProps) {
const isLocalFile = receiptURIs.isLocalFile;
const readonly = route.params.readonly ?? false;
+ const isFromReviewDuplicates = route.params.isFromReviewDuplicates ?? false;
const parentReportAction = ReportActionUtils.getReportAction(report?.parentReportID ?? '-1', report?.parentReportActionID ?? '-1');
const canEditReceipt = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.RECEIPT);
@@ -61,7 +62,8 @@ function TransactionReceipt({route}: TransactionReceiptProps) {
const isTrackExpenseReport = ReportUtils.isTrackExpenseReport(report);
// eslint-disable-next-line rulesdir/no-negated-variables
- const shouldShowNotFoundPage = isTrackExpenseReport || transaction?.reportID === CONST.REPORT.SPLIT_REPORTID ? !transaction : (moneyRequestReportID ?? '-1') !== transaction?.reportID;
+ const shouldShowNotFoundPage =
+ isTrackExpenseReport || transaction?.reportID === CONST.REPORT.SPLIT_REPORTID || isFromReviewDuplicates ? !transaction : (moneyRequestReportID ?? '-1') !== transaction?.reportID;
return (
ReportUtils.isEmptyReport(report), [report]);
const isOptimisticDelete = report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED;
const indexOfLinkedMessage = useMemo(
(): number => reportActions.findIndex((obj) => String(obj.reportActionID) === String(reportActionIDFromRoute)),
@@ -358,9 +359,11 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
!isCurrentReportLoadedFromOnyx ||
isLoading;
+ const isLinkedActionBecomesDeleted = prevIsLinkedActionDeleted !== undefined && !prevIsLinkedActionDeleted && isLinkedActionDeleted;
+
// eslint-disable-next-line rulesdir/no-negated-variables
const shouldShowNotFoundLinkedAction =
- (!isLinkedActionInaccessibleWhisper && isLinkedActionDeleted && !!prevIsLinkedActionDeleted) ||
+ (!isLinkedActionInaccessibleWhisper && isLinkedActionDeleted && !isLinkedActionBecomesDeleted) ||
(shouldShowSkeleton &&
!reportMetadata.isLoadingInitialReportActions &&
!!reportActionIDFromRoute &&
@@ -660,12 +663,11 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
useEffect(() => {
// If the linked action is previously available but now deleted,
// remove the reportActionID from the params to not link to the deleted action.
- const isLinkedActionBecomesDeleted = prevIsLinkedActionDeleted !== undefined && !prevIsLinkedActionDeleted && isLinkedActionDeleted;
if (!isLinkedActionBecomesDeleted) {
return;
}
Navigation.setParams({reportActionID: ''});
- }, [prevIsLinkedActionDeleted, isLinkedActionDeleted]);
+ }, [isLinkedActionBecomesDeleted]);
// If user redirects to an inaccessible whisper via a deeplink, on a report they have access to,
// then we set reportActionID as empty string, so we display them the report and not the "Not found page".
@@ -809,6 +811,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
policy={policy}
pendingAction={reportPendingAction}
isComposerFullSize={!!isComposerFullSize}
+ isEmptyChat={isEmptyChat}
lastReportAction={lastReportAction}
workspaceTooltip={workspaceTooltip}
/>
diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx
index c1702381b668..966437a91d31 100644
--- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx
+++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx
@@ -24,6 +24,7 @@ import Navigation from '@libs/Navigation/Navigation';
import * as ReportUtils from '@libs/ReportUtils';
import * as SubscriptionUtils from '@libs/SubscriptionUtils';
import * as IOU from '@userActions/IOU';
+import * as Modal from '@userActions/Modal';
import * as Report from '@userActions/Report';
import * as Task from '@userActions/Task';
import DelegateNoAccessModal from '@src/components/DelegateNoAccessModal';
@@ -236,13 +237,6 @@ function AttachmentPickerWithMenuItems({
{
icon: Expensicons.Paperclip,
text: translate('reportActionCompose.addAttachment'),
- onSelected: () => {
- if (Browser.isSafari()) {
- return;
- }
- triggerAttachmentPicker();
- },
- shouldCallAfterModalHide: true,
},
];
return (
@@ -329,8 +323,14 @@ function AttachmentPickerWithMenuItems({
// In order for the file picker to open dynamically, the click
// function must be called from within a event handler that was initiated
// by the user on Safari.
- if (index === menuItems.length - 1 && Browser.isSafari()) {
- triggerAttachmentPicker();
+ if (index === menuItems.length - 1) {
+ if (Browser.isSafari()) {
+ triggerAttachmentPicker();
+ return;
+ }
+ Modal.close(() => {
+ triggerAttachmentPicker();
+ });
}
}}
anchorPosition={styles.createMenuPositionReportActionCompose(shouldUseNarrowLayout, windowHeight, windowWidth)}
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
index 12b145a78e87..e63bd952b4ab 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
@@ -1,6 +1,6 @@
import {useIsFocused, useNavigation} from '@react-navigation/native';
import lodashDebounce from 'lodash/debounce';
-import type {ForwardedRef, MutableRefObject, RefObject} from 'react';
+import type {ForwardedRef, MutableRefObject, RefAttributes, RefObject} from 'react';
import React, {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import type {
LayoutChangeEvent,
@@ -14,7 +14,7 @@ import type {
import {DeviceEventEmitter, findNodeHandle, InteractionManager, NativeModules, View} from 'react-native';
import {useFocusedInputHandler} from 'react-native-keyboard-controller';
import type {OnyxEntry} from 'react-native-onyx';
-import {useOnyx} from 'react-native-onyx';
+import {withOnyx} from 'react-native-onyx';
import {useAnimatedRef, useSharedValue} from 'react-native-reanimated';
import type {Emoji} from '@assets/emojis/types';
import type {FileObject} from '@components/AttachmentModal';
@@ -29,6 +29,7 @@ import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as Browser from '@libs/Browser';
+import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus';
import {forceClearInput} from '@libs/ComponentUtils';
import * as ComposerUtils from '@libs/ComposerUtils';
import convertToLTRForComposer from '@libs/convertToLTRForComposer';
@@ -39,6 +40,7 @@ import getPlatform from '@libs/getPlatform';
import * as KeyDownListener from '@libs/KeyboardShortcut/KeyDownPressListener';
import Parser from '@libs/Parser';
import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager';
+import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
import updateMultilineInputRange from '@libs/updateMultilineInputRange';
import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside';
@@ -63,85 +65,113 @@ type SyncSelection = {
type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string};
-type ComposerWithSuggestionsProps = Partial & {
- /** Report ID */
- reportID: string;
+type ComposerWithSuggestionsOnyxProps = {
+ /** The parent report actions for the report */
+ parentReportActions: OnyxEntry;
- /** Callback to focus composer */
- onFocus: () => void;
+ /** The modal state */
+ modal: OnyxEntry;
- /** Callback to blur composer */
- onBlur: (event: NativeSyntheticEvent) => void;
+ /** The preferred skin tone of the user */
+ preferredSkinTone: number;
- /** Callback when layout of composer changes */
- onLayout?: (event: LayoutChangeEvent) => void;
+ /** Whether the input is focused */
+ editFocused: OnyxEntry;
+};
- /** Callback to update the value of the composer */
- onValueChange: (value: string) => void;
+type ComposerWithSuggestionsProps = ComposerWithSuggestionsOnyxProps &
+ Partial & {
+ /** Report ID */
+ reportID: string;
- /** Callback when the composer got cleared on the UI thread */
- onCleared?: (text: string) => void;
+ /** Callback to focus composer */
+ onFocus: () => void;
- /** Whether the composer is full size */
- isComposerFullSize: boolean;
+ /** Callback to blur composer */
+ onBlur: (event: NativeSyntheticEvent) => void;
- /** Whether the menu is visible */
- isMenuVisible: boolean;
+ /** Callback when layout of composer changes */
+ onLayout?: (event: LayoutChangeEvent) => void;
- /** The placeholder for the input */
- inputPlaceholder: string;
+ /** Callback to update the value of the composer */
+ onValueChange: (value: string) => void;
- /** Function to display a file in a modal */
- displayFileInModal: (file: FileObject) => void;
+ /** Callback when the composer got cleared on the UI thread */
+ onCleared?: (text: string) => void;
- /** Whether the user is blocked from concierge */
- isBlockedFromConcierge: boolean;
+ /** Whether the composer is full size */
+ isComposerFullSize: boolean;
- /** Whether the input is disabled */
- disabled: boolean;
+ /** Whether the menu is visible */
+ isMenuVisible: boolean;
- /** Whether the full composer is available */
- isFullComposerAvailable: boolean;
+ /** The placeholder for the input */
+ inputPlaceholder: string;
- /** Function to set whether the full composer is available */
- setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void;
+ /** Function to display a file in a modal */
+ displayFileInModal: (file: FileObject) => void;
- /** Function to set whether the comment is empty */
- setIsCommentEmpty: (isCommentEmpty: boolean) => void;
+ /** Whether the user is blocked from concierge */
+ isBlockedFromConcierge: boolean;
- /** Function to handle sending a message */
- handleSendMessage: () => void;
+ /** Whether the input is disabled */
+ disabled: boolean;
- /** Whether the compose input should show */
- shouldShowComposeInput: OnyxEntry;
+ /** Whether the full composer is available */
+ isFullComposerAvailable: boolean;
- /** Function to measure the parent container */
- measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void;
+ /** Function to set whether the full composer is available */
+ setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void;
- /** Whether the scroll is likely to trigger a layout */
- isScrollLikelyLayoutTriggered: RefObject;
+ /** Function to set whether the comment is empty */
+ setIsCommentEmpty: (isCommentEmpty: boolean) => void;
- /** Function to raise the scroll is likely layout triggered */
- raiseIsScrollLikelyLayoutTriggered: () => void;
+ /** Function to handle sending a message */
+ handleSendMessage: () => void;
- /** The ref to the suggestions */
- suggestionsRef: React.RefObject;
+ /** Whether the compose input should show */
+ shouldShowComposeInput: OnyxEntry;
- /** The ref to the next modal will open */
- isNextModalWillOpenRef: MutableRefObject;
+ /** Function to measure the parent container */
+ measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void;
- /** The last report action */
- lastReportAction?: OnyxEntry;
+ /** Whether the scroll is likely to trigger a layout */
+ isScrollLikelyLayoutTriggered: RefObject;
- /** Whether to include chronos */
- includeChronos?: boolean;
+ /** Function to raise the scroll is likely layout triggered */
+ raiseIsScrollLikelyLayoutTriggered: () => void;
- /** Whether report is from group policy */
- isGroupPolicyReport: boolean;
+ /** The ref to the suggestions */
+ suggestionsRef: React.RefObject;
- /** policy ID of the report */
- policyID: string;
-};
+ /** The ref to the next modal will open */
+ isNextModalWillOpenRef: MutableRefObject;
+
+ /** Whether the edit is focused */
+ editFocused: boolean;
+
+ /** Wheater chat is empty */
+ isEmptyChat?: boolean;
+
+ /** The last report action */
+ lastReportAction?: OnyxEntry;
+
+ /** Whether to include chronos */
+ includeChronos?: boolean;
+
+ /** The parent report action ID */
+ parentReportActionID?: string;
+
+ /** The parent report ID */
+ // eslint-disable-next-line react/no-unused-prop-types -- its used in the withOnyx HOC
+ parentReportID: string | undefined;
+
+ /** Whether report is from group policy */
+ isGroupPolicyReport: boolean;
+
+ /** policy ID of the report */
+ policyID: string;
+ };
type SwitchToCurrentReportProps = {
preexistingReportID: string;
@@ -181,6 +211,10 @@ const debouncedBroadcastUserIsTyping = lodashDebounce(
const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc();
+// We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will
+// prevent auto focus on existing chat for mobile device
+const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
+
/**
* This component holds the value and selection state.
* If a component really needs access to these state values it should be put here.
@@ -189,10 +223,17 @@ const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc();
*/
function ComposerWithSuggestions(
{
+ // Onyx
+ modal,
+ preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE,
+ parentReportActions,
+
// Props: Report
reportID,
includeChronos,
+ isEmptyChat,
lastReportAction,
+ parentReportActionID,
isGroupPolicyReport,
policyID,
@@ -222,6 +263,7 @@ function ComposerWithSuggestions(
// Refs
suggestionsRef,
isNextModalWillOpenRef,
+ editFocused,
// For testing
children,
@@ -246,15 +288,6 @@ function ComposerWithSuggestions(
}
return draftComment;
});
-
- const [modal] = useOnyx(ONYXKEYS.MODAL);
- const [preferredSkinTone] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, {
- selector: EmojiUtils.getPreferredSkinToneIndex,
- initialValue: CONST.EMOJI_DEFAULT_SKIN_TONE,
- });
-
- const [editFocused] = useOnyx(ONYXKEYS.INPUT_FOCUSED);
-
const commentRef = useRef(value);
const lastTextRef = useRef(value);
@@ -265,7 +298,13 @@ function ComposerWithSuggestions(
const {shouldUseNarrowLayout} = useResponsiveLayout();
const maxComposerLines = shouldUseNarrowLayout ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES;
- const shouldAutoFocus = !modal?.isVisible && shouldShowComposeInput && Modal.areAllModalsHidden() && isFocused;
+ const parentReportAction = parentReportActions?.[parentReportActionID ?? '-1'];
+ const shouldAutoFocus =
+ !modal?.isVisible &&
+ Modal.areAllModalsHidden() &&
+ isFocused &&
+ (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentReportAction))) &&
+ shouldShowComposeInput;
const valueRef = useRef(value);
valueRef.current = value;
@@ -274,8 +313,6 @@ function ComposerWithSuggestions(
const [composerHeight, setComposerHeight] = useState(0);
- const [showSoftInputOnFocus, setShowSoftInputOnFocus] = useState(false);
-
const textInputRef = useRef(null);
const syncSelectionWithOnChangeTextRef = useRef(null);
@@ -763,19 +800,6 @@ function ComposerWithSuggestions(
onScroll={hideSuggestionMenu}
shouldContainScroll={Browser.isMobileSafari()}
isGroupPolicyReport={isGroupPolicyReport}
- showSoftInputOnFocus={showSoftInputOnFocus}
- onTouchStart={() => {
- if (showSoftInputOnFocus) {
- return;
- }
- if (Browser.isMobileSafari()) {
- setTimeout(() => {
- setShowSoftInputOnFocus(true);
- }, CONST.ANIMATED_TRANSITION);
- return;
- }
- setShowSoftInputOnFocus(true);
- }}
/>
@@ -813,6 +837,22 @@ ComposerWithSuggestions.displayName = 'ComposerWithSuggestions';
const ComposerWithSuggestionsWithRef = forwardRef(ComposerWithSuggestions);
-export default memo(ComposerWithSuggestionsWithRef);
+export default withOnyx, ComposerWithSuggestionsOnyxProps>({
+ modal: {
+ key: ONYXKEYS.MODAL,
+ },
+ preferredSkinTone: {
+ key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
+ selector: EmojiUtils.getPreferredSkinToneIndex,
+ },
+ editFocused: {
+ key: ONYXKEYS.INPUT_FOCUSED,
+ },
+ parentReportActions: {
+ key: ({parentReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`,
+ canEvict: false,
+ initWithStoredValues: false,
+ },
+})(memo(ComposerWithSuggestionsWithRef));
export type {ComposerWithSuggestionsProps, ComposerRef};
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx
index a64513e5d3a3..f325ef10b56f 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx
@@ -40,7 +40,9 @@ function ComposerWithSuggestionsE2e(props: ComposerWithSuggestionsProps, ref: Fo
hasFocusBeenRequested.current = true;
const setFocus = () => {
+ console.debug('[E2E] Requesting focus for ComposerWithSuggestions');
if (!(textInputRef && 'current' in textInputRef)) {
+ console.error('[E2E] textInputRef is not available, failed to focus');
return;
}
@@ -54,12 +56,11 @@ function ComposerWithSuggestionsE2e(props: ComposerWithSuggestionsProps, ref: Fo
textInputRef.current?.blur();
setFocus();
- // 1000ms is enough time for any keyboard to open
- }, 1000);
+ // Simulate user behavior and don't set focus immediately
+ }, 5_000);
};
- // Simulate user behavior and don't set focus immediately
- setTimeout(setFocus, 2000);
+ setFocus();
}, []);
return (
diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
index 1f2c7b7798b5..762021163833 100644
--- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
+++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
@@ -28,6 +28,7 @@ import useNetwork from '@hooks/useNetwork';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import DomUtils from '@libs/DomUtils';
import {getDraftComment} from '@libs/DraftCommentUtils';
@@ -63,7 +64,7 @@ type SuggestionsRef = {
getIsSuggestionsMenuVisible: () => boolean;
};
-type ReportActionComposeProps = Pick & {
+type ReportActionComposeProps = Pick & {
/** A method to call when the form is submitted */
onSubmit: (newComment: string) => void;
@@ -89,6 +90,10 @@ type ReportActionComposeProps = Pick {
const initialModalState = getModalState();
- return shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible;
+ return shouldFocusInputOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible;
});
const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize);
const [shouldHideEducationalTooltip, setShouldHideEducationalTooltip] = useState(false);
@@ -460,8 +466,11 @@ function ReportActionCompose({
raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLikelyLayoutTriggered}
reportID={reportID}
policyID={report?.policyID ?? '-1'}
+ parentReportID={report?.parentReportID}
+ parentReportActionID={report?.parentReportActionID}
includeChronos={ReportUtils.chatIncludesChronos(report)}
isGroupPolicyReport={isGroupPolicyReport}
+ isEmptyChat={isEmptyChat}
lastReportAction={lastReportAction}
isMenuVisible={isMenuVisible}
inputPlaceholder={inputPlaceholder}
diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx
index 25dc4b24cfa2..cb9f95e5bb5e 100644
--- a/src/pages/home/report/ReportActionItem.tsx
+++ b/src/pages/home/report/ReportActionItem.tsx
@@ -198,8 +198,8 @@ function ReportActionItem({
const popoverAnchorRef = useRef>(null);
const downloadedPreviews = useRef([]);
const prevDraftMessage = usePrevious(draftMessage);
+ const [isUserValidated] = useOnyx(ONYXKEYS.USER, {selector: (user) => !!user?.validated});
const {canUseP2PDistanceRequests} = usePermissions();
-
// The app would crash due to subscribing to the entire report collection if parentReportID is an empty string. So we should have a fallback ID here.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID || -1}`);
@@ -608,7 +608,7 @@ function ReportActionItem({
success
style={[styles.w100, styles.requestPreviewBox]}
text={translate('bankAccount.addBankAccount')}
- onPress={() => BankAccounts.openPersonalBankAccountSetupView(Navigation.getTopmostReportId() ?? linkedReport?.reportID)}
+ onPress={() => BankAccounts.openPersonalBankAccountSetupView(Navigation.getTopmostReportId() ?? linkedReport?.reportID, isUserValidated)}
pressOnEnter
large
/>
diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx
index ce925d4375af..76ba6426929b 100644
--- a/src/pages/home/report/ReportActionsList.tsx
+++ b/src/pages/home/report/ReportActionsList.tsx
@@ -159,7 +159,7 @@ function ReportActionsList({
const styles = useThemeStyles();
const {translate} = useLocalize();
const {windowHeight} = useWindowDimensions();
- const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const {isInNarrowPaneModal, shouldUseNarrowLayout} = useResponsiveLayout();
const {isOffline} = useNetwork();
const route = useRoute>();
@@ -346,12 +346,15 @@ function ReportActionsList({
return;
}
if (!hasNewestReportActionRef.current) {
+ if (isInNarrowPaneModal) {
+ return;
+ }
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report.reportID));
return;
}
InteractionManager.runAfterInteractions(() => reportScrollManager.scrollToBottom());
},
- [reportScrollManager, report.reportID],
+ [isInNarrowPaneModal, reportScrollManager, report.reportID],
);
useEffect(() => {
// Why are we doing this, when in the cleanup of the useEffect we are already calling the unsubscribe function?
diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx
index 90746efa3b68..7c4ec786b633 100644
--- a/src/pages/home/report/ReportFooter.tsx
+++ b/src/pages/home/report/ReportFooter.tsx
@@ -48,6 +48,9 @@ type ReportFooterProps = {
/** Whether to show educational tooltip in workspace chat for first-time user */
workspaceTooltip: OnyxEntry;
+ /** Whether the chat is empty */
+ isEmptyChat?: boolean;
+
/** The pending action when we are adding a chat */
pendingAction?: PendingAction;
@@ -70,6 +73,7 @@ function ReportFooter({
report = {reportID: '-1'},
reportMetadata,
policy,
+ isEmptyChat = true,
isReportReadyForDisplay = true,
isComposerFullSize = false,
workspaceTooltip,
@@ -220,6 +224,7 @@ function ReportFooter({
onComposerBlur={onComposerBlur}
reportID={report.reportID}
report={report}
+ isEmptyChat={isEmptyChat}
lastReportAction={lastReportAction}
pendingAction={pendingAction}
isComposerFullSize={isComposerFullSize}
@@ -241,6 +246,7 @@ export default memo(
lodashIsEqual(prevProps.report, nextProps.report) &&
prevProps.pendingAction === nextProps.pendingAction &&
prevProps.isComposerFullSize === nextProps.isComposerFullSize &&
+ prevProps.isEmptyChat === nextProps.isEmptyChat &&
prevProps.lastReportAction === nextProps.lastReportAction &&
prevProps.isReportReadyForDisplay === nextProps.isReportReadyForDisplay &&
prevProps.workspaceTooltip?.shouldShow === nextProps.workspaceTooltip?.shouldShow &&
diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
index 7cbb2327c5c8..e5ec056f4839 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
@@ -89,11 +89,11 @@ function IOURequestStepConfirmation({
const isSubmittingFromTrackExpense = action === CONST.IOU.ACTION.SUBMIT;
const isMovingTransactionFromTrackExpense = IOUUtils.isMovingTransactionFromTrackExpense(action);
const payeePersonalDetails = useMemo(() => {
- if (personalDetails?.[transaction?.splitPayerAccountIDs?.[0] ?? -1]) {
- return personalDetails?.[transaction?.splitPayerAccountIDs?.[0] ?? -1];
+ if (personalDetails?.[transaction?.splitPayerAccountIDs?.at(0) ?? -1]) {
+ return personalDetails?.[transaction?.splitPayerAccountIDs?.at(0) ?? -1];
}
- const participant = transaction?.participants?.find((val) => val.accountID === (transaction?.splitPayerAccountIDs?.[0] ?? -1));
+ const participant = transaction?.participants?.find((val) => val.accountID === (transaction?.splitPayerAccountIDs?.at(0) ?? -1));
return {
login: participant?.login ?? '',
diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx
index b5cda2e497d3..47f11942cd7d 100644
--- a/src/pages/iou/request/step/IOURequestStepDistance.tsx
+++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx
@@ -21,10 +21,12 @@ import usePolicy from '@hooks/usePolicy';
import usePrevious from '@hooks/usePrevious';
import useThemeStyles from '@hooks/useThemeStyles';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
+import type {MileageRate} from '@libs/DistanceRequestUtils';
import * as ErrorUtils from '@libs/ErrorUtils';
import * as IOUUtils from '@libs/IOUUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
+import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
import * as IOU from '@userActions/IOU';
@@ -36,6 +38,7 @@ 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 {Participant} from '@src/types/onyx/IOU';
import type {Errors} from '@src/types/onyx/OnyxCommon';
import type {Waypoint, WaypointCollection} from '@src/types/onyx/Transaction';
import StepScreenWrapper from './StepScreenWrapper';
@@ -83,6 +86,7 @@ function IOURequestStepDistance({
const scrollViewRef = useRef(null);
const isLoadingRoute = transaction?.comment?.isLoading ?? false;
const isLoading = transaction?.isLoading ?? false;
+ const isSplitRequest = iouType === CONST.IOU.TYPE.SPLIT;
const hasRouteError = !!transaction?.errorFields?.route;
const [shouldShowAtLeastTwoDifferentWaypointsError, setShouldShowAtLeastTwoDifferentWaypointsError] = useState(false);
const isWaypointEmpty = (waypoint?: Waypoint) => {
@@ -104,7 +108,39 @@ function IOURequestStepDistance({
const isCreatingNewRequest = !(backTo || isEditing);
const [recentWaypoints, {status: recentWaypointsStatus}] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS);
const iouRequestType = TransactionUtils.getRequestType(transaction);
- const customUnitRateID = TransactionUtils.getRateID(transaction);
+ const customUnitRateID = TransactionUtils.getRateID(transaction) ?? '-1';
+
+ // Sets `amount` and `split` share data before moving to the next step to avoid briefly showing `0.00` as the split share for participants
+ const setDistanceRequestData = useCallback(
+ (participants: Participant[]) => {
+ // Get policy report based on transaction participants
+ const isPolicyExpenseChat = participants?.some((participant) => participant.isPolicyExpenseChat);
+ const selectedReportID = participants?.length === 1 ? participants.at(0)?.reportID ?? reportID : reportID;
+ const policyReport = participants.at(0) ? ReportUtils.getReport(selectedReportID) : report;
+
+ const IOUpolicyID = IOU.getIOURequestPolicyID(transaction, policyReport);
+ const IOUpolicy = PolicyUtils.getPolicy(report?.policyID ?? IOUpolicyID);
+ const policyCurrency = policy?.outputCurrency ?? PolicyUtils.getPersonalPolicy()?.outputCurrency ?? CONST.CURRENCY.USD;
+
+ const mileageRates = DistanceRequestUtils.getMileageRates(IOUpolicy);
+ const defaultMileageRate = DistanceRequestUtils.getDefaultMileageRate(IOUpolicy);
+ const mileageRate: MileageRate = TransactionUtils.isCustomUnitRateIDForP2P(transaction)
+ ? DistanceRequestUtils.getRateForP2P(policyCurrency)
+ : mileageRates?.[customUnitRateID] ?? defaultMileageRate;
+
+ const {unit, rate} = mileageRate ?? {};
+ const distance = TransactionUtils.getDistanceInMeters(transaction, unit);
+ const currency = mileageRate?.currency ?? policyCurrency;
+ const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate ?? 0);
+ IOU.setMoneyRequestAmount(transactionID, amount, currency);
+
+ const participantAccountIDs: number[] | undefined = participants?.map((participant) => Number(participant.accountID ?? -1));
+ if (isSplitRequest && amount && currency && !isPolicyExpenseChat) {
+ IOU.setSplitShares(transaction, amount, currency ?? '', participantAccountIDs ?? []);
+ }
+ },
+ [report, transaction, transactionID, isSplitRequest, policy?.outputCurrency, reportID, customUnitRateID],
+ );
// For quick button actions, we'll skip the confirmation page unless the report is archived or this is a workspace
// request and the workspace requires a category or a tag
@@ -245,6 +281,7 @@ function IOURequestStepDistance({
const participantAccountID = participant?.accountID ?? -1;
return participantAccountID ? OptionsListUtils.getParticipantsOption(participant, personalDetails) : OptionsListUtils.getReportOption(participant);
});
+ setDistanceRequestData(participants);
if (shouldSkipConfirmation) {
if (iouType === CONST.IOU.TYPE.SPLIT) {
IOU.splitBill({
@@ -348,6 +385,7 @@ function IOURequestStepDistance({
iouRequestType,
reportNameValuePairs,
customUnitRateID,
+ setDistanceRequestData,
]);
const getError = () => {
diff --git a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx
index 59e1591a23ff..9bee2df17ef0 100644
--- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx
+++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import type {OnyxEntry} from 'react-native-onyx';
-import {useOnyx, withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import SelectionList from '@components/SelectionList';
import RadioListItem from '@components/SelectionList/RadioListItem';
import Text from '@components/Text';
@@ -23,35 +23,23 @@ import withFullTransactionOrNotFound from './withFullTransactionOrNotFound';
import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound';
import withWritableReportOrNotFound from './withWritableReportOrNotFound';
-type IOURequestStepDistanceRateOnyxProps = {
- /** Policy details */
- policy: OnyxEntry;
-
- /** Collection of categories attached to the policy */
- policyCategories: OnyxEntry;
-
- /** Collection of tags attached to the policy */
- policyTags: OnyxEntry;
+type IOURequestStepDistanceRateProps = WithWritableReportOrNotFoundProps & {
+ /** Holds data related to Money Request view state, rather than the underlying Money Request data. */
+ transaction: OnyxEntry;
};
-type IOURequestStepDistanceRateProps = IOURequestStepDistanceRateOnyxProps &
- WithWritableReportOrNotFoundProps & {
- /** Holds data related to Money Request view state, rather than the underlying Money Request data. */
- transaction: OnyxEntry;
- };
-
function IOURequestStepDistanceRate({
- policy: policyReal,
report,
reportDraft,
route: {
params: {action, reportID, backTo, transactionID},
},
transaction,
- policyTags,
- policyCategories,
}: IOURequestStepDistanceRateProps) {
const [policyDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${IOU.getIOURequestPolicyID(transaction, reportDraft) ?? '-1'}`);
+ const [policyReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '-1'}`);
+ const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report ? report.policyID : '0'}`);
+ const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`);
const policy = policyReal ?? policyDraft;
@@ -70,18 +58,20 @@ function IOURequestStepDistanceRate({
Navigation.goBack(backTo);
};
- const sections = Object.values(rates).map((rate) => {
- const rateForDisplay = DistanceRequestUtils.getRateForDisplay(rate.unit, rate.rate, rate.currency, translate, toLocaleDigit);
-
- return {
- text: rate.name ?? rateForDisplay,
- alternateText: rate.name ? rateForDisplay : '',
- keyForList: rate.customUnitRateID,
- value: rate.customUnitRateID,
- isDisabled: !rate.enabled,
- isSelected: currentRateID ? currentRateID === rate.customUnitRateID : rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE,
- };
- });
+ const sections = Object.values(rates)
+ .sort((rateA, rateB) => (rateA?.rate ?? 0) - (rateB?.rate ?? 0))
+ .map((rate) => {
+ const rateForDisplay = DistanceRequestUtils.getRateForDisplay(rate.unit, rate.rate, rate.currency, translate, toLocaleDigit);
+
+ return {
+ text: rate.name ?? rateForDisplay,
+ alternateText: rate.name ? rateForDisplay : '',
+ keyForList: rate.customUnitRateID,
+ value: rate.customUnitRateID,
+ isDisabled: !rate.enabled,
+ isSelected: currentRateID ? currentRateID === rate.customUnitRateID : rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE,
+ };
+ });
const unit = (Object.values(rates).at(0)?.unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.mile') : translate('common.kilometer')) as Unit;
@@ -133,20 +123,8 @@ function IOURequestStepDistanceRate({
IOURequestStepDistanceRate.displayName = 'IOURequestStepDistanceRate';
-const IOURequestStepDistanceRateWithOnyx = withOnyx({
- policy: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '-1'}`,
- },
- policyCategories: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report ? report.policyID : '0'}`,
- },
- policyTags: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`,
- },
-})(IOURequestStepDistanceRate);
-
// eslint-disable-next-line rulesdir/no-negated-variables
-const IOURequestStepDistanceRateWithWritableReportOrNotFound = withWritableReportOrNotFound(IOURequestStepDistanceRateWithOnyx);
+const IOURequestStepDistanceRateWithWritableReportOrNotFound = withWritableReportOrNotFound(IOURequestStepDistanceRate);
// eslint-disable-next-line rulesdir/no-negated-variables
const IOURequestStepDistanceRateWithFullTransactionOrNotFound = withFullTransactionOrNotFound(IOURequestStepDistanceRateWithWritableReportOrNotFound);
diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx
index 3f23b3a802be..893a54c5ccfd 100644
--- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx
+++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx
@@ -1,11 +1,12 @@
import type {StackScreenProps} from '@react-navigation/stack';
import {Str} from 'expensify-common';
-import React, {useCallback} from 'react';
+import React, {useCallback, useState} from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx, withOnyx} from 'react-native-onyx';
import Button from '@components/Button';
import CopyTextToClipboard from '@components/CopyTextToClipboard';
+import DelegateNoAccessModal from '@components/DelegateNoAccessModal';
import FixedFooter from '@components/FixedFooter';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import MenuItem from '@components/MenuItem';
@@ -13,6 +14,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback';
import ScreenWrapper from '@components/ScreenWrapper';
import ScrollView from '@components/ScrollView';
import Text from '@components/Text';
+import useDelegateUserDetails from '@hooks/useDelegateUserDetails';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
@@ -40,6 +42,10 @@ function ContactMethodsPage({loginList, session, route}: ContactMethodsPageProps
const {formatPhoneNumber, translate} = useLocalize();
const loginNames = Object.keys(loginList ?? {});
const navigateBackTo = route?.params?.backTo;
+ const [account] = useOnyx(ONYXKEYS.ACCOUNT);
+ const isActingAsDelegate = !!account?.delegatedAccess?.delegate;
+ const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false);
+ const {delegatorEmail} = useDelegateUserDetails();
// Sort the login names by placing the one corresponding to the default contact method as the first item before displaying the contact methods.
// The default contact method is determined by checking against the session email (the current login).
@@ -97,8 +103,12 @@ function ContactMethodsPage({loginList, session, route}: ContactMethodsPageProps
});
const onNewContactMethodButtonPress = useCallback(() => {
+ if (isActingAsDelegate) {
+ setIsNoDelegateAccessMenuVisible(true);
+ return;
+ }
Navigation.navigate(ROUTES.SETTINGS_NEW_CONTACT_METHOD.getRoute(navigateBackTo));
- }, [navigateBackTo]);
+ }, [navigateBackTo, isActingAsDelegate]);
return (
+ setIsNoDelegateAccessMenuVisible(false)}
+ delegatorEmail={delegatorEmail ?? ''}
+ />
);
}
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/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/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/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/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/QuickbooksDesktopImportPage.tsx b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopImportPage.tsx
index f20975a25648..8aa21ebccdd8 100644
--- a/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopImportPage.tsx
+++ b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopImportPage.tsx
@@ -29,15 +29,15 @@ 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'),
diff --git a/src/pages/workspace/accounting/utils.tsx b/src/pages/workspace/accounting/utils.tsx
index 2afa6ddd282d..7e694babdd5c 100644
--- a/src/pages/workspace/accounting/utils.tsx
+++ b/src/pages/workspace/accounting/utils.tsx
@@ -260,8 +260,11 @@ function getAccountingIntegrationData(
onExportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.getRoute(policyID)),
onCardReconciliationPagePress: () => {},
onAdvancedPagePress: () => {},
- subscribedImportSettings: [],
+ // TODO: [QBD] Make sure all values are passed to subscribedSettings
+ subscribedImportSettings: [CONST.QUICKBOOKS_DESKTOP_CONFIG.ENABLE_NEW_CATEGORIES, CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CLASSES],
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,
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/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/styles/index.ts b/src/styles/index.ts
index 435e6a732fa1..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: {
@@ -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/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..5ecf9ad8d84f 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -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"
}
```