diff --git a/.github/workflows/buildAndroid.yml b/.github/workflows/buildAndroid.yml index 3115076ef62b..c88542068b92 100644 --- a/.github/workflows/buildAndroid.yml +++ b/.github/workflows/buildAndroid.yml @@ -20,11 +20,14 @@ on: description: The pull request number associated with this build, if relevant. type: string required: false + outputs: AAB_FILE_NAME: value: ${{ jobs.build.outputs.AAB_FILE_NAME }} APK_FILE_NAME: value: ${{ jobs.build.outputs.APK_FILE_NAME }} + APK_ARTIFACT_NAME: + value: ${{ jobs.build.outputs.APK_ARTIFACT_NAME }} workflow_dispatch: inputs: @@ -56,6 +59,7 @@ jobs: outputs: AAB_FILE_NAME: ${{ steps.build.outputs.AAB_FILE_NAME }} APK_FILE_NAME: ${{ steps.build.outputs.APK_FILE_NAME }} + APK_ARTIFACT_NAME: ${{ steps.build.outputs.APK_ARTIFACT_NAME }} steps: - name: Checkout @@ -159,6 +163,7 @@ jobs: echo "APK_PATH=$apkPath" echo "APK_FILE_NAME=$(basename "$apkPath")" echo "SHOULD_UPLOAD_SOURCEMAPS=$SHOULD_UPLOAD_SOURCEMAPS" + echo "APK_ARTIFACT_NAME=${{ inputs.artifact-prefix }}android-artifact-apk" >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT" - name: Upload Android AAB artifact @@ -174,7 +179,7 @@ jobs: continue-on-error: true uses: actions/upload-artifact@v4 with: - name: ${{ inputs.artifact-prefix }}android-artifact-apk + name: ${{ steps.build.outputs.APK_ARTIFACT_NAME }} path: ${{ steps.build.outputs.APK_PATH }} - name: Upload Android sourcemaps artifact diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index f88e841617bb..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) }} @@ -125,7 +125,7 @@ jobs: uses: actions/download-artifact@v4 id: downloadBaselineAPK with: - name: baseline-${{ needs.prep.outputs.BASELINE_VERSION }}-android-artifact-apk + name: ${{ needs.buildBaseline.outputs.APK_ARTIFACT_NAME }} path: zip # Set github-token only if the baseline was built in this workflow run: github-token: ${{ needs.prep.outputs.BASELINE_ARTIFACT_WORKFLOW_ID && github.token }} @@ -139,7 +139,7 @@ jobs: uses: actions/download-artifact@v4 id: downloadDeltaAPK with: - name: delta-${{ needs.prep.outputs.DELTA_REF }}-android-artifact-apk + name: ${{ needs.buildDelta.outputs.APK_ARTIFACT_NAME }} path: zip - name: Rename delta APK diff --git a/android/app/build.gradle b/android/app/build.gradle index dcd0ff44585e..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 1009004702 - versionName "9.0.47-2" + 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/assets/images/companyCards/card-bank_of_america.svg b/assets/images/companyCards/card-bank_of_america.svg deleted file mode 100644 index 684a6a0a28f5..000000000000 --- a/assets/images/companyCards/card-bank_of_america.svg +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/images/companyCards/card-other.svg b/assets/images/companyCards/card-other.svg deleted file mode 100644 index 11ff21285626..000000000000 --- a/assets/images/companyCards/card-other.svg +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 90ac50d1dcbd..75cb080f1349 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -9,7 +9,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-updater": "^6.3.6", + "electron-updater": "^6.3.8", "mime-types": "^2.1.35", "node-machine-id": "^1.1.12" }, @@ -59,9 +59,9 @@ } }, "node_modules/builder-util-runtime": { - "version": "9.2.7", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.7.tgz", - "integrity": "sha512-0qw2vcbA66LW/ImxZSy0vKQr9OqrpFXxtLyITBla7CdLlgz9fZkVAhKBi8EmNYIplL9j3zizB3mcgWnwVC6Fmg==", + "version": "9.2.9", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.9.tgz", + "integrity": "sha512-DWeHdrRFVvNnVyD4+vMztRpXegOGaQHodsAjyhstTbUNBIjebxM1ahxokQL+T1v8vpW8SY7aJ5is/zILH82lAw==", "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -156,12 +156,12 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "node_modules/electron-updater": { - "version": "6.3.6", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.6.tgz", - "integrity": "sha512-SciFV8nt04rwFFlW8Ph7NkoXVgISMJ9i7aAmovFmp3xFd6GUPBKpLKJNGPy10/R0hJKe/9fDkuVQ75LEZEQ+Ng==", + "version": "6.3.8", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.8.tgz", + "integrity": "sha512-OFsA2vyuOZgsqq4EW6tgW8X8e521ybDmQyIYLqss7HdXev+Ak90YatzpIECOBJXpmro5YDq4yZ2xFsKXqPt1DQ==", "license": "MIT", "dependencies": { - "builder-util-runtime": "9.2.7", + "builder-util-runtime": "9.2.9", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", @@ -469,9 +469,9 @@ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==" }, "builder-util-runtime": { - "version": "9.2.7", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.7.tgz", - "integrity": "sha512-0qw2vcbA66LW/ImxZSy0vKQr9OqrpFXxtLyITBla7CdLlgz9fZkVAhKBi8EmNYIplL9j3zizB3mcgWnwVC6Fmg==", + "version": "9.2.9", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.9.tgz", + "integrity": "sha512-DWeHdrRFVvNnVyD4+vMztRpXegOGaQHodsAjyhstTbUNBIjebxM1ahxokQL+T1v8vpW8SY7aJ5is/zILH82lAw==", "requires": { "debug": "^4.3.4", "sax": "^1.2.4" @@ -538,11 +538,11 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "electron-updater": { - "version": "6.3.6", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.6.tgz", - "integrity": "sha512-SciFV8nt04rwFFlW8Ph7NkoXVgISMJ9i7aAmovFmp3xFd6GUPBKpLKJNGPy10/R0hJKe/9fDkuVQ75LEZEQ+Ng==", + "version": "6.3.8", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.8.tgz", + "integrity": "sha512-OFsA2vyuOZgsqq4EW6tgW8X8e521ybDmQyIYLqss7HdXev+Ak90YatzpIECOBJXpmro5YDq4yZ2xFsKXqPt1DQ==", "requires": { - "builder-util-runtime": "9.2.7", + "builder-util-runtime": "9.2.9", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", diff --git a/desktop/package.json b/desktop/package.json index 2392c98434c1..326d6f24f740 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -6,7 +6,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-updater": "^6.3.6", + "electron-updater": "^6.3.8", "mime-types": "^2.1.35", "node-machine-id": "^1.1.12" }, diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account.md index 1ad70117ed5c..49aaa480e2dc 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account.md @@ -8,7 +8,7 @@ You can choose to connect either a business deposit-only account that only recei | Business deposit-only account | Verified business account | |---------------------------------------------------|------------------------------------------------------| -| ✔ Receive reimbursements for invoices | ✔ Reimburse expenses via direct bank transfer | +| ✔ Receive payments for invoices | ✔ Reimburse expenses via direct bank transfer | | | ✔ Pay bills | | | ✔ Issue Expensify Cards | diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md similarity index 95% rename from docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills.md rename to docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md index 465f6742eaea..aff11c059d81 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md @@ -1,26 +1,26 @@ --- -title: Pay Bills +title: Create and Pay Bills description: Expensify bill management and payment methods. --- Streamline your operations by receiving and paying vendor or supplier bills directly in Expensify. Vendors can send bills even if they don't have an Expensify account, and you can manage payments seamlessly. -## Receive Bills in Expensify +# Receive Bills in Expensify You can receive bills in three ways: - Directly from Vendors: Provide your Expensify billing email to vendors. - Forwarding Emails: Forward bills received in your email to Expensify. - Manual Upload: For physical bills, create a Bill in Expensify from the Reports page. -## Bill Pay Workflow +# Bill Pay Workflow 1. When a vendor or supplier sends a bill to Expensify, the document is automatically SmartScanned, and a Bill is created. This Bill is managed by the primary domain contact, who can view it on the Reports page within their default group policy. 2. Once the Bill is ready for processing, it follows the established approval workflow. As each person approves it, the Bill appears in the next approver’s Inbox. The final approver will pay the Bill using one of the available payment methods. 3. During this process, the Bill is coded with the appropriate GL codes from your connected accounting software. After completing the approval workflow, the Bill can be exported back to your accounting system. -## Payment Methods +# Payment Methods There are multiple ways to pay Bills in Expensify. Let’s go over each method below. -### ACH bank-to-bank transfer +## ACH bank-to-bank transfer To use this payment method, you must have a [business bank account connected to your Expensify account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account). @@ -32,7 +32,7 @@ To use this payment method, you must have a [business bank account connected to **Fees:** None -### Credit or Debit Card +## Credit or Debit Card This option is available to all US and International customers receiving a bill from a US vendor with a US business bank account. **To pay with a credit or debit card:** @@ -43,13 +43,13 @@ This option is available to all US and International customers receiving a bill **Fees:** 2.9% of the total amount paid. -### Venmo +## Venmo If both you and the vendor must have Venmo connected to Expensify, you can pay the bill by following the steps outlined [here](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments#setting-up-third-party-payments). **Fees:** Venmo charges a 3% sender’s fee. -### Pay outside of Expensify +## Pay outside of Expensify If you are unable to pay using one of the above methods, you can still mark the Bill as paid. This will update its status to indicate that the payment was made outside Expensify. **To mark a Bill as paid outside of Expensify:** diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-an-Invoice.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-an-Invoice.md new file mode 100644 index 000000000000..84fafc949527 --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-an-Invoice.md @@ -0,0 +1,65 @@ +--- +title: Pay an Invoice +description: A guide to different methods of paying an invoice +--- + +
+ +There are multiple ways to pay Invoices in Expensify. Let’s go over each method below. + +# How to Pay Invoices + +1. Sign in to your [Expensify web account](www.expensify.com). +2. Click on the Invoice you’d like to pay to see the details. +3. Click on the **Pay** button. +4. Follow the prompts to pay through one of the following methods. + +### ACH bank-to-bank transfer + +To use this payment method, you must have a [business bank account connected to your Expensify account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account). + +**To pay with an ACH bank-to-bank transfer:** +1. Sign in to your [Expensify web account](www.expensify.com). +2. Go to the **Home** or **Reports** page and locate the Invoice that needs to be paid. +3. Click the **Pay** button to be redirected to the Invoice. +4. Choose the ACH option from the drop-down list. + +**Fees:** None + +### Credit or Debit Card +This option is available to all US and International customers receiving an invoice from a US vendor with a US business bank account. + +**To pay with a credit or debit card:** +1. Sign in to your [Expensify web account](www.expensify.com). +2. Click on the Invoice you’d like to pay to see the details. +3. Click the **Pay** button. +4. Enter your credit card or debit card details. + +**Fees:** 2.9% credit card payment fee. + +### Venmo +If both you and the vendor must have Venmo connected to Expensify, you can pay the Invoice by following the steps outlined [here](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments#setting-up-third-party-payments). + +**Fees:** Venmo and Paypal.me charges may apply. + + +### Pay outside of Expensify +If you are unable to pay using one of the above methods, you can still mark the Invoice as paid. This will update its status to indicate that the payment was made outside Expensify. + +**To mark an Invoice as paid outside of Expensify:** +1. Sign in to your [Expensify web account](www.expensify.com). +2. Click on the Invoice you’d like to pay to see the details. +3. Click the Pay button. +4. Choose **I’ll do it manually**. + +**Fees:** None. + +{% include faq-begin.md %} + +## What’s the difference between an Invoice and an Expense Report in Expensify? +An invoice is an expense submitted to a client or contractor for payment. An expense report is an expense/group of expenses submitted to an employer for reimbursement. + +## What’s the difference between a Bill and an Invoice in Expensify? +A Bill is an amount owed to a payee (usually a vendor or supplier) and is usually created from a vendor invoice. An Invoice is a receivable and indicates an amount owed to you by someone else. + +{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md b/docs/articles/expensify-classic/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md index 158a55b93e0f..6a43141f1ab9 100644 --- a/docs/articles/expensify-classic/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md +++ b/docs/articles/expensify-classic/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md @@ -18,7 +18,7 @@ This error occurs when the account applied as a category to the expense in Expen 5. Click on the pencil icon on the right to check if you have "In multiple accounts" selected: 6. If "In multiple accounts" is selected, go to Chart of Accounts and click Edit for the account in question. 7. Check the billable option and select an income account within your chart of accounts -8. Sync your QuickBooks Online connection in Settings > Workspaces > [click workspace] > Connections. +8. Sync your QuickBooks Online connection in Settings > Workspaces > [workspace name] > Connections. 9. Open the report and click the Export to button and then the QuickBooks Online option. # ExpensiError QBO046: Feature Not Included in Subscription @@ -40,11 +40,11 @@ _Please note: Self Employed is not supported:_ **Why does this happen?** -QuickBooks Online requires all expenses exported from Expensify to use a category matching an account in your chart of accounts. If a category from another source is used, QuickBooks Online will reject the expense. This errors occurs when an expense on the report has a category applied that is not valid in QuickBooks Online. +QuickBooks Online requires all expenses exported from Expensify to use a category matching an account in your chart of accounts. If a category from another source is used, QuickBooks Online will reject the expense. This error occurs when an expense on the report has a category applied that is not valid in QuickBooks Online. ## How to fix it -1. Sync your QuickBooks Online connection in Expensify from Settings > Workspaces > [click workspace] > Connections, and click the **Sync Now** button. +1. Sync your QuickBooks Online connection in Expensify from Settings > Workspaces > [workspace name] > Connections, and click the **Sync Now** button. 2. Review the expenses on the report. If any appear with a red _Category no longer valid_ violation, recategorize the expense until all expenses are violation-free. 3. Click the **Export t**o button and then the **QuickBooks Online** option. - If you receive the same error, continue. @@ -56,7 +56,7 @@ QuickBooks Online requires all expenses exported from Expensify to use a categor **Why does this happen?** -This error occurs when you have an Employee Record set up with the employee's name, which prevents the Expensify integration from automatically creating the Vendor Record with the same name, since QuickBooks Online won't allow you to have an employee and vendor with the same name. +This error occurs when you have an Employee Record set up with the employee's name. This prevents the Expensify integration from automatically creating the Vendor Record with the same name since QuickBooks Online won't allow you to have an employee and vendor with the same name. ## How to fix it @@ -67,7 +67,7 @@ There are two different ways you can resolve this error. 1. Log into QuickBooks Online. 2. Access the Employee Records for your submitters. 3. Edit the name to differentiate them from the name they have on their account in Expensify. -4. Sync your QuickBooks Online connection in Settings > Workspaces > [click workspace] > Connections. +4. Sync your QuickBooks Online connection in Settings > Workspaces > [workspace name] > Connections. 5. Open the report and click the Export to button and then the QuickBooks Online option. **Option 2**: @@ -85,7 +85,7 @@ This error occurs when you are exporting reimbursable expenses as Journal Entrie There are three different ways you can resolve this error. - Select a different type of export for reimbursable expenses under Settings > Workspaces > [worksapce name] > Connections > Configure > Export tab. -- Enable _Automatically Create Entities_ under Settings > Workspaces > [worksapce name] > Connections > Configure > Advanced to create vendor records automatically. +- Enable _Automatically Create Entities_ under Settings > Workspaces > [workspace name] > Connections > Configure > Advanced to create vendor records automatically. - Manually create vendor records in QuickBooks Online for each employee. # ExpensiError QBO099: Items marked as billable must have sales information checked @@ -97,7 +97,7 @@ This error occurs when an Item category on an expense does not have sales inform ## How to fix it 1. Log into QuickBooks Online. -2. Navigate to to your items list. +2. Navigate to your items list. 3. Click **Edit** to the right of the item used on the report with the error. Here you will see an option to check either "Sales" or "Purchasing". 4. Check the option for **Sales**. 5. Select an income account. @@ -146,12 +146,12 @@ This error occurs because the currency on the Vendor record in QuickBooks Online 1. Log into QuickBooks Online. 2. Open the vendor record. -3. Update the record to use with the correct A/P account, currency and an email matching their Expensify email. -You can find the correct Vendor record by exporting your QuickBooks Online [vendor list](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fqbo.intuit.com%2Fapp%2Fvendors) to a spreadsheet (click the export icon on the right-hand side of the page), and search for the email address of the person who submitted the report. +3. Update the record to use with the correct A/P account, currency, and email matching their Expensify email. +You can find the correct Vendor record by exporting your QuickBooks Online vendor list to a spreadsheet (click the export icon on the right-hand side of the page), and search for the email address of the person who submitted the report. If you have multiple Vendors with different currencies with the same email, Expensify is likely trying to export to the wrong one. -1. Try removing the email address from the vendor in QuickBooks Online you aren't trying to export to. +1. Try removing the email address from the vendor in QuickBooks Online that you aren't trying to export to. 2. Sync your QuickBooks Online connection in Settings > Workspaces > [workspace name] > Connections. 3. Open the report and click the **Export to** button and then the **QuickBooks Online** option. @@ -160,13 +160,13 @@ If this still fails, you'll need to confirm that the A/P account selected in Exp 1. Navigate to Settings > Workspaces > [workspace name] > Connections. 2. Under the Exports tab check that both A/P accounts are the correct currency. -# Why are company card expenses exporting to the wrong account in QuickBooks Online? +# Why are company card expenses exported to the wrong account in QuickBooks Online? Multiple factors could be causing your company card transactions to export to the wrong place in your accounting system, but the best place to start is always the same. 1. First, confirm that the company cards have been mapped to the correct accounts in Settings > Domains > Company Cards > click the **Edit Export button** for the card to view the account. -2. Next, confirm the expenses in question have been imported from the company card? - - Only expenses that have the Card+Lock icon next to them will export according to the mapping settings that you configure in the domain settings. +2. Next, confirm the expenses in question have been imported from the company card. + - Only expenses with the Card+Lock icon next to them will export according to the mapping settings that you configure in the domain settings. It’s important to note that expenses imported from a card linked at the individual account level, expenses created from a SmartScanned receipt, and manually created cash expenses will export to the default bank account selected in your connection's configuration settings. @@ -174,7 +174,7 @@ It’s important to note that expenses imported from a card linked at the indivi The user exporting the report must be a domain admin. You can check the history and comment section at the bottom of the report to see who exported the report. -If your reports are being exported automatically by Concierge, the user listed as the Preferred Exporter under Settings > Workspaces > [workspaces name] > Connections > click **Configure** must be a domain admin as well. +If your reports are being exported automatically by Concierge, the user listed as the Preferred Exporter under Settings > Workspaces > [workspace name] > Connections > click **Configure** must also be a domain admin. If the report exporter is not a domain admin, all company card expenses will export to the bank account set in Settings > Workspaces > [workspace name] > Connections > click **Configure** for non-reimbursable expenses. 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/connections/sage-intacct/Connect-to-Sage-Intacct.md b/docs/articles/new-expensify/connections/sage-intacct/Connect-to-Sage-Intacct.md index 8e69a03fb666..c577c17e8463 100644 --- a/docs/articles/new-expensify/connections/sage-intacct/Connect-to-Sage-Intacct.md +++ b/docs/articles/new-expensify/connections/sage-intacct/Connect-to-Sage-Intacct.md @@ -4,37 +4,37 @@ description: Integrate Sage Intacct with Expensify order: 1 --- -# Connect to Sage Intacct +The Sage Intacct integration allows for automated syncing and reduces manual entries. The integration allows you to import your standard dimensions (like department, class, location, customer, and project/job) as well as user-defined dimensions for selection in Expensify. -Enjoy automated syncing and reduce manual entries with the Expensify and Sage Intacct integration. Gain in-depth, real-time financial insights with user-defined dimensions, as well as expense coding by department, class, location, customer, and project (job). +The features available for the Expensify connection with Sage Intacct vary based on your Sage Intacct subscription. The features may still be visible in Expensify even if you don't have access, but you will receive an error if the feature isn't available with your subscription. {% include info.html %} The Sage Intacct integration is only available on the Control plan. {% include end-info.html %} -## Overview +# Overview -Expensify’s integration with Sage Intacct allows you to connect using either role-based permissions or user-based permissions and exporting either expense reports or vendor bills. +Expensify’s integration with Sage Intacct allows you to connect using either role-based permissions or user-based permissions and to export either expense reports or vendor bills. -Checklist of items to complete: +**Checklist of items to complete:** 1. Create a web services user and configure permissions -1. Enable the T&E module (only required if exporting out-of-pocket expenses as Expense Reports) -1. Set up Employees in Sage Intacct (only required if exporting expenses as Expense Reports) -1. Set up Expense Types (only required if exporting expenses as Expense Reports) -1. Enable Customization Services -1. Download the Expensify Package -1. Upload the Expensify Package in Sage Intacct -1. Add web services authorization -1. Enter credentials and connect Expensify and Sage Intacct -1. Configure integration sync options - -## Step 1a: Create a web services user (Connecting with User-based permissions) -Note: If the steps in this section look different in your Sage Intacct instance, you likely use role-based permissions. If that’s the case, follow the steps [here]. +2. Enable the T&E module (only required if exporting out-of-pocket expenses as Expense Reports) +3. Set up Employees in Sage Intacct (only required if exporting expenses as Expense Reports) +4. Set up Expense Types (only required if exporting expenses as Expense Reports) +5. Enable Customization Services +6. Download the Expensify Package +7. Upload the Expensify Package in Sage Intacct +8. Add web services authorization +9. Enter credentials and connect Expensify and Sage Intacct +10. Configure integration sync options + +# Step 1a: Create a web services user (Connecting with User-based permissions) +Note: If the steps in this section look different from your Sage Intacct instance, you likely use role-based permissions. If that’s the case, start with [Step 1b](#step-1b-create-a-web-services-user-connecting-with-role-based-permissions). To connect to Sage Intacct, you’ll need to create a special web services user (please note that Sage Intacct does not charge extra for web services users). -1. Go to **Company > Web Services Users > New**. +1. Go to **Company > Web Services Users > New** 2. Configure the user as outlined below: - **User ID**: “xmlgateway_expensify” - **Last Name and First Name:** “Expensify” @@ -59,15 +59,15 @@ These are the permissions required for this integration when exporting out-of-po - **Projects (Read-only)** - Only required if using Projects or Customers - **Accounts Payable (All)** - Only required if exporting any expenses expenses as vendor bills -## Step 1b: Create a web services user (Connecting with Role-based permissions) -Note: If the steps in this section look different in your Sage Intacct instance, you likely use role-based permissions. If that’s the case, follow the steps [here]. +# Step 1b: Create a web services user (Connecting with Role-based permissions) +Note: If the steps in this section look different in your Sage Intacct instance, you likely use role-based permissions. If that’s the case, start with [Step 1a](#step-1a-create-a-web-services-user-connecting-with-user-based-permissions). **First, you'll need to create the new role:** 1. In Sage Intacct, click **Company**, then click on the **+ button** next to Roles -1. Name the role "Expensify", then click **Save** -1. Go to **Roles > Subscriptions** and find the “Expensify” role you just created -1. Configure correct permissions for this role by clicking the checkbox and then clicking on the Permissions hyperlink. These are the permissions required for this integration when exporting out-of-pocket expenses as vendor bills: +2. Name the role "Expensify", then click **Save** +3. Go to **Roles > Subscriptions** and find the “Expensify” role you just created +4. Configure correct permissions for this role by clicking the checkbox and then clicking on the Permissions hyperlink. These are the permissions required for this integration when exporting out-of-pocket expenses as vendor bills: - **Administration (All)** - **Company (Read-only)** - **Cash Management (All)** @@ -89,7 +89,7 @@ Note: If the steps in this section look different in your Sage Intacct instance, 3. Assign the role to that user: click the **+ button**, then select the “Expensify” role and click **Save** -## Step 2: Enable and configure the Time & Expenses Module +# Step 2: Enable and configure the Time & Expenses Module **Note: This step is only required if exporting out-of-pocket expenses from Expensify to Sage Intacct as Expense Reports.** Enabling the T&E module is a paid subscription through Sage Intacct and the T&E module is often included in your Sage Intacct instance. For information on the costs of enabling this module, please contact your Sage Intacct account manager. @@ -118,7 +118,7 @@ In Sage Intacct, go to **Company menu > Subscriptions > Time & Expenses** and to 6. Click **Save** to confirm your configuration -## Step 3: Set up Employees in Sage Intacct +# Step 3: Set up Employees in Sage Intacct **Note: This step is only required if exporting out-of-pocket expenses from Expensify to Sage Intacct as Expense Reports.** To set up employees in Sage Intacct: @@ -135,7 +135,7 @@ To set up employees in Sage Intacct: 1. Fill in their Primary Email Address along with any other required information -## Step 4: Set up Expense Types in Sage Intacct +# Step 4: Set up Expense Types in Sage Intacct **Note: This step is only required if exporting out-of-pocket expenses from Expensify to Sage Intacct as Expense Reports.** Expense Types provide a user-friendly way to display the names of your expense accounts to your employees. To set up expense types in Sage Intacct: @@ -150,20 +150,20 @@ Expense Types provide a user-friendly way to display the names of your expense a - **Description** - **Account Number** (from your General Ledger) -## Step 5: Enable Customization Services +# Step 5: Enable Customization Services **Note:** If you already have Platform Services enabled, you can skip this step. To enable Customization Services, go to **Company > Subscriptions > Customization Services**. -## Step 6: Download the Expensify Package +# Step 6: Download the Expensify Package 1. In Expensify, go to Settings > Workspaces 1. Click into the workspace where you'd like to connect to Sage Intacct - If you already use Expensify, you can optionally create a test workspace by clicking **New Workspace** at the top-right of the Workspaces page. A test workspace allows you to have a sandbox environment for testing before implementing the integration live. 1. Go to **Connections > Sage Intacct > Connect to Sage Intacct** 1. Select **Download Package** (You only need to download the file; we’ll upload it from your Downloads folder later) -## Step 7: Upload Package in Sage Intacct +# Step 7: Upload Package in Sage Intacct If you use Customization Services: 1. Go to **Customization Services > Custom Packages > New Package** @@ -177,20 +177,20 @@ If you use Platform Services: 1. Click **Import** -## Step 8: Add Web Services Authorization +# Step 8: Add Web Services Authorization 1. Go to **Company > Company Info > Security** in Sage Intacct and click **Edit** 2. Scroll down to **Web Services Authorizations** and add “expensify” (all lower case) as a Sender ID -## Step 9: Enter Credentials and Connect Expensify and Sage Intacct +# Step 9: Enter Credentials and Connect Expensify and Sage Intacct 1. In Expensify, go to **Settings > Workspaces > [Workspace Name] > Accounting** 1. Click **Set up** next to Sage Intacct 1. Enter the credentials you set for your web services user in Step 1 1. Click **Confirm** -## FAQ +# FAQ -### Why wasn't my report automatically exported to Sage Intacct? +## Why wasn't my report automatically exported to Sage Intacct? There are a number of factors that can cause auto-export to fail. If this happens, you will find the specific export error in the report comments for the report that failed to export. Once you’ve resolved any errors, you can manually export the report to Sage Intacct. -### Can I export negative expenses to Sage Intacct? +## Can I export negative expenses to Sage Intacct? Yes, you can export negative expenses to Sage Intacct. If you are exporting out-of-pocket expenses as expense reports, then the total of each exported report cannot be negative. 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 664b7a4a5dde..8dd29aa08d95 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.47 + 9.0.49 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.47.2 + 9.0.49.1 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index a48e35b39e03..bbc9daf9ea45 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.47 + 9.0.49 CFBundleSignature ???? CFBundleVersion - 9.0.47.2 + 9.0.49.1 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 3c5931186ff5..7528abbc3bbf 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.47 + 9.0.49 CFBundleVersion - 9.0.47.2 + 9.0.49.1 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5a5c2bb90562..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 @@ -2360,7 +2373,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.143): + - RNLiveMarkdown (0.1.164): - DoubleConversion - glog - hermes-engine @@ -2380,9 +2393,9 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/common (= 0.1.143) + - RNLiveMarkdown/newarch (= 0.1.164) - Yoga - - RNLiveMarkdown/common (0.1.143): + - RNLiveMarkdown/newarch (0.1.164): - DoubleConversion - glog - hermes-engine @@ -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 @@ -3229,7 +3242,7 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 8781e2529230a1bc3ea8d75e5c3cd071b6c6aed7 RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 - RNLiveMarkdown: e44918843c2638692348f39eafc275698baf0444 + RNLiveMarkdown: b2bd97a6f1206be16cf6536c092fe39f986aca34 RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: 460d6ff97ae49c7d5708c3212c6521697c36a0c4 RNPermissions: 0b1429b55af59d1d08b75a8be2459f65a8ac3f28 diff --git a/package-lock.json b/package-lock.json index 37d9301d710b..286f04eaf893 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "new.expensify", - "version": "9.0.47-2", + "version": "9.0.49-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.47-2", + "version": "9.0.49-1", "hasInstallScript": true, "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.143", + "@expensify/react-native-live-markdown": "0.1.164", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -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", @@ -3635,9 +3635,9 @@ } }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.143", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.143.tgz", - "integrity": "sha512-hZXYjKyTl/b2p7Ig9qhoB7cfVtTTcoE2cWvea8NJT3f5ZYckdyHDAgHI4pg0S0N68jP205Sk5pzqlltZUpZk5w==", + "version": "0.1.164", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.164.tgz", + "integrity": "sha512-x1/Oa+I1AI82xWEFYd2kSkSj4rZ1q2JG4aEDomUHSqcNjuQetQPw9kVFN5DaLHt0Iu0iKEUrXIhy5LpMSHJQLg==", "workspaces": [ "parser", "example", @@ -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 77ca26a5a562..136131d411f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.47-2", + "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.", @@ -66,7 +66,7 @@ }, "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.143", + "@expensify/react-native-live-markdown": "0.1.164", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -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 73bd75df47bc..35282678e287 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -136,12 +136,24 @@ const onboardingEmployerOrSubmitMessage: OnboardingMessageType = { type OnboardingPurposeType = ValueOf; +type OnboardingCompanySizeType = ValueOf; + +type OnboardingAccountingType = ValueOf | null; + const onboardingInviteTypes = { IOU: 'iou', INVOICE: 'invoice', CHAT: 'chat', } as const; +const onboardingCompanySize = { + MICRO: '1-10', + SMALL: '11-50', + MEDIUM_SMALL: '51-100', + MEDIUM: '101-1000', + LARGE: '1001+', +} as const; + type OnboardingInviteType = ValueOf; type OnboardingTaskType = { @@ -193,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, @@ -778,6 +794,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', @@ -1517,6 +1535,18 @@ const CONST = { }, QUICKBOOKS_ONLINE: 'quickbooksOnline', + QUICKBOOKS_DESKTOP_CONFIG: { + EXPORT_DATE: 'exportDate', + EXPORTER: 'exporter', + MARK_CHECKS_TO_BE_PRINTED: 'markChecksToBePrinted', + REIMBURSABLE_ACCOUNT: 'reimbursableAccount', + REIMBURSABLE: 'reimbursable', + ENABLE_NEW_CATEGORIES: 'enableNewCategories', + MAPPINGS: { + CLASSES: 'classes', + }, + }, + QUICKBOOKS_CONFIG: { ENABLE_NEW_CATEGORIES: 'enableNewCategories', SYNC_CLASSES: 'syncClasses', @@ -1625,6 +1655,13 @@ const CONST = { JOURNAL_ENTRY: 'journal_entry', }, + QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE: { + VENDOR_BILL: 'VENDOR_BILL', + CHECK: 'CHECK', + JOURNAL_ENTRY: 'JOURNAL_ENTRY', + NOTHING: 'NOTHING', + }, + SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE: { EXPENSE_REPORT: 'EXPENSE_REPORT', VENDOR_BILL: 'VENDOR_BILL', @@ -1895,6 +1932,12 @@ const CONST = { VENDOR_BILL: 'bill', }, + QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE: { + CREDIT_CARD: 'CREDIT_CARD_CHARGE', + JOURNAL_ENTRY: 'JOURNAL_ENTRY', + VENDOR_BILL: 'VENDOR_BILL', + }, + MISSING_PERSONAL_DETAILS_INDEXES: { MAPPING: { LEGAL_NAME: 0, @@ -2566,17 +2609,41 @@ const CONST = { CARD_TITLE_INPUT_LIMIT: 255, }, COMPANY_CARDS: { + 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', + BREX: 'Brex', + CAPITAL_ONE: 'Capital One', + CHASE: 'Chase', + CITI_BANK: 'Citibank', + STRIPE: 'Stripe', + 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', @@ -4847,6 +4914,7 @@ const CONST = { SELECTABLE_ONBOARDING_CHOICES: {...selectableOnboardingChoices}, ONBOARDING_SIGNUP_QUALIFIERS: {...signupQualifiers}, ONBOARDING_INVITE_TYPES: {...onboardingInviteTypes}, + ONBOARDING_COMPANY_SIZE: {...onboardingCompanySize}, ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE: 'What would you like to do with this expense?', ONBOARDING_CONCIERGE: { [onboardingChoices.EMPLOYER]: @@ -5969,6 +6037,7 @@ const CONST = { KEYWORD: 'keyword', IN: 'in', }, + EMPTY_VALUE: 'none', }, REFERRER: { @@ -6201,6 +6270,19 @@ type FeedbackSurveyOptionID = ValueOf; type CancellationType = ValueOf; -export type {Country, IOUAction, IOUType, RateAndUnit, OnboardingPurposeType, IOURequestType, SubscriptionType, FeedbackSurveyOptionID, CancellationType, OnboardingInviteType}; +export type { + Country, + IOUAction, + IOUType, + RateAndUnit, + OnboardingPurposeType, + OnboardingCompanySizeType, + IOURequestType, + SubscriptionType, + FeedbackSurveyOptionID, + CancellationType, + OnboardingInviteType, + OnboardingAccountingType, +}; export default CONST; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 2a325bb4a74f..0b69fe9be80b 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1,6 +1,6 @@ import type {ValueOf} from 'type-fest'; import type CONST from './CONST'; -import type {OnboardingPurposeType} from './CONST'; +import type {OnboardingCompanySizeType, OnboardingPurposeType} from './CONST'; import type * as FormTypes from './types/form'; import type * as OnyxTypes from './types/onyx'; import type Onboarding from './types/onyx/Onboarding'; @@ -342,6 +342,9 @@ const ONYXKEYS = { /** Onboarding policyID selected by the user during Onboarding flow */ ONBOARDING_POLICY_ID: 'onboardingPolicyID', + /** Onboarding company size selected by the user during Onboarding flow */ + ONBOARDING_COMPANY_SIZE: 'onboardingCompanySize', + /** Onboarding Purpose selected by the user during Onboarding flow */ ONBOARDING_ADMINS_CHAT_REPORT_ID: 'onboardingAdminsChatReportID', @@ -557,8 +560,6 @@ const ONYXKEYS = { DISPLAY_NAME_FORM_DRAFT: 'displayNameFormDraft', ONBOARDING_PERSONAL_DETAILS_FORM: 'onboardingPersonalDetailsForm', ONBOARDING_PERSONAL_DETAILS_FORM_DRAFT: 'onboardingPersonalDetailsFormDraft', - ONBOARDING_PERSONAL_WORK: 'onboardingWorkForm', - ONBOARDING_PERSONAL_WORK_DRAFT: 'onboardingWorkFormDraft', ROOM_NAME_FORM: 'roomNameForm', ROOM_NAME_FORM_DRAFT: 'roomNameFormDraft', REPORT_DESCRIPTION_FORM: 'reportDescriptionForm', @@ -721,7 +722,6 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm; [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; [ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM]: FormTypes.DisplayNameForm; - [ONYXKEYS.FORMS.ONBOARDING_PERSONAL_WORK]: FormTypes.WorkForm; [ONYXKEYS.FORMS.ROOM_NAME_FORM]: FormTypes.RoomNameForm; [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM]: FormTypes.ReportDescriptionForm; [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: FormTypes.LegalNameForm; @@ -960,6 +960,7 @@ type OnyxValuesMapping = { [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; [ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: OnboardingPurposeType; + [ONYXKEYS.ONBOARDING_COMPANY_SIZE]: OnboardingCompanySizeType; [ONYXKEYS.ONBOARDING_CUSTOM_CHOICES]: OnboardingPurposeType[] | []; [ONYXKEYS.ONBOARDING_ERROR_MESSAGE]: string; [ONYXKEYS.ONBOARDING_POLICY_ID]: string; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 47fabe3c421c..c2c404d7fea5 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -138,6 +138,14 @@ const ROUTES = { route: 'settings/security/delegate/:login/role/:role', getRoute: (login: string, role?: string) => `settings/security/delegate/${encodeURIComponent(login)}/role/${role}` as const, }, + SETTINGS_UPDATE_DELEGATE_ROLE: { + route: 'settings/security/delegate/:login/update-role/:currentRole', + getRoute: (login: string, currentRole: string) => `settings/security/delegate/${encodeURIComponent(login)}/update-role/${currentRole}` as const, + }, + SETTINGS_UPDATE_DELEGATE_ROLE_MAGIC_CODE: { + route: 'settings/security/delegate/:login/update-role/:role/magic-code', + getRoute: (login: string, role: string) => `settings/security/delegate/${encodeURIComponent(login)}/update-role/${role}/magic-code` as const, + }, SETTINGS_DELEGATE_CONFIRM: { route: 'settings/security/delegate/:login/role/:role/confirm', getRoute: (login: string, role: string) => `settings/security/delegate/${encodeURIComponent(login)}/role/${role}/confirm` as const, @@ -303,8 +311,12 @@ const ROUTES = { }, ATTACHMENTS: { route: 'attachment', - getRoute: (reportID: string, type: ValueOf, url: string, accountID?: number) => - `attachment?source=${encodeURIComponent(url)}&type=${type}${reportID ? `&reportID=${reportID}` : ''}${accountID ? `&accountID=${accountID}` : ''}` as const, + getRoute: (reportID: string, type: ValueOf, url: string, accountID?: number, isAuthTokenRequired?: boolean) => { + const reportParam = reportID ? `&reportID=${reportID}` : ''; + const accountParam = accountID ? `&accountID=${accountID}` : ''; + const authTokenParam = isAuthTokenRequired ? '&isAuthTokenRequired=true' : ''; + return `attachment?source=${encodeURIComponent(url)}&type=${type}${reportParam}${accountParam}${authTokenParam}` as const; + }, }, REPORT_PARTICIPANTS: { route: 'r/:reportID/participants', @@ -658,6 +670,26 @@ 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, + }, + POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/export/out-of-pocket-expense/account-select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/export/out-of-pocket-expense/account-select` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/export/out-of-pocket-expense/entity-select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/export/out-of-pocket-expense/entity-select` as const, + }, POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT: { route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/export', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/export` as const, @@ -678,6 +710,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, @@ -1179,9 +1223,13 @@ const ROUTES = { route: 'onboarding/personal-details', getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding/personal-details`, backTo), }, - ONBOARDING_WORK: { - route: 'onboarding/work', - getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding/work`, backTo), + ONBOARDING_EMPLOYEES: { + route: 'onboarding/employees', + getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding/employees`, backTo), + }, + ONBOARDING_ACCOUNTING: { + route: 'onboarding/accounting', + getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding/accounting`, backTo), }, ONBOARDING_PURPOSE: { route: 'onboarding/purpose', @@ -1192,8 +1240,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 6e9efd15e910..3da0f86d651e 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -135,6 +135,8 @@ const SCREENS = { DELEGATE_ROLE: 'Settings_Delegate_Role', DELEGATE_CONFIRM: 'Settings_Delegate_Confirm', DELEGATE_MAGIC_CODE: 'Settings_Delegate_Magic_Code', + UPDATE_DELEGATE_ROLE: 'Settings_Delegate_Update_Role', + UPDATE_DELEGATE_ROLE_MAGIC_CODE: 'Settings_Delegate_Update_Magic_Code', }, }, SAVE_THE_WORLD: { @@ -315,11 +317,19 @@ 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', QUICKBOOKS_DESKTOP_EXPORT: 'Workspace_Accounting_Quickbooks_Desktop_Export', QUICKBOOKS_DESKTOP_SETUP_MODAL: 'Policy_Accouting_Quickbooks_Desktop_Setup_Modal', 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', @@ -528,7 +538,8 @@ const SCREENS = { ONBOARDING: { PERSONAL_DETAILS: 'Onboarding_Personal_Details', PURPOSE: 'Onboarding_Purpose', - WORK: 'Onboarding_Work', + EMPLOYEES: 'Onboarding_Employees', + ACCOUNTING: 'Onboarding_Accounting', }, WELCOME_VIDEO: { diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx index 9b5d21743bef..8ccab44a2cb9 100644 --- a/src/components/AccountSwitcher.tsx +++ b/src/components/AccountSwitcher.tsx @@ -9,6 +9,7 @@ import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import {clearDelegatorErrors, connect, disconnect} from '@libs/actions/Delegate'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; @@ -22,10 +23,8 @@ import Avatar from './Avatar'; import ConfirmModal from './ConfirmModal'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; -import type {MenuItemProps} from './MenuItem'; -import MenuItemList from './MenuItemList'; -import type {MenuItemWithLink} from './MenuItemList'; -import Popover from './Popover'; +import type {PopoverMenuItem} from './PopoverMenu'; +import PopoverMenu from './PopoverMenu'; import {PressableWithFeedback} from './Pressable'; import Text from './Text'; @@ -41,6 +40,7 @@ function AccountSwitcher() { const [session] = useOnyx(ONYXKEYS.SESSION); const [user] = useOnyx(ONYXKEYS.USER); const buttonRef = useRef(null); + const {windowHeight} = useWindowDimensions(); const [shouldShowDelegatorMenu, setShouldShowDelegatorMenu] = useState(false); const [shouldShowOfflineModal, setShouldShowOfflineModal] = useState(false); @@ -49,10 +49,14 @@ function AccountSwitcher() { const isActingAsDelegate = !!account?.delegatedAccess?.delegate ?? false; const canSwitchAccounts = canUseNewDotCopilot && (delegators.length > 0 || isActingAsDelegate); - const createBaseMenuItem = (personalDetails: PersonalDetails | undefined, errors?: Errors, additionalProps: MenuItemWithLink = {}): MenuItemWithLink => { + const createBaseMenuItem = ( + personalDetails: PersonalDetails | undefined, + errors?: Errors, + additionalProps: Partial> = {}, + ): PopoverMenuItem => { const error = Object.values(errors ?? {}).at(0) ?? ''; return { - title: personalDetails?.displayName ?? personalDetails?.login, + text: personalDetails?.displayName ?? personalDetails?.login ?? '', description: Str.removeSMSDomain(personalDetails?.login ?? ''), avatarID: personalDetails?.accountID ?? -1, icon: personalDetails?.avatar ?? '', @@ -66,14 +70,12 @@ function AccountSwitcher() { }; }; - const menuItems = (): MenuItemProps[] => { + const menuItems = (): PopoverMenuItem[] => { const currentUserMenuItem = createBaseMenuItem(currentUserPersonalDetails, undefined, { - wrapperStyle: [styles.buttonDefaultBG], - focused: true, shouldShowRightIcon: true, iconRight: Expensicons.Checkmark, success: true, - key: `${currentUserPersonalDetails?.login}-current`, + isSelected: true, }); if (isActingAsDelegate) { @@ -89,34 +91,32 @@ function AccountSwitcher() { return [ createBaseMenuItem(delegatePersonalDetails, error, { - onPress: () => { + onSelected: () => { if (isOffline) { Modal.close(() => setShouldShowOfflineModal(true)); return; } disconnect(); }, - key: `${delegateEmail}-delegate`, }), currentUserMenuItem, ]; } - const delegatorMenuItems: MenuItemProps[] = delegators + const delegatorMenuItems: PopoverMenuItem[] = delegators .filter(({email}) => email !== currentUserPersonalDetails.login) - .map(({email, role, errorFields}, index) => { + .map(({email, role, errorFields}) => { const error = ErrorUtils.getLatestErrorField({errorFields}, 'connect'); const personalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(email); return createBaseMenuItem(personalDetails, error, { badgeText: translate('delegate.role', {role}), - onPress: () => { + onSelected: () => { if (isOffline) { Modal.close(() => setShouldShowOfflineModal(true)); return; } connect(email); }, - key: `${email}-${index}`, }); }); @@ -181,23 +181,27 @@ function AccountSwitcher() { {canSwitchAccounts && ( - { setShouldShowDelegatorMenu(false); clearDelegatorErrors(); }} anchorRef={buttonRef} - anchorPosition={styles.accountSwitcherAnchorPosition} - > - - {translate('delegate.switchAccount')} - - - + anchorPosition={CONST.POPOVER_ACCOUNT_SWITCHER_POSITION} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, + }} + menuItems={menuItems()} + headerText={translate('delegate.switchAccount')} + containerStyles={[{maxHeight: windowHeight / 2}, styles.pb0, styles.mw100, shouldUseNarrowLayout ? {} : styles.wFitContent]} + headerStyles={styles.pt0} + innerContainerStyle={styles.pb0} + scrollContainerStyle={styles.pb4} + shouldUseScrollView + shouldUpdateFocusedIndex={false} + /> )} ; -}; - -type BaseAnchorForAttachmentsOnlyProps = AnchorForAttachmentsOnlyProps & - BaseAnchorForAttachmentsOnlyOnyxProps & { - /** Press in handler for the link */ - onPressIn?: () => void; +type BaseAnchorForAttachmentsOnlyProps = AnchorForAttachmentsOnlyProps & { + /** Press in handler for the link */ + onPressIn?: () => void; - /** Press out handler for the link */ - onPressOut?: () => void; - }; + /** Press out handler for the link */ + onPressOut?: () => void; +}; -function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', download, onPressIn, onPressOut}: BaseAnchorForAttachmentsOnlyProps) { +function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', onPressIn, onPressOut}: BaseAnchorForAttachmentsOnlyProps) { const sourceURLWithAuth = addEncryptedAuthTokenToURL(source); const sourceID = (source.match(CONST.REGEX.ATTACHMENT_ID) ?? [])[1]; - + const [download] = useOnyx(`${ONYXKEYS.COLLECTION.DOWNLOAD}${sourceID}`); const {isOffline} = useNetwork(); const styles = useThemeStyles(); @@ -78,11 +70,4 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow BaseAnchorForAttachmentsOnly.displayName = 'BaseAnchorForAttachmentsOnly'; -export default withOnyx({ - download: { - key: ({source}) => { - const sourceID = (source?.match(CONST.REGEX.ATTACHMENT_ID) ?? [])[1]; - return `${ONYXKEYS.COLLECTION.DOWNLOAD}${sourceID}`; - }, - }, -})(BaseAnchorForAttachmentsOnly); +export default BaseAnchorForAttachmentsOnly; diff --git a/src/components/Composer/index.native.tsx b/src/components/Composer/implementation/index.native.tsx similarity index 99% rename from src/components/Composer/index.native.tsx rename to src/components/Composer/implementation/index.native.tsx index ffceccc84c8d..9f237dd02424 100644 --- a/src/components/Composer/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -5,6 +5,7 @@ import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {NativeSyntheticEvent, TextInput, TextInputChangeEventData, TextInputPasteEventData} from 'react-native'; import {StyleSheet} from 'react-native'; import type {FileObject} from '@components/AttachmentModal'; +import type {ComposerProps} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; @@ -17,7 +18,6 @@ import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullCompo import * as EmojiUtils from '@libs/EmojiUtils'; import * as FileUtils from '@libs/fileDownload/FileUtils'; import CONST from '@src/CONST'; -import type {ComposerProps} from './types'; const excludeNoStyles: Array = []; const excludeReportMentionStyle: Array = ['mentionReport']; 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 15087193a593..d9474effa478 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -1,409 +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'; -import {flushSync} from 'react-dom'; -// eslint-disable-next-line no-restricted-imports -import type {NativeSyntheticEvent, Text as RNText, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData, ViewStyle} from 'react-native'; -import {DeviceEventEmitter, StyleSheet, View} from 'react-native'; -import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; -import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; -import Text from '@components/Text'; -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 * 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'; -/** - * Retrieves the characters from the specified cursor position up to the next space or new line. - * - * @param inputString - The input string. - * @param cursorPosition - The position of the cursor within the input string. - * @returns - The substring from the cursor position up to the next space or new line. - * If no space or new line is found, returns the substring from the cursor position to the end of the input string. - */ -const getNextChars = (inputString: string, cursorPosition: number): string => { - // Get the substring starting from the cursor position - const subString = inputString.substring(cursorPosition); - - // Find the index of the next space or new line character - const spaceIndex = subString.search(/[ \n]/); - - if (spaceIndex === -1) { - return subString; - } - - // If there is a space or new line, return the substring up to the space or new line - return subString.substring(0, spaceIndex); -}; - -const excludeNoStyles: Array = []; -const excludeReportMentionStyle: Array = ['mentionReport']; - -// 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 textRef = useRef(null); - const textInput = useRef(null); - const [selection, setSelection] = useState< - | { - start: number; - end?: number; - positionX?: number; - positionY?: number; - } - | undefined - >({ - start: selectionProp.start, - end: selectionProp.end, - }); - const [caretContent, setCaretContent] = useState(''); - const [valueBeforeCaret, setValueBeforeCaret] = useState(''); - const [textInputWidth, setTextInputWidth] = useState(''); - 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; - if (shouldCalculateCaretPosition && isRendered) { - // we do flushSync to make sure that the valueBeforeCaret is updated before we calculate the caret position to receive a proper position otherwise we will calculate position for the previous state - flushSync(() => { - setValueBeforeCaret((webEvent.target as HTMLInputElement).value.slice(0, webEvent.nativeEvent.selection.start)); - setCaretContent(getNextChars(value ?? '', webEvent.nativeEvent.selection.start)); - }); - const selectionValue = { - start: webEvent.nativeEvent.selection.start, - end: webEvent.nativeEvent.selection.end, - positionX: (textRef.current?.offsetLeft ?? 0) - CONST.SPACE_CHARACTER_WIDTH, - positionY: textRef.current?.offsetTop, - }; - - 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 renderElementForCaretPosition = ( - - - {`${valueBeforeCaret} `} - - {`${caretContent}`} - - - - ); - - 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) => { - setTextInputWidth(`${e.nativeEvent.contentSize.width}px`); - updateIsFullComposerAvailable({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles); - }} - disabled={isDisabled} - onKeyPress={handleKeyPress} - /> - {shouldCalculateCaretPosition && renderElementForCaretPosition} - - ); -} - -Composer.displayName = 'Composer'; - -export default React.forwardRef(Composer); +export default Composer; diff --git a/src/components/ContextMenuItem.tsx b/src/components/ContextMenuItem.tsx index fe7be45e1b57..c57c10e2c2b9 100644 --- a/src/components/ContextMenuItem.tsx +++ b/src/components/ContextMenuItem.tsx @@ -133,7 +133,7 @@ function ContextMenuItem( title={itemText} icon={itemIcon} onPress={triggerPressAndUpdateSuccess} - wrapperStyle={[styles.pr9, wrapperStyle]} + wrapperStyle={[styles.pr8, wrapperStyle]} success={!isThrottledButtonActive} description={description} descriptionTextStyle={styles.breakWord} 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/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index 564d2eeb8c75..d831fca562c3 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -8,6 +8,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isReceiptError} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; import * as Localize from '@libs/Localize'; +import CONST from '@src/CONST'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; @@ -53,6 +54,60 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles}: DotIndica const isErrorMessage = type === 'error'; + const renderMessage = (message: string | ReceiptError, index: number) => { + if (isReceiptError(message)) { + return ( + + {Localize.translateLocal('iou.error.receiptFailureMessage')} + { + fileDownload(message.source, message.filename); + }} + > + {Localize.translateLocal('iou.error.saveFileMessage')} + + + {Localize.translateLocal('iou.error.loseFileMessage')} + + ); + } + + if (message === CONST.COMPANY_CARDS.CONNECTION_ERROR) { + return ( + + {Localize.translateLocal('workspace.companyCards.brokenConnectionErrorFirstPart')} + { + // TODO: re-navigate the user to the bank’s website to re-authenticate https://github.com/Expensify/App/issues/50448 + }} + > + {Localize.translateLocal('workspace.companyCards.brokenConnectionErrorLink')} + + + {Localize.translateLocal('workspace.companyCards.brokenConnectionErrorSecondPart')} + + ); + } + + return ( + + {message} + + ); + }; + return ( @@ -61,36 +116,7 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles}: DotIndica fill={isErrorMessage ? theme.danger : theme.success} /> - - {uniqueMessages.map((message, i) => - isReceiptError(message) ? ( - - {Localize.translateLocal('iou.error.receiptFailureMessage')} - { - fileDownload(message.source, message.filename); - }} - > - {Localize.translateLocal('iou.error.saveFileMessage')} - - - {Localize.translateLocal('iou.error.loseFileMessage')} - - ) : ( - - {message} - - ), - )} - + {uniqueMessages.map(renderMessage)} ); } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index bbf7dfcb0c6a..2d52d26f9af6 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -1,5 +1,5 @@ import React, {memo} from 'react'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import type {CustomRendererProps, TBlock} from 'react-native-render-html'; import {AttachmentContext} from '@components/AttachmentContext'; @@ -90,7 +90,7 @@ function ImageRenderer({tnode}: ImageRendererProps) { return; } - const route = ROUTES.ATTACHMENTS?.getRoute(reportID ?? '-1', type, source, accountID); + const route = ROUTES.ATTACHMENTS?.getRoute(reportID ?? '-1', type, source, accountID, isAttachmentOrReceipt); Navigation.navigate(route); }} onLongPress={(event) => { @@ -114,13 +114,20 @@ function ImageRenderer({tnode}: ImageRendererProps) { ImageRenderer.displayName = 'ImageRenderer'; -export default withOnyx({ - user: { - key: ONYXKEYS.USER, - }, -})( - memo( - ImageRenderer, - (prevProps, nextProps) => prevProps.tnode.attributes === nextProps.tnode.attributes && prevProps.user?.shouldUseStagingServer === nextProps.user?.shouldUseStagingServer, - ), +const ImageRendererMemorize = memo( + ImageRenderer, + (prevProps, nextProps) => prevProps.tnode.attributes === nextProps.tnode.attributes && prevProps.user?.shouldUseStagingServer === nextProps.user?.shouldUseStagingServer, ); + +function ImageRendererWrapper(props: CustomRendererProps) { + const [user] = useOnyx(ONYXKEYS.USER); + return ( + + ); +} + +export default ImageRendererWrapper; diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index d569d091d618..e3606e758742 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -1,7 +1,15 @@ import AmexCompanyCards from '@assets/images/companyCards/amex.svg'; import AmexCardCompanyCardDetail from '@assets/images/companyCards/card-amex.svg'; +import BankOfAmericaCompanyCardDetail from '@assets/images/companyCards/card-bofa.svg'; +import BrexCompanyCardDetail from '@assets/images/companyCards/card-brex.svg'; +import CapitalOneCompanyCardDetail from '@assets/images/companyCards/card-capitalone.svg'; +import ChaseCompanyCardDetail from '@assets/images/companyCards/card-chase.svg'; +import CitibankCompanyCardDetail from '@assets/images/companyCards/card-citi.svg'; import MasterCardCompanyCardDetail from '@assets/images/companyCards/card-mastercard.svg'; +import StripeCompanyCardDetail from '@assets/images/companyCards/card-stripe.svg'; import VisaCompanyCardDetail from '@assets/images/companyCards/card-visa.svg'; +import WellsFargoCompanyCardDetail from '@assets/images/companyCards/card-wellsfargo.svg'; +import OtherCompanyCardDetail from '@assets/images/companyCards/card=-generic.svg'; import CompanyCardsEmptyState from '@assets/images/companyCards/emptystate__card-pos.svg'; import MasterCardCompanyCards from '@assets/images/companyCards/mastercard.svg'; import CompanyCardsPendingState from '@assets/images/companyCards/pendingstate_laptop-with-hourglass-and-cards.svg'; @@ -246,4 +254,12 @@ export { MasterCardCompanyCardDetail, AmexCardCompanyCardDetail, TurtleInShell, + BankOfAmericaCompanyCardDetail, + BrexCompanyCardDetail, + CapitalOneCompanyCardDetail, + ChaseCompanyCardDetail, + CitibankCompanyCardDetail, + OtherCompanyCardDetail, + StripeCompanyCardDetail, + WellsFargoCompanyCardDetail, }; 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/ReportWelcomeText.tsx b/src/components/ReportWelcomeText.tsx index 0b314ac8b2a3..3c38c9f4c4a3 100644 --- a/src/components/ReportWelcomeText.tsx +++ b/src/components/ReportWelcomeText.tsx @@ -40,7 +40,7 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { const isInvoiceRoom = ReportUtils.isInvoiceRoom(report); const isSystemChat = ReportUtils.isSystemChat(report); const isDefault = !(isChatRoom || isPolicyExpenseChat || isSelfDM || isInvoiceRoom || isSystemChat); - const participantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report, undefined, undefined, true); + const participantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report, undefined, true, true); const isMultipleParticipant = participantAccountIDs.length > 1; const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails), isMultipleParticipant); const welcomeMessage = SidebarUtils.getWelcomeMessage(report, policy); diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx index 50b84ae68469..30825ed3bfba 100644 --- a/src/components/Search/SearchContext.tsx +++ b/src/components/Search/SearchContext.tsx @@ -13,6 +13,8 @@ const defaultSearchContext = { clearSelectedTransactions: () => {}, shouldShowStatusBarLoading: false, setShouldShowStatusBarLoading: () => {}, + lastSearchType: undefined, + setLastSearchType: () => {}, }; const Context = React.createContext(defaultSearchContext); @@ -69,6 +71,7 @@ function SearchContextProvider({children}: ChildrenProps) { ); const [shouldShowStatusBarLoading, setShouldShowStatusBarLoading] = useState(false); + const [lastSearchType, setLastSearchType] = useState(undefined); const searchContext = useMemo( () => ({ @@ -78,8 +81,10 @@ function SearchContextProvider({children}: ChildrenProps) { clearSelectedTransactions, shouldShowStatusBarLoading, setShouldShowStatusBarLoading, + lastSearchType, + setLastSearchType, }), - [searchContextData, setCurrentSearchHash, setSelectedTransactions, clearSelectedTransactions, shouldShowStatusBarLoading], + [searchContextData, setCurrentSearchHash, setSelectedTransactions, clearSelectedTransactions, shouldShowStatusBarLoading, lastSearchType, setLastSearchType], ); return {children}; 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 f50540346e6d..4c383021645f 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -1,4 +1,4 @@ -import React, {useMemo, useState} from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import {InteractionManager, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -20,6 +20,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as SearchActions from '@libs/actions/Search'; +import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; import * as SearchUtils from '@libs/SearchUtils'; @@ -39,12 +40,14 @@ import type {SearchQueryJSON} from './types'; type HeaderWrapperProps = Pick & { text: string; + value: string; isCannedQuery: boolean; + onSubmit: () => void; + setValue: (input: string) => void; }; -function HeaderWrapper({icon, children, text, isCannedQuery}: HeaderWrapperProps) { +function HeaderWrapper({icon, children, text, value, isCannedQuery, onSubmit, setValue}: HeaderWrapperProps) { const styles = useThemeStyles(); - // If the icon is present, the header bar should be taller and use different font. const isCentralPaneSettings = !!icon; @@ -69,10 +72,11 @@ function HeaderWrapper({icon, children, text, isCannedQuery}: HeaderWrapperProps ) : ( {}} + value={value} + setValue={setValue} + onSubmit={onSubmit} updateSearch={() => {}} - disabled + autoFocus={false} isFullWidth wrapperStyle={[styles.searchRouterInputResults, styles.br2]} wrapperFocusedStyle={styles.searchRouterInputResultsFocused} @@ -124,17 +128,25 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) { const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const taxRates = getAllTaxRates(); const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST); + const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); + const [policyTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); const [isDeleteExpensesConfirmModalVisible, setIsDeleteExpensesConfirmModalVisible] = useState(false); const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); - const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); - const {status, type} = queryJSON; const isCannedQuery = SearchUtils.isCannedSearchQuery(queryJSON); + const headerText = isCannedQuery ? translate(getHeaderContent(type).titleText) : SearchUtils.getSearchHeaderTitle(queryJSON, personalDetails, cardList, reports, taxRates); + const [inputValue, setInputValue] = useState(headerText); + + useEffect(() => { + setInputValue(headerText); + }, [headerText]); + + const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); const headerIcon = getHeaderContent(type).icon; - const headerText = isCannedQuery ? translate(getHeaderContent(type).titleText) : SearchUtils.getSearchHeaderTitle(queryJSON, personalDetails, cardList, reports, taxRates); const handleDeleteExpenses = () => { if (selectedTransactionsKeys.length === 0) { @@ -315,18 +327,36 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) { } const onPress = () => { - const filterFormValues = SearchUtils.buildFilterFormValuesFromQuery(queryJSON); + const filterFormValues = SearchUtils.buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTagsLists, currencyList, personalDetails, cardList, reports, taxRates); SearchActions.updateAdvancedFilters(filterFormValues); Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS); }; + const onSubmit = () => { + if (!inputValue) { + return; + } + const inputQueryJSON = SearchUtils.buildSearchQueryJSON(inputValue); + if (inputQueryJSON) { + const standardizedQuery = SearchUtils.standardizeQueryJSON(inputQueryJSON, cardList, taxRates); + const query = SearchUtils.buildSearchQueryString(standardizedQuery); + SearchActions.clearAllFilters(); + Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); + } else { + Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} user query failed to parse`, inputValue, false); + } + }; + return ( <> {headerButtonsOptions.length > 0 ? ( diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 8f5ad55bc0c9..924cf366415a 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -14,6 +14,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import Log from '@libs/Log'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; import * as SearchUtils from '@libs/SearchUtils'; import Navigation from '@navigation/Navigation'; @@ -39,6 +40,9 @@ function SearchRouter() { const {isSearchRouterDisplayed, closeSearchRouter} = useSearchRouterContext(); const listRef = useRef(null); + const taxRates = getAllTaxRates(); + const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [textInputValue, debouncedInputValue, setTextInputValue] = useDebouncedState('', 500); const [userSearchQuery, setUserSearchQuery] = useState(undefined); const contextualReportID = useNavigationState, string | undefined>((state) => { @@ -144,7 +148,8 @@ function SearchRouter() { return; } closeSearchRouter(); - const queryString = SearchUtils.buildSearchQueryString(query); + const standardizedQuery = SearchUtils.standardizeQueryJSON(query, cardList, taxRates); + const queryString = SearchUtils.buildSearchQueryString(standardizedQuery); Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: queryString})); clearUserQuery(); }, diff --git a/src/components/Search/SearchRouter/SearchRouterInput.tsx b/src/components/Search/SearchRouter/SearchRouterInput.tsx index 460ff37c88b2..70e2e64074c9 100644 --- a/src/components/Search/SearchRouter/SearchRouterInput.tsx +++ b/src/components/Search/SearchRouter/SearchRouterInput.tsx @@ -19,6 +19,9 @@ type SearchRouterInputProps = { /** Callback to update search in SearchRouter */ updateSearch: (searchTerm: string) => void; + /** Callback invoked when the user submits the input */ + onSubmit?: () => void; + /** SearchRouterList ref for managing TextInput and SearchRouterList focus */ routerListRef?: RefObject; @@ -28,6 +31,9 @@ type SearchRouterInputProps = { /** Whether the input is disabled */ disabled?: boolean; + /** Whether the input should be focused */ + autoFocus?: boolean; + /** Any additional styles to apply */ wrapperStyle?: StyleProp; @@ -45,9 +51,11 @@ function SearchRouterInput({ value, setValue, updateSearch, + onSubmit = () => {}, routerListRef, isFullWidth, disabled = false, + autoFocus = true, wrapperStyle, wrapperFocusedStyle, rightComponent, @@ -70,15 +78,17 @@ function SearchRouterInput({ { setIsFocused(true); routerListRef?.current?.updateExternalTextInputFocus(true); diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 7d86ce1150d5..20ade90843d7 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -91,7 +91,6 @@ function SearchRouterList( const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const taxRates = getAllTaxRates(); const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); - const contextualQuery = `in:${reportForContextualSearch?.reportID}`; const sections: Array> = []; if (currentQuery?.inputQuery) { @@ -108,7 +107,7 @@ function SearchRouterList( }); } - if (reportForContextualSearch && !currentQuery?.inputQuery?.includes(contextualQuery)) { + if (reportForContextualSearch && !currentQuery?.inputQuery) { sections.push({ data: [ { diff --git a/src/components/Search/SearchStatusBar.tsx b/src/components/Search/SearchStatusBar.tsx index 674b2da14f22..afba2acc415c 100644 --- a/src/components/Search/SearchStatusBar.tsx +++ b/src/components/Search/SearchStatusBar.tsx @@ -17,12 +17,10 @@ import type {TranslationPaths} from '@src/languages/types'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type IconAsset from '@src/types/utils/IconAsset'; import {useSearchContext} from './SearchContext'; -import type {ChatSearchStatus, ExpenseSearchStatus, InvoiceSearchStatus, SearchStatus, TripSearchStatus} from './types'; +import type {ChatSearchStatus, ExpenseSearchStatus, InvoiceSearchStatus, SearchQueryJSON, TripSearchStatus} from './types'; type SearchStatusBarProps = { - type: SearchDataTypes; - status: SearchStatus; - policyID: string | undefined; + queryJSON: SearchQueryJSON; onStatusChange?: () => void; }; @@ -154,13 +152,13 @@ function getOptions(type: SearchDataTypes) { } } -function SearchStatusBar({type, status, policyID, onStatusChange}: SearchStatusBarProps) { +function SearchStatusBar({queryJSON, onStatusChange}: SearchStatusBarProps) { const {singleExecution} = useSingleExecution(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const theme = useTheme(); const {translate} = useLocalize(); - const options = getOptions(type); + const options = getOptions(queryJSON.type); const scrollRef = useRef(null); const isScrolledRef = useRef(false); const {shouldShowStatusBarLoading} = useSearchContext(); @@ -179,10 +177,10 @@ function SearchStatusBar({type, status, policyID, onStatusChange}: SearchStatusB {options.map((item, index) => { const onPress = singleExecution(() => { onStatusChange?.(); - const query = SearchUtils.buildCannedSearchQuery({type: item.type, status: item.status, policyID}); + const query = SearchUtils.buildSearchQueryString({...queryJSON, status: item.status}); Navigation.setParams({q: query}); }); - const isActive = status === item.status; + const isActive = queryJSON.status === item.status; const isFirstItem = index === 0; const isLastItem = index === options.length - 1; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 3c8d6260698c..670cfef54df8 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -91,8 +91,9 @@ function Search({queryJSON, onSearchListScroll, contentContainerStyle}: SearchPr const {isSmallScreenWidth, isLargeScreenWidth} = useResponsiveLayout(); const navigation = useNavigation>(); const lastSearchResultsRef = useRef>(); + const {setCurrentSearchHash, setSelectedTransactions, selectedTransactions, clearSelectedTransactions, setShouldShowStatusBarLoading, lastSearchType, setLastSearchType} = + useSearchContext(); const {selectionMode} = useMobileSelectionMode(false); - const {setCurrentSearchHash, setSelectedTransactions, selectedTransactions, clearSelectedTransactions, setShouldShowStatusBarLoading} = useSearchContext(); const [offset, setOffset] = useState(0); const {type, status, sortBy, sortOrder, hash} = queryJSON; @@ -101,6 +102,13 @@ function Search({queryJSON, onSearchListScroll, contentContainerStyle}: SearchPr const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const previousTransactions = usePrevious(transactions); + useEffect(() => { + if (!currentSearchResults?.search?.type) { + return; + } + setLastSearchType(currentSearchResults.search.type); + }, [lastSearchType, queryJSON, setLastSearchType, currentSearchResults]); + const canSelectMultiple = isSmallScreenWidth ? !!selectionMode?.isEnabled : true; useEffect(() => { @@ -184,8 +192,8 @@ function Search({queryJSON, onSearchListScroll, contentContainerStyle}: SearchPr useEffect(() => { /** We only want to display the skeleton for the status filters the first time we load them for a specific data type */ - setShouldShowStatusBarLoading(shouldShowLoadingState && searchResults?.search?.type !== type); - }, [searchResults?.search?.type, setShouldShowStatusBarLoading, shouldShowLoadingState, type]); + setShouldShowStatusBarLoading(shouldShowLoadingState && lastSearchType !== type); + }, [lastSearchType, setShouldShowStatusBarLoading, shouldShowLoadingState, type]); useEffect(() => { if (type === CONST.SEARCH.DATA_TYPES.CHAT) { @@ -379,11 +387,6 @@ function Search({queryJSON, onSearchListScroll, contentContainerStyle}: SearchPr onTurnOnSelectionMode={(item) => item && toggleTransaction(item)} onCheckboxPress={toggleTransaction} onSelectAll={toggleAllTransactions} - isSelected={(item) => - status !== CONST.SEARCH.STATUS.EXPENSE.ALL && SearchUtils.isReportListItemType(item) - ? item.transactions.some((transaction) => selectedTransactions[transaction.keyForList]?.isSelected) - : !!item.isSelected - } customListHeader={ !isLargeScreenWidth ? null : ( ) } + 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} diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 3d35190bf1a4..4f96090be9d0 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -41,6 +41,8 @@ type SearchContext = { clearSelectedTransactions: (hash?: number) => void; shouldShowStatusBarLoading: boolean; setShouldShowStatusBarLoading: (shouldShow: boolean) => void; + setLastSearchType: (type: string | undefined) => void; + lastSearchType: string | undefined; }; type ASTNode = { diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx index b95865b37d02..f4140f602f40 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -118,7 +118,7 @@ function BaseListItem({ )} - {!item.isSelected && !!item.brickRoadIndicator && shouldDisplayRBR && ( + {(!item.isSelected || item.canShowSeveralIndicators) && !!item.brickRoadIndicator && shouldDisplayRBR && ( ( onLongPressRow, shouldShowTextInput = !!textInputLabel || !!textInputIconLeft, shouldShowListEmptyContent = true, + listItemWrapperStyle, shouldIgnoreFocus = false, scrollEventThrottle, contentContainerStyle, @@ -475,6 +476,7 @@ function BaseSelectionList( setFocusedIndex(normalizedIndex); }} shouldSyncFocus={!isTextInputFocusedRef.current} + wrapperStyle={listItemWrapperStyle} /> {item.footerContent && item.footerContent} 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/SelectionList/RadioListItem.tsx b/src/components/SelectionList/RadioListItem.tsx index 067c2341ef67..1f0e174d4749 100644 --- a/src/components/SelectionList/RadioListItem.tsx +++ b/src/components/SelectionList/RadioListItem.tsx @@ -21,6 +21,7 @@ function RadioListItem({ alternateTextNumberOfLines = 2, onFocus, shouldSyncFocus, + wrapperStyle, }: RadioListItemProps) { const styles = useThemeStyles(); const fullTitle = isMultilineSupported ? item.text?.trimStart() : item.text; @@ -31,7 +32,7 @@ function RadioListItem({ return ( = Partial & { /** Whether to show the empty list content */ shouldShowListEmptyContent?: boolean; + /** The style is applied for the wrap component of list item */ + listItemWrapperStyle?: StyleProp; + /** Scroll event throttle for preventing onScroll callbacks to be fired too often */ scrollEventThrottle?: number; diff --git a/src/components/SelectionListWithModal/index.tsx b/src/components/SelectionListWithModal/index.tsx index 068e70099318..46d6494d1d21 100644 --- a/src/components/SelectionListWithModal/index.tsx +++ b/src/components/SelectionListWithModal/index.tsx @@ -1,3 +1,4 @@ +import {useIsFocused} from '@react-navigation/native'; import type {ForwardedRef} from 'react'; import React, {forwardRef, useEffect, useRef, useState} from 'react'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -28,6 +29,8 @@ 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 isFocused = useIsFocused(); + const {selectionMode} = useMobileSelectionMode(shouldAutoTurnOff); // Check if selection should be on when the modal is opened const wasSelectionOnRef = useRef(false); @@ -50,6 +53,9 @@ function SelectionListWithModal( } return; } + if (!isFocused) { + return; + } if (!wasSelectionOnRef.current && selectedItems.length > 0) { wasSelectionOnRef.current = true; } @@ -58,7 +64,7 @@ function SelectionListWithModal( } else if (selectedItems.length === 0 && selectionMode?.isEnabled && !wasSelectionOnRef.current) { turnOffMobileSelectionMode(); } - }, [sections, selectionMode, isSmallScreenWidth, isSelected]); + }, [sections, selectionMode, isSmallScreenWidth, isSelected, isFocused]); useEffect( () => () => { diff --git a/src/components/SettlementButton/AnimatedSettlementButton.tsx b/src/components/SettlementButton/AnimatedSettlementButton.tsx index 648c1dad36c3..5de528d741a2 100644 --- a/src/components/SettlementButton/AnimatedSettlementButton.tsx +++ b/src/components/SettlementButton/AnimatedSettlementButton.tsx @@ -1,6 +1,7 @@ import React, {useCallback, useEffect} from 'react'; import Animated, {runOnJS, useAnimatedStyle, useSharedValue, withDelay, withTiming} from 'react-native-reanimated'; import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -14,6 +15,7 @@ type AnimatedSettlementButtonProps = SettlementButtonProps & { function AnimatedSettlementButton({isPaidAnimationRunning, onAnimationFinish, isDisabled, ...settlementButtonProps}: AnimatedSettlementButtonProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); const buttonScale = useSharedValue(1); const buttonOpacity = useSharedValue(1); const paymentCompleteTextScale = useSharedValue(0); @@ -77,7 +79,7 @@ function AnimatedSettlementButton({isPaidAnimationRunning, onAnimationFinish, is {isPaidAnimationRunning && ( - Payment complete + {translate('iou.paymentComplete')} )} 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 847600d64644..eadcabb8bfc6 100644 --- a/src/components/TextInput/BaseTextInput/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/index.native.tsx @@ -72,6 +72,7 @@ function BaseTextInput( ref: ForwardedRef, ) { const InputComponent = isMarkdownEnabled ? RNMarkdownTextInput : RNTextInput; + const isAutoGrowHeightMarkdown = isMarkdownEnabled && autoGrowHeight; const inputProps = {shouldSaveDraft: false, shouldUseDefaultValue: false, ...props}; const theme = useTheme(); @@ -258,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); @@ -274,7 +276,10 @@ function BaseTextInput( onLayout={onLayout} accessibilityLabel={label} style={[ - autoGrowHeight && styles.autoGrowHeightInputContainer(textInputHeight, variables.componentSizeLarge, typeof maxAutoGrowHeight === 'number' ? maxAutoGrowHeight : 0), + autoGrowHeight && + !isAutoGrowHeightMarkdown && + styles.autoGrowHeightInputContainer(textInputHeight, variables.componentSizeLarge, typeof maxAutoGrowHeight === 'number' ? maxAutoGrowHeight : 0), + isAutoGrowHeightMarkdown && {minHeight: variables.componentSizeLarge}, !isMultiline && styles.componentHeightLarge, touchableInputWrapperStyle, ]} @@ -351,9 +356,10 @@ function BaseTextInput( !isMultiline && {height, lineHeight: undefined}, // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled. - ...(autoGrowHeight + ...(autoGrowHeight && !isAutoGrowHeightMarkdown ? [StyleUtils.getAutoGrowHeightInputStyle(textInputHeight, typeof maxAutoGrowHeight === 'number' ? maxAutoGrowHeight : 0), styles.verticalAlignTop] : []), + isAutoGrowHeightMarkdown ? [StyleUtils.getMarkdownMaxHeight(maxAutoGrowHeight), styles.verticalAlignTop] : undefined, // Add disabled color theme when field is not editable. inputProps.disabled && styles.textInputDisabled, styles.pointerEventsAuto, @@ -444,7 +450,7 @@ function BaseTextInput( This text view is used to calculate width or height of the input value given textStyle in this component. This Text component is intentionally positioned out of the screen. */} - {(!!autoGrow || autoGrowHeight) && ( + {(!!autoGrow || autoGrowHeight) && !isAutoGrowHeightMarkdown && ( // Add +2 to width on Safari browsers so that text is not cut off due to the cursor or when changing the value // https://github.com/Expensify/App/issues/8158 // https://github.com/Expensify/App/issues/26628 diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index 89f26330f859..48b99486d6cc 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -78,6 +78,7 @@ function BaseTextInput( ref: ForwardedRef, ) { const InputComponent = isMarkdownEnabled ? RNMarkdownTextInput : RNTextInput; + const isAutoGrowHeightMarkdown = isMarkdownEnabled && autoGrowHeight; const theme = useTheme(); const styles = useThemeStyles(); @@ -97,6 +98,7 @@ function BaseTextInput( const [textInputHeight, setTextInputHeight] = useState(0); const [height, setHeight] = useState(variables.componentSizeLarge); const [width, setWidth] = useState(null); + const labelScale = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_SCALE : styleConst.INACTIVE_LABEL_SCALE)).current; const labelTranslateY = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y)).current; const input = useRef(null); @@ -259,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; @@ -300,7 +303,10 @@ function BaseTextInput( // or if multiline is not supplied we calculate the textinput height, using onLayout. onLayout={onLayout} style={[ - autoGrowHeight && styles.autoGrowHeightInputContainer(textInputHeight, variables.componentSizeLarge, typeof maxAutoGrowHeight === 'number' ? maxAutoGrowHeight : 0), + autoGrowHeight && + !isAutoGrowHeightMarkdown && + styles.autoGrowHeightInputContainer(textInputHeight, variables.componentSizeLarge, typeof maxAutoGrowHeight === 'number' ? maxAutoGrowHeight : 0), + isAutoGrowHeightMarkdown && {minHeight: variables.componentSizeLarge}, !isMultiline && styles.componentHeightLarge, touchableInputWrapperStyle, ]} @@ -326,6 +332,7 @@ function BaseTextInput( /> ) : null} + {iconLeft && ( @@ -386,10 +393,10 @@ function BaseTextInput( !isMultiline && Browser.isMobileChrome() && {boxSizing: 'content-box', height: undefined, ...styles.overflowAuto}, // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled. - ...(autoGrowHeight + ...(autoGrowHeight && !isAutoGrowHeightMarkdown ? [StyleUtils.getAutoGrowHeightInputStyle(textInputHeight, typeof maxAutoGrowHeight === 'number' ? maxAutoGrowHeight : 0), styles.verticalAlignTop] : []), - + isAutoGrowHeightMarkdown ? [StyleUtils.getMarkdownMaxHeight(maxAutoGrowHeight), styles.verticalAlignTop] : undefined, // Add disabled color theme when field is not editable. inputProps.disabled && shouldUseDisabledStyles && styles.textInputDisabled, styles.pointerEventsAuto, @@ -490,7 +497,7 @@ function BaseTextInput( This text view is used to calculate width or height of the input value given textStyle in this component. This Text component is intentionally positioned out of the screen. */} - {(!!autoGrow || autoGrowHeight) && ( + {(!!autoGrow || autoGrowHeight) && !isAutoGrowHeightMarkdown && ( // Add +2 to width on Safari browsers so that text is not cut off due to the cursor or when changing the value // Reference: https://github.com/Expensify/App/issues/8158, https://github.com/Expensify/App/issues/26628 // For mobile Chrome, ensure proper display of the text selection handle (blue bubble down). diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts index c7e9bf2c0218..2006ca85dd13 100644 --- a/src/hooks/useMarkdownStyle.ts +++ b/src/hooks/useMarkdownStyle.ts @@ -75,6 +75,19 @@ function useMarkdownStyle(message: string | null = null, excludeStyles: Array `automatically approved ${amount} via workspace rules`, approvedAmount: ({amount}: ApprovedAmountParams) => `approved ${amount}`, + unapprovedAmount: ({amount}: UnapprovedParams) => `unapproved ${amount}`, automaticallyForwardedAmount: ({amount}: ForwardedAmountParams) => `automatically approved ${amount} via workspace rules`, forwardedAmount: ({amount}: ForwardedAmountParams) => `approved ${amount}`, @@ -1040,6 +1041,7 @@ const translations = { bookingPendingDescription: "This booking is pending because it hasn't been paid yet.", bookingArchived: 'This booking is archived', bookingArchivedDescription: 'This booking is archived because the trip date has passed. Add an expense for the final amount if needed.', + paymentComplete: 'Payment complete', justTrackIt: 'Just track it (don’t submit it)', }, notificationPreferencesPage: { @@ -1737,9 +1739,9 @@ const translations = { getStarted: 'Get started', whatsYourName: "What's your name?", whereYouWork: 'Where do you work?', + errorSelection: 'Please make a selection to continue.', purpose: { title: 'What do you want to do today?', - errorSelection: 'Please make a selection to continue.', errorContinue: 'Please press continue to get set up.', errorBackButton: 'Please finish the setup questions to start using the app.', [CONST.ONBOARDING_CHOICES.EMPLOYER]: 'Get paid back by my employer', @@ -1748,6 +1750,18 @@ const translations = { [CONST.ONBOARDING_CHOICES.CHAT_SPLIT]: 'Chat and split expenses with friends', [CONST.ONBOARDING_CHOICES.LOOKING_AROUND]: 'Something else', }, + employees: { + title: 'How many employees do you have?', + [CONST.ONBOARDING_COMPANY_SIZE.MICRO]: '1-10 employees', + [CONST.ONBOARDING_COMPANY_SIZE.SMALL]: '11-50 employees', + [CONST.ONBOARDING_COMPANY_SIZE.MEDIUM_SMALL]: '51-100 employees', + [CONST.ONBOARDING_COMPANY_SIZE.MEDIUM]: '101-1,000 employees', + [CONST.ONBOARDING_COMPANY_SIZE.LARGE]: 'More than 1,000 employees', + }, + accounting: { + title: 'Do you use any accounting software?', + noneOfAbove: 'None of the above', + }, error: { requiredFirstName: 'Please input your first name to continue.', }, @@ -2457,6 +2471,7 @@ const translations = { }), settlementFrequency: 'Settlement frequency', deleteConfirmation: 'Are you sure you want to delete this workspace?', + deleteWithCardsConfirmation: 'Are you sure you want to delete this workspace? This will remove all card feeds and assigned cards.', unavailable: 'Unavailable workspace', memberNotFound: 'Member not found. To invite a new member to the workspace, please use the invite button above.', notAuthorized: `You don't have access to this page. If you're trying to join this workspace, just ask the workspace owner to add you as a member. Something else? Reach out to ${CONST.EMAIL.CONCIERGE}.`, @@ -2510,7 +2525,72 @@ const translations = { }, }, qbd: { + exportOutOfPocketExpensesDescription: 'Set how out-of-pocket expenses export to QuickBooks Desktop.', + exportOutOfPocketExpensesCheckToogle: 'Mark checks as “print later”', exportDescription: 'Configure how Expensify data exports to QuickBooks Desktop.', + date: 'Export date', + exportInvoices: 'Export invoices to', + exportExpensifyCard: 'Export Expensify Card transactions as', + account: 'Account', + accountDescription: 'Choose where to post journal entries.', + accountsPayable: 'Accounts payable', + accountsPayableDescription: 'Choose where to create vendor bills.', + bankAccount: 'Bank account', + notConfigured: 'Not configured', + bankAccountDescription: 'Choose where to send checks from.', + creditCardAccount: 'Credit card account', + exportDate: { + label: 'Export date', + description: 'Use this date when exporting reports to QuickBooks Desktop.', + values: { + [CONST.QUICKBOOKS_EXPORT_DATE.LAST_EXPENSE]: { + label: 'Date of last expense', + description: 'Date of the most recent expense on the report.', + }, + [CONST.QUICKBOOKS_EXPORT_DATE.REPORT_EXPORTED]: { + label: 'Export date', + description: 'Date the report was exported to QuickBooks Desktop.', + }, + [CONST.QUICKBOOKS_EXPORT_DATE.REPORT_SUBMITTED]: { + label: 'Submitted date', + description: 'Date the report was submitted for approval.', + }, + }, + }, + exportCheckDescription: "We'll create an itemized check for each Expensify report and send it from the bank account below.", + exportJournalEntryDescription: "We'll create an itemized journal entry for each Expensify report and post it to the account below.", + exportVendorBillDescription: + "We'll create an itemized vendor bill for each Expensify report and add it to the account below. If this period is closed, we'll post to the 1st of the next open period.", + deepDiveExpensifyCard: 'Expensify Card transactions will automatically export to an "Expensify Card Liability Account" created with', + deepDiveExpensifyCardIntegration: 'our integration.', + outOfPocketLocationEnabledDescription: + 'QuickBooks Desktop doesn’t support locations on vendor bills or checks. As you have locations enabled on your workspace, these export options are unavailable.', + outOfPocketTaxEnabledDescription: + "QuickBooks Desktop doesn't support taxes on journal entry exports. As you have taxes enabled on your workspace, this export option is unavailable.", + outOfPocketTaxEnabledError: 'Journal entries are unavailable when taxes are enabled. Please choose a different export option.', + outOfPocketLocationEnabledError: 'Vendor bills are unavailable when locations are enabled. Please choose a different export option.', + accounts: { + [CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD]: 'Credit card', + [CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL]: 'Vendor bill', + [CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY]: 'Journal entry', + [CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK]: 'Check', + + [`${CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}Description`]: + "We'll automatically match the merchant name on the credit card transaction to any corresponding vendors in QuickBooks. If no vendors exist, we'll create a 'Credit Card Misc.' vendor for association.", + [`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}Description`]: + "We'll create an itemized vendor bill for each Expensify report with the date of the last expense, and add it to the account below. If this period is closed, we'll post to the 1st of the next open period.", + + [`${CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}AccountDescription`]: 'Choose where to export credit card transactions.', + [`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}AccountDescription`]: 'Choose a vendor to apply to all credit card transactions.', + + [`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}Error`]: + 'Vendor bills are unavailable when locations are enabled. Please choose a different export option.', + [`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK}Error`]: 'Checks are unavailable when locations are enabled. Please choose a different export option.', + [`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY}Error`]: + 'Journal entries are unavailable when taxes are enabled. Please choose a different export option.', + }, + noAccountsFound: 'No accounts found', + noAccountsFoundDescription: 'Add the account in Quickbooks Desktop and sync the connection again.', qbdSetup: 'QuickBooks Desktop setup', requiredSetupDevice: { title: "Can't connect from this device", @@ -2525,6 +2605,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.', @@ -3104,12 +3190,21 @@ const translations = { selectCardFeed: 'Select card feed', addCardFeed: 'Add card feed', addNewCard: { + other: 'Other', cardProviders: { amex: 'American Express Corporate Cards', mastercard: 'Mastercard Commercial Cards', visa: 'Visa Commercial Cards', }, 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:', @@ -3135,13 +3230,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.`, @@ -3158,6 +3258,9 @@ const translations = { card: 'Card', startTransactionDate: 'Start transaction date', cardName: 'Card name', + brokenConnectionErrorFirstPart: `Card feed connection is broken. Please `, + brokenConnectionErrorLink: 'log into your bank ', + brokenConnectionErrorSecondPart: 'so we can establish the connection again.', assignedYouCard: ({assigner}: AssignedYouCardParams) => `${assigner} assigned you a company card! Imported transactions will appear in this chat.`, chooseCardFeed: 'Choose card feed', }, @@ -4334,6 +4437,8 @@ const translations = { current: 'Current', past: 'Past', }, + noCategory: 'No category', + noTag: 'No tag', expenseType: 'Expense type', recentSearches: 'Recent searches', recentChats: 'Recent chats', @@ -4469,8 +4574,7 @@ const translations = { unshare: ({to}: UnshareParams) => `removed user ${to}`, stripePaid: ({amount, currency}: StripePaidParams) => `paid ${currency}${amount}`, takeControl: `took control`, - unapproved: ({amount, currency}: UnapprovedParams) => `unapproved ${currency}${amount}`, - 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}`, @@ -5069,12 +5173,15 @@ const translations = { return ''; } }, + removeCopilot: 'Remove copilot', + removeCopilotConfirmation: 'Are you sure you want to remove this copilot?', + changeAccessLevel: 'Change access level', makeSureItIsYou: "Let's make sure it's you", enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Please enter the magic code sent to ${contactMethod} to add a copilot.`, + 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 b8fdb30568a3..00bf0ef6d79e 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -649,7 +649,7 @@ const translations = { editAction: ({action}: EditActionParams) => `Editar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'gastos' : 'comentario'}`, deleteAction: ({action}: DeleteActionParams) => `Eliminar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'gastos' : 'comentario'}`, deleteConfirmation: ({action}: DeleteConfirmationParams) => - `¿Estás seguro de que quieres eliminar este ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'gasto' : 'comentario'}`, + `¿Estás seguro de que quieres eliminar este ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'gasto' : 'comentario'}?`, onlyVisible: 'Visible sólo para', replyInThread: 'Responder en el hilo', joinThread: 'Unirse al hilo', @@ -927,6 +927,7 @@ const translations = { automaticallyApprovedAmount: ({amount}: ApprovedAmountParams) => `aprobado automáticamente ${amount} según las reglas del espacio de trabajo`, approvedAmount: ({amount}: ApprovedAmountParams) => `aprobó ${amount}`, + unapprovedAmount: ({amount}: UnapprovedParams) => `desaprobó ${amount}`, automaticallyForwardedAmount: ({amount}: ForwardedAmountParams) => `aprobado automáticamente ${amount} según las reglas del espacio de trabajo`, forwardedAmount: ({amount}: ForwardedAmountParams) => `aprobó ${amount}`, @@ -1036,6 +1037,7 @@ const translations = { bookingPendingDescription: 'Esta reserva está pendiente porque aún no se ha pagado.', bookingArchived: 'Esta reserva está archivada', bookingArchivedDescription: 'Esta reserva está archivada porque la fecha del viaje ha pasado. Agregue un gasto por el monto final si es necesario.', + paymentComplete: 'Pago completo', justTrackIt: 'Solo guardarlo (no enviarlo)', }, notificationPreferencesPage: { @@ -1738,9 +1740,9 @@ const translations = { getStarted: 'Comenzar', whatsYourName: '¿Cómo te llamas?', whereYouWork: '¿Dónde trabajas?', + errorSelection: 'Por favor selecciona una opción para continuar.', purpose: { title: '¿Qué quieres hacer hoy?', - errorSelection: 'Por favor selecciona una opción para continuar.', errorContinue: 'Por favor, haz click en continuar para configurar tu cuenta.', errorBackButton: 'Por favor, finaliza las preguntas de configuración para empezar a utilizar la aplicación.', [CONST.ONBOARDING_CHOICES.EMPLOYER]: 'Cobrar de mi empresa', @@ -1749,6 +1751,18 @@ const translations = { [CONST.ONBOARDING_CHOICES.CHAT_SPLIT]: 'Chatea y divide gastos con tus amigos', [CONST.ONBOARDING_CHOICES.LOOKING_AROUND]: 'Algo más', }, + employees: { + title: '¿Cuántos empleados tienes?', + [CONST.ONBOARDING_COMPANY_SIZE.MICRO]: '1-10 empleados', + [CONST.ONBOARDING_COMPANY_SIZE.SMALL]: '11-50 empleados', + [CONST.ONBOARDING_COMPANY_SIZE.MEDIUM_SMALL]: '51-100 empleados', + [CONST.ONBOARDING_COMPANY_SIZE.MEDIUM]: '101-1,000 empleados', + [CONST.ONBOARDING_COMPANY_SIZE.LARGE]: 'Más de 1,000 empleados', + }, + accounting: { + title: '¿Utilizas algún software de contabilidad?', + noneOfAbove: 'Ninguno de los anteriores', + }, error: { requiredFirstName: 'Introduce tu nombre para continuar.', }, @@ -2479,6 +2493,7 @@ const translations = { }), settlementFrequency: 'Frecuencia de liquidación', deleteConfirmation: '¿Estás seguro de que quieres eliminar este espacio de trabajo?', + deleteWithCardsConfirmation: '¿Estás seguro de que quieres eliminar este espacio de trabajo? Se eliminarán todos los datos de las tarjetas y las tarjetas asignadas.', unavailable: 'Espacio de trabajo no disponible', memberNotFound: 'Miembro no encontrado. Para invitar a un nuevo miembro al espacio de trabajo, por favor, utiliza el botón invitar que está arriba.', notAuthorized: `No tienes acceso a esta página. Si estás intentando unirte a este espacio de trabajo, pide al dueño del espacio de trabajo que te añada como miembro. ¿Necesitas algo más? Comunícate con ${CONST.EMAIL.CONCIERGE}`, @@ -2533,7 +2548,74 @@ const translations = { }, }, qbd: { + exportOutOfPocketExpensesDescription: 'Establezca cómo se exportan los gastos de bolsillo a QuickBooks Desktop.', + exportOutOfPocketExpensesCheckToogle: 'Marcar los cheques como “imprimir más tarde”', exportDescription: 'Configura cómo se exportan los datos de Expensify a QuickBooks Desktop.', + date: 'Fecha de exportación', + exportInvoices: 'Exportar facturas a', + exportExpensifyCard: 'Exportar las transacciones de la tarjeta Expensify como', + account: 'Cuenta', + accountDescription: 'Elige dónde contabilizar los asientos contables.', + accountsPayable: 'Cuentas por pagar', + accountsPayableDescription: 'Elige dónde crear las facturas de proveedores.', + bankAccount: 'Cuenta bancaria', + notConfigured: 'No configurado', + bankAccountDescription: 'Elige desde dónde enviar los cheques.', + creditCardAccount: 'Cuenta de la tarjeta de crédito', + exportDate: { + label: 'Fecha de exportación', + description: 'Usa esta fecha al exportar informes a QuickBooks Desktop.', + values: { + [CONST.QUICKBOOKS_EXPORT_DATE.LAST_EXPENSE]: { + label: 'Fecha del último gasto', + description: 'Fecha del gasto más reciente en el informe.', + }, + [CONST.QUICKBOOKS_EXPORT_DATE.REPORT_EXPORTED]: { + label: 'Fecha de exportación', + description: 'Fecha de exportación del informe a QuickBooks Desktop.', + }, + [CONST.QUICKBOOKS_EXPORT_DATE.REPORT_SUBMITTED]: { + label: 'Fecha de envío', + description: 'Fecha en la que el informe se envió para aprobación.', + }, + }, + }, + exportCheckDescription: 'Crearemos un cheque desglosado para cada informe de Expensify y lo enviaremos desde la cuenta bancaria a continuación.', + exportJournalEntryDescription: 'Crearemos una entrada contable desglosada para cada informe de Expensify y lo contabilizaremos en la cuenta a continuación.', + exportVendorBillDescription: + 'Crearemos una factura de proveedor desglosada para cada informe de Expensify y la añadiremos a la cuenta a continuación. Si este periodo está cerrado, lo contabilizaremos el 1º del siguiente periodo abierto.', + deepDiveExpensifyCard: 'Las transacciones de la Tarjeta Expensify se exportarán automáticamente a una "Cuenta de Responsabilidad de la Tarjeta Expensify" creada con', + deepDiveExpensifyCardIntegration: 'nuestra integración.', + outOfPocketLocationEnabledDescription: + 'QuickBooks Desktop no permite lugares en facturas de proveedores o cheques. Como tienes activadas los lugares en tu espacio de trabajo, estas opciones de exportación no están disponibles.', + outOfPocketTaxEnabledDescription: + 'QuickBooks Desktop no admite impuestos en las exportaciones de asientos contables. Como tienes impuestos habilitados en tu espacio de trabajo, esta opción de exportación no está disponible.', + outOfPocketTaxEnabledError: 'Los asientos contables no están disponibles cuando los impuestos están habilitados. Por favor, selecciona otra opción de exportación.', + outOfPocketLocationEnabledError: 'Las facturas de proveedores no están disponibles cuando las ubicaciones están habilitadas. Por favor, selecciona otra opción de exportación.', + accounts: { + [CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD]: 'Tarjeta de crédito', + [CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL]: 'Factura del proveedor', + [CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY]: 'Asiento contable', + [CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK]: 'Cheque', + + [`${CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}Description`]: + "Automáticamente relacionaremos el nombre del comerciante de la transacción con tarjeta de crédito con cualquier proveedor correspondiente en QuickBooks. Si no existen proveedores, crearemos un proveedor asociado 'Credit Card Misc.'.", + [`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}Description`]: + 'Crearemos una factura de proveedor desglosada para cada informe de Expensify con la fecha del último gasto, y la añadiremos a la cuenta a continuación. Si este periodo está cerrado, lo contabilizaremos el 1º del siguiente periodo abierto.', + + [`${CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}AccountDescription`]: 'Elige dónde exportar las transacciones con tarjeta de crédito.', + [`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}AccountDescription`]: + 'Selecciona el proveedor que se aplicará a todas las transacciones con tarjeta de crédito.', + + [`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}Error`]: + 'Las facturas de proveedores no están disponibles cuando las ubicaciones están habilitadas. Por favor, selecciona otra opción de exportación.', + [`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK}Error`]: + 'Los cheques no están disponibles cuando las ubicaciones están habilitadas. Por favor, selecciona otra opción de exportación.', + [`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY}Error`]: + 'Los asientos contables no están disponibles cuando los impuestos están habilitados. Por favor, selecciona otra opción de exportación.', + }, + noAccountsFound: 'No se encontraron cuentas', + noAccountsFoundDescription: 'Añade la cuenta en QuickBooks Desktop y sincroniza de nuevo la conexión.', qbdSetup: 'Configuración de QuickBooks Desktop', requiredSetupDevice: { title: 'No se puede conectar desde este dispositivo', @@ -2548,6 +2630,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.', @@ -3141,12 +3229,21 @@ const translations = { selectCardFeed: 'Seleccionar feed de tarjetas', addCardFeed: 'Añadir alimentación de tarjeta', addNewCard: { + other: 'Otros', cardProviders: { amex: 'Tarjetas de empresa American Express', mastercard: 'Tarjetas comerciales Mastercard', visa: 'Tarjetas comerciales Visa', }, 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: @@ -3173,13 +3270,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}.`, @@ -3196,6 +3298,9 @@ const translations = { card: 'Tarjeta', startTransactionDate: 'Fecha de inicio de transacciones', cardName: 'Nombre de la tarjeta', + brokenConnectionErrorFirstPart: `La conexión de la fuente de tarjetas está rota. Por favor, `, + brokenConnectionErrorLink: 'inicia sesión en tu banco ', + brokenConnectionErrorSecondPart: 'para que podamos restablecer la conexión.', assignedYouCard: ({assigner}: AssignedYouCardParams) => `¡${assigner} te ha asignado una tarjeta de empresa! Las transacciones importadas aparecerán en este chat.`, chooseCardFeed: 'Elige feed de tarjetas', }, @@ -4380,6 +4485,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', @@ -4397,7 +4504,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.', @@ -4516,8 +4623,7 @@ const translations = { unshare: ({to}: UnshareParams) => `usuario eliminado ${to}`, stripePaid: ({amount, currency}: StripePaidParams) => `pagado ${currency}${amount}`, takeControl: `tomó el control`, - unapproved: ({amount, currency}: UnapprovedParams) => `no aprobado ${currency}${amount}`, - 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'}`, @@ -5239,7 +5345,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) { @@ -5582,12 +5688,16 @@ const translations = { return ''; } }, + removeCopilot: 'Eliminar copiloto', + removeCopilotConfirmation: '¿Estás seguro de que quieres eliminar este copiloto?', + changeAccessLevel: 'Cambiar nivel de acceso', makeSureItIsYou: 'Vamos a asegurarnos de que eres tú', enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Por favor, introduce el código mágico enviado a ${contactMethod} para agregar un copiloto.`, + enterMagicCodeUpdate: ({contactMethod}: EnterMagicCodeParams) => + `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/languages/params.ts b/src/languages/params.ts index 65d42f526f82..924a300cd660 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -316,7 +316,7 @@ type UnshareParams = {to: string}; type StripePaidParams = {amount: string; currency: string}; -type UnapprovedParams = {amount: string; currency: string}; +type UnapprovedParams = {amount: string}; type RemoveMembersWarningPrompt = { memberName: string; diff --git a/src/libs/API/parameters/CompleteGuidedSetupParams.ts b/src/libs/API/parameters/CompleteGuidedSetupParams.ts index 0b2c0b66ef0a..1242b9285de9 100644 --- a/src/libs/API/parameters/CompleteGuidedSetupParams.ts +++ b/src/libs/API/parameters/CompleteGuidedSetupParams.ts @@ -1,4 +1,4 @@ -import type {OnboardingPurposeType} from '@src/CONST'; +import type {OnboardingAccountingType, OnboardingCompanySizeType, OnboardingPurposeType} from '@src/CONST'; type CompleteGuidedSetupParams = { firstName: string; @@ -7,6 +7,8 @@ type CompleteGuidedSetupParams = { guidedSetupData: string; engagementChoice: OnboardingPurposeType; paymentSelected?: string; + companySize?: OnboardingCompanySizeType; + userReportedIntegration?: OnboardingAccountingType; }; export default CompleteGuidedSetupParams; diff --git a/src/libs/API/parameters/CreateWorkspaceParams.ts b/src/libs/API/parameters/CreateWorkspaceParams.ts index 18ef4a0e763f..91c1039169aa 100644 --- a/src/libs/API/parameters/CreateWorkspaceParams.ts +++ b/src/libs/API/parameters/CreateWorkspaceParams.ts @@ -10,6 +10,7 @@ type CreateWorkspaceParams = { expenseCreatedReportActionID: string; customUnitID: string; customUnitRateID: string; + engagementChoice?: string; }; export default CreateWorkspaceParams; diff --git a/src/libs/API/parameters/RemoveDelegateParams.ts b/src/libs/API/parameters/RemoveDelegateParams.ts new file mode 100644 index 000000000000..e19b7680a9b8 --- /dev/null +++ b/src/libs/API/parameters/RemoveDelegateParams.ts @@ -0,0 +1,5 @@ +type RemoveDelegateParams = { + delegate: string; +}; + +export default RemoveDelegateParams; diff --git a/src/libs/API/parameters/UpdateDelegateRoleParams.ts b/src/libs/API/parameters/UpdateDelegateRoleParams.ts new file mode 100644 index 000000000000..c7834c0e2d36 --- /dev/null +++ b/src/libs/API/parameters/UpdateDelegateRoleParams.ts @@ -0,0 +1,9 @@ +import type {DelegateRole} from '@src/types/onyx/Account'; + +type UpdateDelegateRoleParams = { + delegate: string; + role: DelegateRole; + validateCode: string; +}; + +export default UpdateDelegateRoleParams; diff --git a/src/libs/API/parameters/UpdateQuickbooksDesktopExpensesExportDestinationTypeParams.ts b/src/libs/API/parameters/UpdateQuickbooksDesktopExpensesExportDestinationTypeParams.ts new file mode 100644 index 000000000000..d59d4013a2b3 --- /dev/null +++ b/src/libs/API/parameters/UpdateQuickbooksDesktopExpensesExportDestinationTypeParams.ts @@ -0,0 +1,10 @@ +import type {QBDReimbursableExportAccountType} from '@src/types/onyx/Policy'; + +type UpdateQuickbooksDesktopExpensesExportDestinationTypeParams = { + policyID: string; + reimbursableExpensesExportDestination: QBDReimbursableExportAccountType; + reimbursableExpensesAccount: string; + idempotencyKey: string; +}; + +export default UpdateQuickbooksDesktopExpensesExportDestinationTypeParams; diff --git a/src/libs/API/parameters/UpdateQuickbooksDesktopGenericTypeParams.ts b/src/libs/API/parameters/UpdateQuickbooksDesktopGenericTypeParams.ts new file mode 100644 index 000000000000..925ba6f1882c --- /dev/null +++ b/src/libs/API/parameters/UpdateQuickbooksDesktopGenericTypeParams.ts @@ -0,0 +1,7 @@ +type UpdateQuickbooksDesktopGenericTypeParams = { + policyID: string; + settingValue: string; + idempotencyKey: string; +}; + +export default UpdateQuickbooksDesktopGenericTypeParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 3cd71a9c7417..47fb031134fd 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -222,6 +222,7 @@ export type {default as SetPolicyCustomTaxNameParams} from './SetPolicyCustomTax export type {default as SetPolicyForeignCurrencyDefaultParams} from './SetPolicyForeignCurrencyDefaultParams'; export type {default as SetPolicyCurrencyDefaultParams} from './SetPolicyCurrencyDefaultParams'; export type {default as UpdateQuickbooksOnlineGenericTypeParams} from './UpdateQuickbooksOnlineGenericTypeParams'; +export type {default as UpdateQuickbooksDesktopGenericTypeParams} from './UpdateQuickbooksDesktopGenericTypeParams'; export type {default as UpdatePolicyConnectionConfigParams} from './UpdatePolicyConnectionConfigParams'; export type {default as UpdateManyPolicyConnectionConfigurationsParams} from './UpdateManyPolicyConnectionConfigurationsParams'; export type {default as RemovePolicyConnectionParams} from './RemovePolicyConnectionParams'; @@ -296,6 +297,7 @@ export type {default as ConfigureExpensifyCardsForPolicyParams} from './Configur export type {default as CreateExpensifyCardParams} from './CreateExpensifyCardParams'; export type {default as UpdateExpensifyCardTitleParams} from './UpdateExpensifyCardTitleParams'; export type {default as AddDelegateParams} from './AddDelegateParams'; +export type {default as UpdateDelegateRoleParams} from './UpdateDelegateRoleParams'; export type {default as OpenCardDetailsPageParams} from './OpenCardDetailsPageParams'; export type {default as SetPolicyCategoryDescriptionRequiredParams} from './SetPolicyCategoryDescriptionRequiredParams'; export type {default as SetPolicyCategoryApproverParams} from './SetPolicyCategoryApproverParams'; @@ -306,6 +308,7 @@ export type {default as EnablePolicyCompanyCardsParams} from './EnablePolicyComp export type {default as ToggleCardContinuousReconciliationParams} from './ToggleCardContinuousReconciliationParams'; export type {default as CardDeactivateParams} from './CardDeactivateParams'; export type {default as UpdateExpensifyCardLimitTypeParams} from './UpdateExpensifyCardLimitTypeParams'; +export type {default as RemoveDelegateParams} from './RemoveDelegateParams'; export type {default as SetPolicyTagApproverParams} from './SetPolicyTagApproverParams'; export type {default as SaveSearchParams} from './SaveSearch'; export type {default as DeleteSavedSearchParams} from './DeleteSavedSearch'; @@ -331,4 +334,5 @@ export type {default as SetCompanyCardExportAccountParams} from './SetCompanyCar export type {default as SetMissingPersonalDetailsAndShipExpensifyCardParams} from './SetMissingPersonalDetailsAndShipExpensifyCardParams'; export type {default as SetInvoicingTransferBankAccountParams} from './SetInvoicingTransferBankAccountParams'; export type {default as ConnectPolicyToQuickBooksDesktopParams} from './ConnectPolicyToQuickBooksDesktopParams'; +export type {default as UpdateQuickbooksDesktopExpensesExportDestinationTypeParams} from './UpdateQuickbooksDesktopExpensesExportDestinationTypeParams'; export type {default as GetCorpayBankAccountFieldsParams} from './GetCorpayBankAccountFieldsParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index afe342fc278b..9d32393aed26 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -257,7 +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', @@ -381,6 +388,8 @@ const WRITE_COMMANDS = { CREATE_EXPENSIFY_CARD: 'CreateExpensifyCard', CREATE_ADMIN_ISSUED_VIRTUAL_CARD: 'CreateAdminIssuedVirtualCard', ADD_DELEGATE: 'AddDelegate', + REMOVE_DELEGATE: 'RemoveDelegate', + UPDATE_DELEGATE_ROLE: 'UpdateDelegateRole', TOGGLE_CARD_CONTINUOUS_RECONCILIATION: 'ToggleCardContinuousReconciliation', SET_POLICY_TAG_APPROVER: 'SetPolicyTagApprover', SAVE_SEARCH: 'SaveSearch', @@ -687,6 +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; @@ -805,6 +821,8 @@ type WriteCommandParameters = { [WRITE_COMMANDS.CREATE_EXPENSIFY_CARD]: Omit; [WRITE_COMMANDS.CREATE_ADMIN_ISSUED_VIRTUAL_CARD]: Omit; [WRITE_COMMANDS.ADD_DELEGATE]: Parameters.AddDelegateParams; + [WRITE_COMMANDS.UPDATE_DELEGATE_ROLE]: Parameters.UpdateDelegateRoleParams; + [WRITE_COMMANDS.REMOVE_DELEGATE]: Parameters.RemoveDelegateParams; [WRITE_COMMANDS.TOGGLE_CARD_CONTINUOUS_RECONCILIATION]: Parameters.ToggleCardContinuousReconciliationParams; [WRITE_COMMANDS.SAVE_SEARCH]: Parameters.SaveSearchParams; [WRITE_COMMANDS.DELETE_SAVED_SEARCH]: Parameters.DeleteSavedSearchParams; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index a054ca9a8252..fe7e5bb6f200 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -227,6 +227,55 @@ function getMemberCards(policy: OnyxEntry, allCardsList: OnyxCollection< return cards; } +const getBankCardDetailsImage = (bank: ValueOf): IconAsset => { + const iconMap: Record, IconAsset> = { + [CONST.COMPANY_CARDS.BANKS.AMEX]: Illustrations.AmexCardCompanyCardDetail, + [CONST.COMPANY_CARDS.BANKS.BANK_OF_AMERICA]: Illustrations.BankOfAmericaCompanyCardDetail, + [CONST.COMPANY_CARDS.BANKS.CAPITAL_ONE]: Illustrations.CapitalOneCompanyCardDetail, + [CONST.COMPANY_CARDS.BANKS.CHASE]: Illustrations.ChaseCompanyCardDetail, + [CONST.COMPANY_CARDS.BANKS.CITI_BANK]: Illustrations.CitibankCompanyCardDetail, + [CONST.COMPANY_CARDS.BANKS.WELLS_FARGO]: Illustrations.WellsFargoCompanyCardDetail, + [CONST.COMPANY_CARDS.BANKS.BREX]: Illustrations.BrexCompanyCardDetail, + [CONST.COMPANY_CARDS.BANKS.STRIPE]: Illustrations.StripeCompanyCardDetail, + [CONST.COMPANY_CARDS.BANKS.OTHER]: Illustrations.OtherCompanyCardDetail, + }; + 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, @@ -245,4 +294,6 @@ export { getCardFeedIcon, 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 5fbf6a8cc072..f103504cbd86 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -1,11 +1,10 @@ import React, {memo, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import Onyx, {useOnyx} from 'react-native-onyx'; +import Onyx, {withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import ActiveGuidesEventListener from '@components/ActiveGuidesEventListener'; import ComposeProviders from '@components/ComposeProviders'; -import {useSession} from '@components/OnyxProvider'; import OptionsListContextProvider from '@components/OptionListContextProvider'; import {SearchContextProvider} from '@components/Search/SearchContext'; import SearchRouterModal from '@components/Search/SearchRouter/SearchRouterModal'; @@ -68,6 +67,17 @@ import OnboardingModalNavigator from './Navigators/OnboardingModalNavigator'; import RightModalNavigator from './Navigators/RightModalNavigator'; import WelcomeVideoModalNavigator from './Navigators/WelcomeVideoModalNavigator'; +type AuthScreensProps = { + /** Session of currently logged in user */ + session: OnyxEntry; + + /** The report ID of the last opened public room as anonymous user */ + lastOpenedPublicRoomID: OnyxEntry; + + /** The last Onyx update ID was applied to the client */ + initialLastUpdateIDAppliedToClient: OnyxEntry; +}; + const loadReportAttachments = () => require('../../../pages/home/report/ReportAttachments').default; const loadValidateLoginPage = () => require('../../../pages/ValidateLoginPage').default; const loadLogOutPreviousUserPage = () => require('../../../pages/LogOutPreviousUserPage').default; @@ -141,7 +151,7 @@ Onyx.connect({ Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, callback: (value) => { - if (!value || timezone) { + if (!value || !isEmptyObject(timezone)) { return; } @@ -211,7 +221,7 @@ const modalScreenListenersWithCancelSearch = { }, }; -function AuthScreens() { +function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDAppliedToClient}: AuthScreensProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {shouldUseNarrowLayout, onboardingIsMediumOrLargerScreenWidth, isSmallScreenWidth} = useResponsiveLayout(); @@ -228,10 +238,6 @@ function AuthScreens() { const {isOnboardingCompleted} = useOnboardingFlowRouter(); let initialReportID: string | undefined; const isInitialRender = useRef(true); - const session = useSession(); - const [lastOpenedPublicRoomID] = useOnyx(ONYXKEYS.LAST_OPENED_PUBLIC_ROOM_ID); - const [initialLastUpdateIDAppliedToClient] = useOnyx(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT); - if (isInitialRender.current) { Timing.start(CONST.TIMING.HOMEPAGE_INITIAL_RENDER); @@ -579,4 +585,18 @@ AuthScreens.displayName = 'AuthScreens'; const AuthScreensMemoized = memo(AuthScreens, () => true); -export default AuthScreensMemoized; +// 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, + }, + lastOpenedPublicRoomID: { + key: ONYXKEYS.LAST_OPENED_PUBLIC_ROOM_ID, + }, + initialLastUpdateIDAppliedToClient: { + key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, + }, +})(AuthScreensMemoized); diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index eed6686d96e7..c32f63906443 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -312,6 +312,16 @@ 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]: () => + require('../../../../pages/workspace/accounting/qbd/export/QuickbooksDesktopOutOfPocketExpenseConfigurationPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT]: () => + require('../../../../pages/workspace/accounting/qbd/export/QuickbooksDesktopOutOfPocketExpenseEntitySelectPage').default, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT]: () => require('../../../../pages/workspace/accounting/qbd/export/QuickbooksDesktopExportPage').default, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_SETUP_MODAL]: () => require('../../../../pages/workspace/accounting/qbd/QuickBooksDesktopSetupPage').default, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_SETUP_REQUIRED_DEVICE_MODAL]: () => @@ -319,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, @@ -499,8 +514,12 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/workspace/accounting/intacct/import/SageIntacctEditUserDimensionsPage').default, [SCREENS.SETTINGS.DELEGATE.ADD_DELEGATE]: () => require('../../../../pages/settings/Security/AddDelegate/AddDelegatePage').default, [SCREENS.SETTINGS.DELEGATE.DELEGATE_ROLE]: () => require('../../../../pages/settings/Security/AddDelegate/SelectDelegateRolePage').default, + [SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE]: () => + require('../../../../pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateRolePage').default, [SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM]: () => require('../../../../pages/settings/Security/AddDelegate/ConfirmDelegatePage').default, [SCREENS.SETTINGS.DELEGATE.DELEGATE_MAGIC_CODE]: () => require('../../../../pages/settings/Security/AddDelegate/DelegateMagicCodePage').default, + [SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE_MAGIC_CODE]: () => + require('../../../../pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage').default, [SCREENS.WORKSPACE.RULES_CUSTOM_NAME]: () => require('../../../../pages/workspace/rules/RulesCustomNamePage').default, [SCREENS.WORKSPACE.RULES_AUTO_APPROVE_REPORTS_UNDER]: () => require('../../../../pages/workspace/rules/RulesAutoApproveReportsUnderPage').default, [SCREENS.WORKSPACE.RULES_RANDOM_REPORT_AUDIT]: () => require('../../../../pages/workspace/rules/RulesRandomReportAuditPage').default, diff --git a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx index e564af8103cc..4aae43987797 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx @@ -9,9 +9,10 @@ import useThemeStyles from '@hooks/useThemeStyles'; import OnboardingModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator/OnboardingModalNavigatorScreenOptions'; import type {OnboardingModalNavigatorParamList} from '@libs/Navigation/types'; import OnboardingRefManager from '@libs/OnboardingRefManager'; +import OnboardingAccounting from '@pages/OnboardingAccounting'; +import OnboardingEmployees from '@pages/OnboardingEmployees'; import OnboardingPersonalDetails from '@pages/OnboardingPersonalDetails'; import OnboardingPurpose from '@pages/OnboardingPurpose'; -import OnboardingWork from '@pages/OnboardingWork'; import CONST from '@src/CONST'; import SCREENS from '@src/SCREENS'; import Overlay from './Overlay'; @@ -52,8 +53,12 @@ function OnboardingModalNavigator() { component={OnboardingPersonalDetails} /> + 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 5d2a6b75c224..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,15 +38,16 @@ 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, SCREENS.SETTINGS.CLOSE, SCREENS.SETTINGS.DELEGATE.ADD_DELEGATE, SCREENS.SETTINGS.DELEGATE.DELEGATE_ROLE, + SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE, SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM, SCREENS.SETTINGS.DELEGATE.DELEGATE_MAGIC_CODE, + SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE_MAGIC_CODE, ], [SCREENS.SETTINGS.ABOUT]: [SCREENS.SETTINGS.APP_DOWNLOAD_LINKS], [SCREENS.SETTINGS.SAVE_THE_WORLD]: [SCREENS.I_KNOW_A_TEACHER, SCREENS.INTRO_SCHOOL_PRINCIPAL, SCREENS.I_AM_A_TEACHER], 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 b6422494241e..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,11 +45,19 @@ 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, SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT, SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_SETUP_MODAL, 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 57e83d309966..8f152e2d35de 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -119,8 +119,12 @@ const config: LinkingOptions['config'] = { path: ROUTES.ONBOARDING_PERSONAL_DETAILS.route, exact: true, }, - [SCREENS.ONBOARDING.WORK]: { - path: ROUTES.ONBOARDING_WORK.route, + [SCREENS.ONBOARDING.EMPLOYEES]: { + path: ROUTES.ONBOARDING_EMPLOYEES.route, + exact: true, + }, + [SCREENS.ONBOARDING.ACCOUNTING]: { + path: ROUTES.ONBOARDING_ACCOUNTING.route, exact: true, }, }, @@ -294,6 +298,12 @@ const config: LinkingOptions['config'] = { login: (login: string) => decodeURIComponent(login), }, }, + [SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE]: { + path: ROUTES.SETTINGS_UPDATE_DELEGATE_ROLE.route, + parse: { + login: (login: string) => decodeURIComponent(login), + }, + }, [SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM]: { path: ROUTES.SETTINGS_DELEGATE_CONFIRM.route, parse: { @@ -306,6 +316,12 @@ const config: LinkingOptions['config'] = { login: (login: string) => decodeURIComponent(login), }, }, + [SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE_MAGIC_CODE]: { + path: ROUTES.SETTINGS_UPDATE_DELEGATE_ROLE_MAGIC_CODE.route, + parse: { + login: (login: string) => decodeURIComponent(login), + }, + }, [SCREENS.SETTINGS.PROFILE.STATUS]: { path: ROUTES.SETTINGS_STATUS, exact: true, @@ -377,6 +393,17 @@ 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, + }, + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES]: { + path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES.route, + }, + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT]: { + path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT.route, + }, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.route}, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_SETUP_MODAL]: { path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_SETUP_MODAL.route, @@ -388,6 +415,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/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts index c84213918f70..218661632896 100644 --- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts @@ -117,11 +117,7 @@ function getMatchingRootRouteForRHPRoute(route: NavigationPartialRoute): Navigat // If there is rhpNavigator in the state generated for backTo url, we want to get root route matching to this rhp screen. const rhpNavigator = stateForBackTo.routes.find((rt) => rt.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR); if (rhpNavigator && rhpNavigator.state) { - const isRHPinState = stateForBackTo.routes.at(0)?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; - - if (isRHPinState) { - return getMatchingRootRouteForRHPRoute(findFocusedRoute(stateForBackTo) as NavigationPartialRoute); - } + return getMatchingRootRouteForRHPRoute(findFocusedRoute(stateForBackTo) as NavigationPartialRoute); } // If we know that backTo targets the root route (full screen) we want to use it. diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 0a65d4a91725..cbd6a2af95bf 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -436,6 +436,21 @@ 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; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT]: { + policyID: string; + }; [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT]: { policyID: string; }; @@ -448,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; }; @@ -692,6 +716,14 @@ type SettingsNavigatorParamList = { login: string; role?: string; }; + [SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE]: { + login: string; + currentRole: string; + }; + [SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE_MAGIC_CODE]: { + login: string; + role: string; + }; [SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM]: { login: string; role: string; @@ -1396,7 +1428,10 @@ type OnboardingModalNavigatorParamList = { [SCREENS.ONBOARDING.PURPOSE]: { backTo?: string; }; - [SCREENS.ONBOARDING.WORK]: { + [SCREENS.ONBOARDING.EMPLOYEES]: { + backTo?: string; + }; + [SCREENS.ONBOARDING.ACCOUNTING]: { backTo?: string; }; }; @@ -1456,6 +1491,7 @@ type AuthScreensParamList = CentralPaneScreensParamList & source: string; type: ValueOf; accountID: string; + isAuthTokenRequired?: string; }; [SCREENS.PROFILE_AVATAR]: { accountID: string; @@ -1484,6 +1520,7 @@ type AuthScreensParamList = CentralPaneScreensParamList & reportID: string; transactionID: string; readonly?: boolean; + isFromReviewDuplicates?: boolean; }; [SCREENS.CONNECTION_COMPLETE]: undefined; }; diff --git a/src/libs/NavigationUtils.ts b/src/libs/NavigationUtils.ts index ea1710b9931c..2f967c2f9a5e 100644 --- a/src/libs/NavigationUtils.ts +++ b/src/libs/NavigationUtils.ts @@ -17,7 +17,13 @@ const CENTRAL_PANE_SCREEN_NAMES = new Set([ SCREENS.REPORT, ]); -const ONBOARDING_SCREEN_NAMES = new Set([SCREENS.ONBOARDING.PERSONAL_DETAILS, SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK, SCREENS.ONBOARDING_MODAL.ONBOARDING]); +const ONBOARDING_SCREEN_NAMES = new Set([ + SCREENS.ONBOARDING.PERSONAL_DETAILS, + SCREENS.ONBOARDING.PURPOSE, + SCREENS.ONBOARDING_MODAL.ONBOARDING, + SCREENS.ONBOARDING.EMPLOYEES, + SCREENS.ONBOARDING.ACCOUNTING, +]); const removePolicyIDParamFromState = (state: State) => { const stateCopy = cloneDeep(state); diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index 48181db8b168..64db07491100 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -97,6 +97,16 @@ function buildNextStep(report: OnyxEntry, predictedNextStatus: ValueOf, predictedNextStatus: ValueOf, predictedNextStatus: ValueOf, predictedNextStatus: ValueOf 0 ? option.participantsList.at(0)?.login ?? '' : ''); } @@ -724,6 +726,8 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails } else { lastMessageTextFromReport = ReportUtils.getIOUApprovedMessage(lastReportAction); } + } else if (ReportActionUtils.isUnapprovedAction(lastReportAction)) { + lastMessageTextFromReport = ReportUtils.getIOUUnapprovedMessage(lastReportAction); } else if (ReportActionUtils.isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.FORWARDED)) { const {automaticAction} = ReportActionUtils.getOriginalMessage(lastReportAction) ?? {}; if (automaticAction) { diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index f7c75d73f2c3..6a1a61afa05d 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -9,6 +9,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; import type {OnyxInputOrEntry, Policy, PolicyCategories, PolicyEmployeeList, PolicyTagLists, PolicyTags, TaxRate} from '@src/types/onyx'; +import type {CardFeedData} from '@src/types/onyx/CardFeeds'; import type {ErrorFields, PendingAction, PendingFields} from '@src/types/onyx/OnyxCommon'; import type { ConnectionLastSync, @@ -1040,6 +1041,10 @@ function getWorkflowApprovalsUnavailable(policy: OnyxEntry) { return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL || !!policy?.errorFields?.approvalMode; } +function hasPolicyFeedsError(feeds: Record, feedToSkip?: string): boolean { + return Object.entries(feeds).filter(([feedName, feedData]) => feedName !== feedToSkip && !!feedData.errors).length > 0; +} + export { canEditTaxRate, extractPolicyIDFromPath, @@ -1068,6 +1073,7 @@ export { goBackFromInvalidPolicy, hasAccountingConnections, hasSyncError, + hasPolicyFeedsError, hasCustomUnitsError, hasEmployeeListError, hasIntegrationAutoSync, diff --git a/src/libs/ReportActionComposeFocusManager.ts b/src/libs/ReportActionComposeFocusManager.ts index cf97524fb3a3..4d6a2b339809 100644 --- a/src/libs/ReportActionComposeFocusManager.ts +++ b/src/libs/ReportActionComposeFocusManager.ts @@ -9,7 +9,10 @@ import navigationRef from './Navigation/navigationRef'; type FocusCallback = (shouldFocusForNonBlurInputOnTapOutside?: boolean) => void; const composerRef: MutableRefObject = React.createRef(); -const editComposerRef = React.createRef(); + +// There are two types of composer: general composer (edit composer) and main composer. +// The general composer callback will take priority if it exists. +const editComposerRef: MutableRefObject = React.createRef(); // There are two types of focus callbacks: priority and general // Priority callback would take priority if it existed let priorityFocusCallback: FocusCallback | null = null; @@ -58,6 +61,7 @@ function focus(shouldFocusForNonBlurInputOnTapOutside?: boolean) { */ function clear(isPriorityCallback = false) { if (isPriorityCallback) { + editComposerRef.current = null; priorityFocusCallback = null; } else { focusCallback = null; @@ -78,6 +82,14 @@ function isEditFocused(): boolean { return !!editComposerRef.current?.isFocused(); } +/** + * Utility function to blur both main composer and edit composer. + */ +function blurAll(): void { + composerRef.current?.blur(); + editComposerRef.current?.blur(); +} + export default { composerRef, onComposerFocus, @@ -86,4 +98,5 @@ export default { isFocused, editComposerRef, isEditFocused, + blurAll, }; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index e67b1bdd7e0a..31b44ac8f916 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -171,6 +171,10 @@ function isApprovedAction(reportAction: OnyxInputOrEntry): reportA return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.APPROVED); } +function isUnapprovedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.UNAPPROVED); +} + function isForwardedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.FORWARDED); } @@ -340,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. */ @@ -1069,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; } @@ -1803,8 +1795,6 @@ export { getNumberOfMoneyRequests, getOneTransactionThreadReportID, getOriginalMessage, - // eslint-disable-next-line deprecation/deprecation - getParentReportAction, getRemovedFromApprovalChainMessage, getReportAction, getReportActionHtml, @@ -1865,6 +1855,7 @@ export { isSubmittedAction, isSubmittedAndClosedAction, isApprovedAction, + isUnapprovedAction, isForwardedAction, isWhisperActionTargetedToOthers, isTagModificationAction, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index e9108956cb1d..d7ff4538748c 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1251,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)) + ); } /** @@ -3174,6 +3183,13 @@ function canHoldUnholdReportAction(reportAction: OnyxInputOrEntry) return {canHoldRequest: false, canUnholdRequest: false}; } + if (isInvoiceReport(moneyRequestReport)) { + return { + canHoldRequest: false, + canUnholdRequest: false, + }; + } + const isRequestSettled = isSettled(moneyRequestReport?.reportID); const isApproved = isReportApproved(moneyRequestReport); const transactionID = moneyRequestReport ? ReportActionsUtils.getOriginalMessage(reportAction)?.IOUTransactionID : 0; @@ -3351,7 +3367,7 @@ function getTransactionReportName(reportAction: OnyxEntry) { + const isInstantSubmitEnabled = PolicyUtils.isInstantSubmitEnabled(policy); + const isSubmitAndClose = PolicyUtils.isSubmitAndClose(policy); + const arePaymentsDisabled = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; + + if (isInstantSubmitEnabled && arePaymentsDisabled && isSubmitAndClose) { + return { + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + }; + } + + if (isInstantSubmitEnabled) { + return { + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }; + } + + return { + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; +} + /** * Builds an optimistic Expense report with a randomly generated reportID * @@ -4510,10 +4558,7 @@ function buildOptimisticExpenseReport( const formattedTotal = CurrencyUtils.convertToDisplayString(storedTotal, currency); const policy = getPolicy(policyID); - const isInstantSubmitEnabled = PolicyUtils.isInstantSubmitEnabled(policy); - - const stateNum = isInstantSubmitEnabled ? CONST.REPORT.STATE_NUM.SUBMITTED : CONST.REPORT.STATE_NUM.OPEN; - const statusNum = isInstantSubmitEnabled ? CONST.REPORT.STATUS_NUM.SUBMITTED : CONST.REPORT.STATUS_NUM.OPEN; + const {stateNum, statusNum} = getExpenseReportStateAndStatus(policy); const expenseReport: OptimisticExpenseReport = { reportID: generateReportID(), @@ -4559,6 +4604,7 @@ function getFormattedAmount(reportAction: ReportAction) { !ReportActionsUtils.isSubmittedAction(reportAction) && !ReportActionsUtils.isForwardedAction(reportAction) && !ReportActionsUtils.isApprovedAction(reportAction) && + !ReportActionsUtils.isUnapprovedAction(reportAction) && !ReportActionsUtils.isSubmittedAndClosedAction(reportAction) ) { return ''; @@ -4582,6 +4628,10 @@ function getReportAutomaticallyApprovedMessage(reportAction: ReportAction) { + return Localize.translateLocal('iou.unapprovedAmount', {amount: getFormattedAmount(reportAction)}); +} + function getIOUApprovedMessage(reportAction: ReportAction) { return Localize.translateLocal('iou.approvedAmount', {amount: getFormattedAmount(reportAction)}); } @@ -7281,7 +7331,7 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry, } return Localize.translateLocal(translationKey, { formattedAmount, - comment: TransactionUtils.getDescription(transaction) ?? '', + comment: TransactionUtils.getMerchantOrDescription(transaction), }); } @@ -8201,6 +8251,7 @@ export { getIOUReportActionDisplayMessage, getIOUReportActionMessage, getReportAutomaticallyApprovedMessage, + getIOUUnapprovedMessage, getIOUApprovedMessage, getReportAutomaticallyForwardedMessage, getIOUForwardedMessage, @@ -8421,6 +8472,7 @@ export { hasMissingInvoiceBankAccount, reasonForReportToBeInOptionList, getReasonAndReportActionThatRequiresAttention, + isPolicyRelatedReport, }; export type { diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 47cee2c7c2b4..ef9f237bd551 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -1,3 +1,4 @@ +import cloneDeep from 'lodash/cloneDeep'; import type {OnyxCollection} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type {ASTNode, QueryFilter, QueryFilters, SearchColumnType, SearchQueryJSON, SearchQueryString, SearchStatus, SortOrder} from '@components/Search/types'; @@ -18,13 +19,16 @@ import type {ListItemDataType, ListItemType, SearchDataTypes, SearchPersonalDeta import * as CurrencyUtils from './CurrencyUtils'; import DateUtils from './DateUtils'; import {translateLocal} from './Localize'; +import {validateAmount} from './MoneyRequestUtils'; import Navigation from './Navigation/Navigation'; import * as PersonalDetailsUtils from './PersonalDetailsUtils'; +import {getTagNamesFromTagsLists} from './PolicyUtils'; import * as ReportActionsUtils from './ReportActionsUtils'; import * as ReportUtils from './ReportUtils'; import * as searchParser from './SearchParser/searchParser'; import * as TransactionUtils from './TransactionUtils'; import * as UserUtils from './UserUtils'; +import * as ValidationUtils from './ValidationUtils'; type FilterKeys = keyof typeof CONST.SEARCH.SYNTAX_FILTER_KEYS; @@ -655,26 +659,60 @@ function buildQueryStringFromFilterFormValues(filterValues: Partial, + policyTags: OnyxCollection, + currencyList: OnyxTypes.CurrencyList, + personalDetails: OnyxTypes.PersonalDetailsList, + cardList: OnyxTypes.CardList, + reports: OnyxCollection, + taxRates: Record, +) { + const filters = queryJSON.flatFilters; const filterKeys = Object.keys(filters); const filtersForm = {} as Partial; + const policyID = queryJSON.policyID; for (const filterKey of filterKeys) { if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION) { filtersForm[filterKey] = filters[filterKey]?.[0]?.value.toString(); } - if ( - filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY || - filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID || - filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE || - filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE || - filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG || - filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY || - filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || - filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO || - filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.IN - ) { - filtersForm[filterKey] = filters[filterKey]?.map((filter) => filter.value.toString()); + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE) { + filtersForm[filterKey] = filters[filterKey] + ?.map((expenseType) => expenseType.value.toString()) + .filter((expenseType) => Object.values(CONST.SEARCH.TRANSACTION_TYPE).includes(expenseType as ValueOf)); + } + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) { + filtersForm[filterKey] = filters[filterKey]?.map((card) => card.value.toString()).filter((card) => Object.keys(cardList).includes(card)); + } + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) { + filtersForm[filterKey] = filters[filterKey]?.map((tax) => tax.value.toString()).filter((tax) => [...Object.values(taxRates)].flat().includes(tax)); + } + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.IN) { + filtersForm[filterKey] = filters[filterKey]?.map((report) => report.value.toString()).filter((id) => reports?.[`${ONYXKEYS.COLLECTION.REPORT}${id}`]); + } + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) { + filtersForm[filterKey] = filters[filterKey]?.map((id) => id.value.toString()).filter((id) => Object.keys(personalDetails).includes(id)); + } + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY) { + filtersForm[filterKey] = filters[filterKey]?.filter((currency) => Object.keys(currencyList).includes(currency.value.toString())).map((currency) => currency.value.toString()); + } + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG) { + const tags = policyID + ? getTagNamesFromTagsLists(policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {}) + : Object.values(policyTags ?? {}) + .filter((item) => !!item) + .map((tagList) => getTagNamesFromTagsLists(tagList ?? {})) + .flat(); + filtersForm[filterKey] = filters[filterKey]?.map((tag) => tag.value.toString()).filter((name) => tags.includes(name)); + } + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY) { + const categories = policyID + ? Object.values(policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`] ?? {}).map((category) => category.name) + : Object.values(policyCategories ?? {}) + .map((xd) => Object.values(xd ?? {}).map((category) => category.name)) + .flat(); + filtersForm[filterKey] = filters[filterKey]?.map((category) => category.value.toString()).filter((name) => categories.includes(name)); } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD) { filtersForm[filterKey] = filters[filterKey] @@ -688,17 +726,20 @@ function buildFilterFormValuesFromQuery(queryJSON: SearchQueryJSON) { .join(' '); } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE) { - filtersForm[FILTER_KEYS.DATE_BEFORE] = filters[filterKey]?.find((filter) => filter.operator === 'lt')?.value.toString(); - filtersForm[FILTER_KEYS.DATE_AFTER] = filters[filterKey]?.find((filter) => filter.operator === 'gt')?.value.toString(); + filtersForm[FILTER_KEYS.DATE_BEFORE] = filters[filterKey]?.find((filter) => filter.operator === 'lt' && ValidationUtils.isValidDate(filter.value.toString()))?.value.toString(); + filtersForm[FILTER_KEYS.DATE_AFTER] = filters[filterKey]?.find((filter) => filter.operator === 'gt' && ValidationUtils.isValidDate(filter.value.toString()))?.value.toString(); } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { - filtersForm[FILTER_KEYS.LESS_THAN] = filters[filterKey]?.find((filter) => filter.operator === 'lt')?.value.toString(); - filtersForm[FILTER_KEYS.GREATER_THAN] = filters[filterKey]?.find((filter) => filter.operator === 'gt')?.value.toString(); + filtersForm[FILTER_KEYS.LESS_THAN] = filters[filterKey]?.find((filter) => filter.operator === 'lt' && validateAmount(filter.value.toString(), 2))?.value.toString(); + filtersForm[FILTER_KEYS.GREATER_THAN] = filters[filterKey]?.find((filter) => filter.operator === 'gt' && validateAmount(filter.value.toString(), 2))?.value.toString(); } } - filtersForm[FILTER_KEYS.TYPE] = queryJSON.type; - filtersForm[FILTER_KEYS.STATUS] = queryJSON.status; + const [typeKey = '', typeValue] = Object.entries(CONST.SEARCH.DATA_TYPES).find(([, value]) => value === queryJSON.type) ?? []; + filtersForm[FILTER_KEYS.TYPE] = typeValue ? queryJSON.type : CONST.SEARCH.DATA_TYPES.EXPENSE; + const [statusKey] = Object.entries(CONST.SEARCH.STATUS).find(([, value]) => Object.values(value).includes(queryJSON.status)) ?? []; + filtersForm[FILTER_KEYS.STATUS] = typeKey === statusKey ? queryJSON.status : CONST.SEARCH.STATUS.EXPENSE.ALL; + if (queryJSON.policyID) { filtersForm[FILTER_KEYS.POLICY_ID] = queryJSON.policyID; } @@ -728,18 +769,21 @@ function getPolicyIDFromSearchQuery(queryJSON: SearchQueryJSON) { function getDisplayValue(filterName: string, filter: string, personalDetails: OnyxTypes.PersonalDetailsList, cardList: OnyxTypes.CardList, reports: OnyxCollection) { if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) { - return PersonalDetailsUtils.createDisplayName(personalDetails?.[filter]?.login ?? '', personalDetails?.[filter]); + // login can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return personalDetails?.[filter]?.login || filter; } if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) { - return cardList[filter].bank; + return cardList[filter]?.bank || filter; } if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.IN) { - return ReportUtils.getReportName(reports?.[`${ONYXKEYS.COLLECTION.REPORT}${filter}`]); + return ReportUtils.getReportName(reports?.[`${ONYXKEYS.COLLECTION.REPORT}${filter}`]) || filter; } return filter; } -function buildFilterString(filterName: string, queryFilters: QueryFilter[], delimiter = ',') { +function buildFilterString(filterName: string, queryFilters: QueryFilter[]) { + const delimiter = filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD ? ' ' : ','; let filterValueString = ''; queryFilters.forEach((queryFilter, index) => { // If the previous queryFilter has the same operator (this rule applies only to eq and neq operators) then append the current value @@ -748,6 +792,8 @@ function buildFilterString(filterName: string, queryFilters: QueryFilter[], deli ((queryFilter.operator === 'eq' && queryFilters?.at(index - 1)?.operator === 'eq') || (queryFilter.operator === 'neq' && queryFilters.at(index - 1)?.operator === 'neq')) ) { filterValueString += `${delimiter}${sanitizeString(queryFilter.value.toString())}`; + } else if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD) { + filterValueString += `${delimiter}${sanitizeString(queryFilter.value.toString())}`; } else { filterValueString += ` ${filterName}${operatorToSignMap[queryFilter.operator]}${sanitizeString(queryFilter.value.toString())}`; } @@ -773,9 +819,15 @@ function getSearchHeaderTitle( let displayQueryFilters: QueryFilter[] = []; if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) { const taxRateIDs = queryFilter.map((filter) => filter.value.toString()); - const taxRateNames = Object.entries(TaxRates) - .filter(([, taxRateKeys]) => taxRateKeys.some((taxID) => taxRateIDs.includes(taxID))) - .map(([taxRate]) => taxRate); + const taxRateNames = taxRateIDs + .map((id) => { + const taxRate = Object.entries(TaxRates) + .filter(([, IDs]) => IDs.includes(id)) + .map(([name]) => name); + return taxRate?.length > 0 ? taxRate : id; + }) + .flat(); + displayQueryFilters = taxRateNames.map((taxRate) => ({ operator: queryFilter.at(0)?.operator ?? CONST.SEARCH.SYNTAX_OPERATORS.AND, value: taxRate, @@ -786,7 +838,7 @@ function getSearchHeaderTitle( value: getDisplayValue(key, filter.value.toString(), PersonalDetails, cardList, reports), })); } - title += buildFilterString(key, displayQueryFilters, ' '); + title += buildFilterString(key, displayQueryFilters); }); return title; @@ -833,6 +885,76 @@ function getOverflowMenu(itemName: string, hash: number, inputQuery: string, sho ]; } +/** + * @private + * Given a filter name and its value, this function will try to find the corresponding ID. + */ +function findIDFromDisplayValue(filterName: ValueOf, filter: string | string[], cardList: OnyxTypes.CardList, taxRates: Record) { + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) { + if (typeof filter === 'string') { + const email = filter; + return PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? filter; + } + const emails = filter; + return emails.map((email) => PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? email); + } + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) { + const names = Array.isArray(filter) ? filter : ([filter] as string[]); + return names.map((name) => taxRates[name] ?? name).flat(); + } + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) { + if (typeof filter === 'string') { + const bank = filter; + const ids = + Object.values(cardList) + .filter((card) => card.bank === bank) + .map((card) => card.cardID.toString()) ?? filter; + return ids.length > 0 ? ids : bank; + } + const banks = filter; + return banks + .map( + (bank) => + Object.values(cardList) + .filter((card) => card.bank === bank) + .map((card) => card.cardID.toString()) ?? bank, + ) + .flat(); + } + return filter; +} + +/** + * Given a search query, this function will standardize the query by replacing display values with their corresponding IDs. + */ +function standardizeQueryJSON(queryJSON: SearchQueryJSON, cardList: OnyxTypes.CardList, taxRates: Record) { + const standardQuery = cloneDeep(queryJSON); + const filters = standardQuery.filters; + const traverse = (node: ASTNode) => { + if (!node.operator) { + return; + } + if (typeof node.left === 'object' && node.left) { + traverse(node.left); + } + if (typeof node.right === 'object' && node.right && !Array.isArray(node.right)) { + traverse(node.right); + } + + if (typeof node.left !== 'object') { + // eslint-disable-next-line no-param-reassign + node.right = findIDFromDisplayValue(node.left, node.right as string | string[], cardList, taxRates); + } + }; + + if (filters) { + traverse(filters); + } + + standardQuery.flatFilters = getFilters(standardQuery); + return standardQuery; +} + /** * Returns whether a given search query is a Canned query. * @@ -874,4 +996,5 @@ export { getExpenseTypeTranslationKey, getOverflowMenu, isCorrectSearchUserName, + standardizeQueryJSON, }; diff --git a/src/libs/StringUtils.ts b/src/libs/StringUtils.ts index b3fcd247284e..d13c38700e18 100644 --- a/src/libs/StringUtils.ts +++ b/src/libs/StringUtils.ts @@ -34,20 +34,19 @@ function removeInvisibleCharacters(value: string): string { // Remove spaces: // - \u200B: zero-width space - // - \u00A0: non-breaking space // - \u2060: word joiner - result = result.replace(/[\u200B\u00A0\u2060]/g, ''); - - // Temporarily replace all newlines with non-breaking spaces - // It is necessary because the next step removes all newlines because they are in the (Cc) category - result = result.replace(/\n/g, '\u00A0'); - - // Remove all characters from the 'Other' (C) category except for format characters (Cf) - // because some of them are used for emojis - result = result.replace(/[\p{Cc}\p{Cs}\p{Co}\p{Cn}]/gu, ''); - - // Replace all non-breaking spaces with newlines - result = result.replace(/\u00A0/g, '\n'); + result = result.replace(/[\u200B\u2060]/g, ''); + + // The control unicode (Cc) regex removes all newlines, + // so we first split the string by newline and rejoin it afterward to retain the original line breaks. + result = result + .split('\n') + .map((part) => + // Remove all characters from the 'Other' (C) category except for format characters (Cf) + // because some of them are used for emojis + part.replace(/[\p{Cc}\p{Cs}\p{Co}\p{Cn}]/gu, ''), + ) + .join('\n'); // Remove characters from the (Cf) category that are not used for emojis result = result.replace(/[\u200E-\u200F]/g, ''); diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 0db771eaa96b..11516af54b28 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -416,6 +416,10 @@ function getMerchant(transaction: OnyxInputOrEntry): string { return transaction?.modifiedMerchant ? transaction.modifiedMerchant : transaction?.merchant ?? ''; } +function getMerchantOrDescription(transaction: OnyxEntry) { + return !isMerchantMissing(transaction) ? getMerchant(transaction) : getDescription(transaction); +} + /** * Return the reimbursable value. Defaults to true to match BE logic. */ @@ -1096,6 +1100,7 @@ export { getOriginalCurrency, getOriginalAmount, getMerchant, + getMerchantOrDescription, getMCCGroup, getCreated, getFormattedCreated, diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 6eafec9f9528..cf4d76c721b3 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -80,7 +80,7 @@ function getTagViolationsForDependentTags(policyTagList: PolicyTagLists, transac */ function getTagViolationForIndependentTags(policyTagList: PolicyTagLists, transactionViolations: TransactionViolation[], transaction: Transaction) { const policyTagKeys = getSortedTagKeys(policyTagList); - const selectedTags = transaction.tag?.split(CONST.COLON) ?? []; + const selectedTags = TransactionUtils.getTagArrayFromName(transaction?.tag ?? ''); let newTransactionViolations = [...transactionViolations]; newTransactionViolations = newTransactionViolations.filter( diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index d69bcf0e5761..71cb5f97e00e 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -27,6 +27,7 @@ import type {OnyxData} from '@src/types/onyx/Request'; import {setShouldForceOffline} from './Network'; import * as PersistedRequests from './PersistedRequests'; import * as Policy from './Policy/Policy'; +import resolveDuplicationConflictAction from './RequestConflictUtils'; import * as Session from './Session'; import Timing from './Timing'; @@ -255,7 +256,9 @@ function getOnyxDataForOpenOrReconnect(isOpenApp = false): OnyxData { function openApp() { return getPolicyParamsForOpenOrReconnect().then((policyParams: PolicyParamsForOpenOrReconnect) => { const params: OpenAppParams = {enablePriorityModeFilter: true, ...policyParams}; - return API.write(WRITE_COMMANDS.OPEN_APP, params, getOnyxDataForOpenOrReconnect(true)); + return API.write(WRITE_COMMANDS.OPEN_APP, params, getOnyxDataForOpenOrReconnect(true), { + checkAndFixConflictingRequest: (persistedRequests) => resolveDuplicationConflictAction(persistedRequests, WRITE_COMMANDS.OPEN_APP), + }); }); } @@ -284,23 +287,7 @@ function reconnectApp(updateIDFrom: OnyxEntry = 0) { } API.write(WRITE_COMMANDS.RECONNECT_APP, params, getOnyxDataForOpenOrReconnect(), { - checkAndFixConflictingRequest: (persistedRequests) => { - const index = persistedRequests.findIndex((request) => request.command === WRITE_COMMANDS.RECONNECT_APP); - if (index === -1) { - return { - conflictAction: { - type: 'push', - }, - }; - } - - return { - conflictAction: { - type: 'replace', - index, - }, - }; - }, + checkAndFixConflictingRequest: (persistedRequests) => resolveDuplicationConflictAction(persistedRequests, WRITE_COMMANDS.RECONNECT_APP), }); }); } diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 7679199e502a..4c179cffa330 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -74,11 +74,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/Delegate.ts b/src/libs/actions/Delegate.ts index 2d6469dfb8a5..06d7093df385 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -1,7 +1,7 @@ import Onyx from 'react-native-onyx'; import type {OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; -import type {AddDelegateParams} from '@libs/API/parameters'; +import type {AddDelegateParams, RemoveDelegateParams} from '@libs/API/parameters'; import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; @@ -314,6 +314,72 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) { API.write(WRITE_COMMANDS.ADD_DELEGATE, parameters, {optimisticData, successData, failureData}); } +function removeDelegate(email: string) { + if (!email) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + delegates: delegatedAccess.delegates?.map((delegate) => + delegate.email === email + ? { + ...delegate, + errorFields: {removeDelegate: null}, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + pendingFields: {email: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, role: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + } + : delegate, + ), + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + delegates: delegatedAccess.delegates?.filter((delegate) => delegate.email !== email), + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + delegates: delegatedAccess.delegates?.map((delegate) => + delegate.email === email + ? { + ...delegate, + errorFields: { + removeDelegate: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError'), + }, + pendingAction: null, + pendingFields: undefined, + } + : delegate, + ), + }, + }, + }, + ]; + + const parameters: RemoveDelegateParams = {delegate: email}; + + API.write(WRITE_COMMANDS.REMOVE_DELEGATE, parameters, {optimisticData, successData, failureData}); +} + function clearAddDelegateErrors(email: string, fieldName: string) { if (!delegatedAccess?.delegates) { return; @@ -342,6 +408,144 @@ function removePendingDelegate(email: string) { }); } +function updateDelegateRole(email: string, role: DelegateRole, validateCode: string) { + if (!delegatedAccess?.delegates) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + delegates: delegatedAccess.delegates.map((delegate) => + delegate.email === email + ? { + ...delegate, + role, + errorFields: {updateDelegateRole: null}, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: {role: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + isLoading: true, + } + : delegate, + ), + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + delegates: delegatedAccess.delegates.map((delegate) => + delegate.email === email + ? { + ...delegate, + role, + errorFields: {updateDelegateRole: null}, + pendingAction: null, + pendingFields: {role: null}, + isLoading: false, + } + : delegate, + ), + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + delegates: delegatedAccess.delegates.map((delegate) => + delegate.email === email + ? { + ...delegate, + errorFields: { + updateDelegateRole: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError'), + }, + isLoading: false, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: {role: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + } + : delegate, + ), + }, + }, + }, + ]; + + const parameters = {delegate: email, validateCode, role}; + + API.write(WRITE_COMMANDS.UPDATE_DELEGATE_ROLE, parameters, {optimisticData, successData, failureData}); +} + +function updateDelegateRoleOptimistically(email: string, role: DelegateRole) { + if (!delegatedAccess?.delegates) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + delegates: delegatedAccess.delegates.map((delegate) => + delegate.email === email + ? { + ...delegate, + role, + errorFields: {updateDelegateRole: null}, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: {role: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + } + : delegate, + ), + }, + }, + }, + ]; + + Onyx.update(optimisticData); +} + +function clearDelegateRolePendingAction(email: string) { + if (!delegatedAccess?.delegates) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + delegates: delegatedAccess.delegates.map((delegate) => + delegate.email === email + ? { + ...delegate, + pendingAction: null, + pendingFields: undefined, + } + : delegate, + ), + }, + }, + }, + ]; + + Onyx.update(optimisticData); +} + function restoreDelegateSession(authenticateResponse: Response) { Onyx.clear(KEYS_TO_PRESERVE_DELEGATE_ACCESS).then(() => { updateSessionAuthTokens(authenticateResponse?.authToken, authenticateResponse?.encryptedAuthToken); @@ -355,4 +559,18 @@ function restoreDelegateSession(authenticateResponse: Response) { }); } -export {connect, disconnect, clearDelegatorErrors, addDelegate, requestValidationCode, clearAddDelegateErrors, removePendingDelegate, restoreDelegateSession, isConnectedAsDelegate}; +export { + connect, + disconnect, + clearDelegatorErrors, + addDelegate, + requestValidationCode, + clearAddDelegateErrors, + removePendingDelegate, + restoreDelegateSession, + isConnectedAsDelegate, + updateDelegateRoleOptimistically, + clearDelegateRolePendingAction, + updateDelegateRole, + removeDelegate, +}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 5db7bb972709..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 { @@ -7562,15 +7585,7 @@ function completePaymentOnboarding(paymentSelected: ValueOf, full = true) { diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 1ed7e309701f..73c7d982b501 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(); @@ -611,6 +611,10 @@ function clearQBOErrorField(policyID: string, fieldName: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {connections: {quickbooksOnline: {config: {errorFields: {[fieldName]: null}}}}}); } +function clearQBDErrorField(policyID: string, fieldName: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {connections: {quickbooksDesktop: {config: {errorFields: {[fieldName]: null}}}}}); +} + function clearXeroErrorField(policyID: string, fieldName: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {connections: {xero: {config: {errorFields: {[fieldName]: null}}}}}); } @@ -1588,7 +1592,7 @@ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', pol * @param [policyID] custom policy id we will use for created workspace * @param [expenseReportId] the reportID of the expense report that is being used to create the workspace */ -function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID(), expenseReportId?: string) { +function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID(), expenseReportId?: string, engagementChoice?: string) { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticCustomUnits(); @@ -1805,6 +1809,7 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName expenseCreatedReportActionID, customUnitID, customUnitRateID, + engagementChoice, }; return {successData, optimisticData, failureData, params}; @@ -1817,9 +1822,10 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName * @param [makeMeAdmin] leave the calling account as an admin on the policy * @param [policyName] custom policy name we will use for created workspace * @param [policyID] custom policy id we will use for created workspace + * @param [engagementChoice] Purpose of using application selected by user in guided setup flow */ -function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID()): CreateWorkspaceParams { - const {optimisticData, failureData, successData, params} = buildPolicyData(policyOwnerEmail, makeMeAdmin, policyName, policyID); +function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID(), engagementChoice = ''): CreateWorkspaceParams { + const {optimisticData, failureData, successData, params} = buildPolicyData(policyOwnerEmail, makeMeAdmin, policyName, policyID, undefined, engagementChoice); API.write(WRITE_COMMANDS.CREATE_WORKSPACE, params, {optimisticData, successData, failureData}); return params; @@ -4536,6 +4542,17 @@ function updateWorkspaceCompanyCard(workspaceAccountID: number, cardID: string, }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`, + value: { + companyCards: { + [bankName]: { + errors: null, + }, + }, + }, + }, ]; const finallyData: OnyxUpdate[] = [ @@ -4569,6 +4586,17 @@ function updateWorkspaceCompanyCard(workspaceAccountID: number, cardID: string, }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`, + value: { + companyCards: { + [bankName]: { + errors: {error: CONST.COMPANY_CARDS.CONNECTION_ERROR}, + }, + }, + }, + }, ]; const parameters = { @@ -4816,6 +4844,7 @@ export { clearAllPolicies, enablePolicyRules, setPolicyDefaultReportTitle, + clearQBDErrorField, setPolicyPreventMemberCreatedTitle, setPolicyPreventSelfApproval, setPolicyAutomaticApprovalLimit, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 95bd2aa0b834..87bec3bc30ea 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -70,7 +70,7 @@ import Parser from '@libs/Parser'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; import getPolicyEmployeeAccountIDs from '@libs/PolicyEmployeeListUtils'; -import {extractPolicyIDFromPath} from '@libs/PolicyUtils'; +import {extractPolicyIDFromPath, getPolicy} from '@libs/PolicyUtils'; import processReportIDDeeplink from '@libs/processReportIDDeeplink'; import * as Pusher from '@libs/Pusher/pusher'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; @@ -81,7 +81,7 @@ import {doesReportBelongToWorkspace} from '@libs/ReportUtils'; import shouldSkipDeepLinkNavigation from '@libs/shouldSkipDeepLinkNavigation'; import Visibility from '@libs/Visibility'; import CONFIG from '@src/CONFIG'; -import type {OnboardingPurposeType} from '@src/CONST'; +import type {OnboardingAccountingType, OnboardingCompanySizeType, OnboardingPurposeType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; @@ -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, ); @@ -3325,16 +3314,13 @@ function getReportPrivateNote(reportID: string | undefined) { function completeOnboarding( engagementChoice: OnboardingPurposeType, data: ValueOf, - { - firstName, - lastName, - }: { - firstName: string; - lastName: string; - }, + firstName = '', + lastName = '', adminsChatReportID?: string, onboardingPolicyID?: string, paymentSelected?: string, + companySize?: OnboardingCompanySizeType, + userReportedIntegration?: OnboardingAccountingType, ) { const actorAccountID = CONST.ACCOUNT_ID.CONCIERGE; const targetChatReport = ReportUtils.getChatByParticipants([actorAccountID, currentUserAccountID]); @@ -3647,6 +3633,38 @@ function completeOnboarding( }, ); + if (userReportedIntegration) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${onboardingPolicyID}`, + value: { + areConnectionsEnabled: true, + pendingFields: { + areConnectionsEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${onboardingPolicyID}`, + value: { + pendingFields: { + areConnectionsEnabled: null, + }, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${onboardingPolicyID}`, + value: { + areConnectionsEnabled: getPolicy(onboardingPolicyID)?.areConnectionsEnabled, + pendingFields: { + areConnectionsEnabled: null, + }, + }, + }); + } + const guidedSetupData: GuidedSetupData = [ {type: 'message', ...introductionMessage}, {type: 'message', ...textMessage}, @@ -3691,6 +3709,8 @@ function completeOnboarding( actorAccountID, guidedSetupData: JSON.stringify(guidedSetupData), paymentSelected, + companySize, + userReportedIntegration, }; API.write(WRITE_COMMANDS.COMPLETE_GUIDED_SETUP, parameters, {optimisticData, successData, failureData}); diff --git a/src/libs/actions/ReportActions.ts b/src/libs/actions/ReportActions.ts index ed8d4886659c..9338527eaccc 100644 --- a/src/libs/actions/ReportActions.ts +++ b/src/libs/actions/ReportActions.ts @@ -36,6 +36,7 @@ function clearReportActionErrors(reportID: string, reportAction: ReportAction, k const linkedTransactionID = ReportActionUtils.getLinkedTransactionID(reportAction.reportActionID, originalReportID || '-1'); if (linkedTransactionID) { Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${linkedTransactionID}`, null); + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportAction.childReportID}`, null); } // Delete the failed task report too diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts new file mode 100644 index 000000000000..68c0860389b9 --- /dev/null +++ b/src/libs/actions/RequestConflictUtils.ts @@ -0,0 +1,30 @@ +import type {WriteCommand} from '@libs/API/types'; +import type OnyxRequest from '@src/types/onyx/Request'; +import type {ConflictActionData} from '@src/types/onyx/Request'; + +/** + * Resolves duplication conflicts between persisted requests and a given command. + * + * This method checks if a specific command exists within a list of persisted requests. + * - If the command is not found, it suggests adding the command to the list, indicating a 'push' action. + * - If the command is found, it suggests updating the existing entry, indicating a 'replace' action at the found index. + */ +function resolveDuplicationConflictAction(persistedRequests: OnyxRequest[], commandToFind: WriteCommand): ConflictActionData { + const index = persistedRequests.findIndex((request) => request.command === commandToFind); + if (index === -1) { + return { + conflictAction: { + type: 'push', + }, + }; + } + + return { + conflictAction: { + type: 'replace', + index, + }, + }; +} + +export default resolveDuplicationConflictAction; diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 2f9ec060c1e8..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); @@ -1087,6 +1092,7 @@ function deleteTask(report: OnyxEntry) { Report.notifyNewAction(report.reportID, currentUserAccountID); if (shouldDeleteTaskReport) { + Navigation.goBack(); if (parentReport?.reportID) { return ROUTES.REPORT_WITH_ID.getRoute(parentReport.reportID); } diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index 0257faf39f14..3c11f3c440db 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -64,7 +64,7 @@ function adaptOnboardingRouteState() { } let adaptedOnboardingModalNavigatorState = {} as Readonly>; - if (currentRoute?.name === SCREENS.ONBOARDING.PERSONAL_DETAILS && selectedPurpose === CONST.ONBOARDING_CHOICES.MANAGE_TEAM) { + if (currentRoute?.name === SCREENS.ONBOARDING.ACCOUNTING && selectedPurpose === CONST.ONBOARDING_CHOICES.MANAGE_TEAM) { adaptedOnboardingModalNavigatorState = { index: 2, routes: [ @@ -73,7 +73,7 @@ function adaptOnboardingRouteState() { params: currentRoute?.params, }, { - name: SCREENS.ONBOARDING.WORK, + name: SCREENS.ONBOARDING.EMPLOYEES, params: currentRoute?.params, }, {...currentRoute}, @@ -119,7 +119,7 @@ function getOnboardingInitialPath(): string { if (isVsb) { Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.MANAGE_TEAM); - return `/${ROUTES.ONBOARDING_WORK.route}`; + return `/${ROUTES.ONBOARDING_EMPLOYEES.route}`; } const isIndividual = onboardingValues.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.INDIVIDUAL; if (isIndividual) { diff --git a/src/libs/actions/Welcome/index.ts b/src/libs/actions/Welcome/index.ts index d27a5e195a0e..d504c5550331 100644 --- a/src/libs/actions/Welcome/index.ts +++ b/src/libs/actions/Welcome/index.ts @@ -4,7 +4,7 @@ import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; import Log from '@libs/Log'; -import type {OnboardingPurposeType} from '@src/CONST'; +import type {OnboardingCompanySizeType, OnboardingPurposeType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type Onboarding from '@src/types/onyx/Onboarding'; import type TryNewDot from '@src/types/onyx/TryNewDot'; @@ -97,6 +97,10 @@ function setOnboardingPurposeSelected(value: OnboardingPurposeType) { Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, value ?? null); } +function setOnboardingCompanySize(value: OnboardingCompanySizeType) { + Onyx.set(ONYXKEYS.ONBOARDING_COMPANY_SIZE, value); +} + function setOnboardingErrorMessage(value: string) { Onyx.set(ONYXKEYS.ONBOARDING_ERROR_MESSAGE, value ?? null); } @@ -190,4 +194,5 @@ export { setOnboardingPolicyID, completeHybridAppOnboarding, setOnboardingErrorMessage, + setOnboardingCompanySize, }; diff --git a/src/libs/actions/connections/QuickBooksDesktop.ts b/src/libs/actions/connections/QuickBooksDesktop.ts deleted file mode 100644 index e33f82ba4b0a..000000000000 --- a/src/libs/actions/connections/QuickBooksDesktop.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as API from '@libs/API'; -import type {ConnectPolicyToQuickBooksDesktopParams} from '@libs/API/parameters'; -import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; - -function getQuickbooksDesktopCodatSetupLink(policyID: string) { - const params: ConnectPolicyToQuickBooksDesktopParams = {policyID}; - - // eslint-disable-next-line rulesdir/no-api-side-effects-method - return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.CONNECT_POLICY_TO_QUICKBOOKS_DESKTOP, params); -} - -// Disable because we will have more utils will be added in this file -// eslint-disable-next-line import/prefer-default-export -export {getQuickbooksDesktopCodatSetupLink}; diff --git a/src/libs/actions/connections/QuickbooksDesktop.ts b/src/libs/actions/connections/QuickbooksDesktop.ts new file mode 100644 index 000000000000..80f50dce7b8a --- /dev/null +++ b/src/libs/actions/connections/QuickbooksDesktop.ts @@ -0,0 +1,437 @@ +import type {OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import * as API from '@libs/API'; +import type {ConnectPolicyToQuickBooksDesktopParams, UpdateQuickbooksDesktopExpensesExportDestinationTypeParams, UpdateQuickbooksDesktopGenericTypeParams} from '@libs/API/parameters'; +import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Connections, QBDReimbursableExportAccountType} from '@src/types/onyx/Policy'; + +function buildOnyxDataForMultipleQuickbooksExportConfigurations>( + policyID: string, + configUpdate: TConfigUpdate, + configCurrentData: Partial, +) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBD]: { + config: { + export: configUpdate, + pendingFields: Object.fromEntries(Object.keys(configUpdate).map((settingName) => [settingName, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])), + errorFields: Object.fromEntries(Object.keys(configUpdate).map((settingName) => [settingName, null])), + }, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBD]: { + config: { + export: configCurrentData, + pendingFields: Object.fromEntries(Object.keys(configUpdate).map((settingName) => [settingName, null])), + errorFields: Object.fromEntries( + Object.keys(configUpdate).map((settingName) => [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: Object.fromEntries(Object.keys(configUpdate).map((settingName) => [settingName, null])), + errorFields: Object.fromEntries(Object.keys(configUpdate).map((settingName) => [settingName, null])), + }, + }, + }, + }, + }, + ]; + return { + optimisticData, + failureData, + successData, + }; +} + +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: { + export: { + [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: { + export: { + [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: { + export: { + [settingName]: settingValue ?? null, + }, + pendingFields: { + [settingName]: null, + }, + errorFields: { + [settingName]: null, + }, + }, + }, + }, + }, + }, + ]; + return { + optimisticData, + failureData, + successData, + }; +} + +function buildOnyxDataForQuickbooksDesktopMappingsConfiguration( + 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, + settingValue: Partial, + oldSettingValue?: Partial, +) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBD]: { + config: { + [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: { + [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: { + [settingName]: settingValue ?? null, + pendingFields: { + [settingName]: null, + }, + errorFields: { + [settingName]: null, + }, + }, + }, + }, + }, + }, + ]; + return { + optimisticData, + failureData, + successData, + }; +} + +function getQuickbooksDesktopCodatSetupLink(policyID: string) { + const params: ConnectPolicyToQuickBooksDesktopParams = {policyID}; + + // eslint-disable-next-line rulesdir/no-api-side-effects-method + return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.CONNECT_POLICY_TO_QUICKBOOKS_DESKTOP, params); +} + +function updateQuickbooksDesktopExpensesExportDestination( + policyID: string, + configUpdate: TConfigUpdate, + configCurrentData: Partial, +) { + const onyxData = buildOnyxDataForMultipleQuickbooksExportConfigurations(policyID, configUpdate, configCurrentData); + + const parameters: UpdateQuickbooksDesktopExpensesExportDestinationTypeParams = { + policyID, + reimbursableExpensesExportDestination: configUpdate.reimbursable, + reimbursableExpensesAccount: configUpdate.reimbursableAccount, + idempotencyKey: String(CONST.QUICKBOOKS_DESKTOP_CONFIG.REIMBURSABLE), + }; + + API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION, parameters, onyxData); +} + +function updateQuickbooksDesktopMarkChecksToBePrinted( + policyID: string, + settingValue: TSettingValue, +) { + const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.MARK_CHECKS_TO_BE_PRINTED, settingValue, !settingValue); + + const parameters: UpdateQuickbooksDesktopGenericTypeParams = { + policyID, + settingValue: JSON.stringify(settingValue), + idempotencyKey: String(CONST.QUICKBOOKS_DESKTOP_CONFIG.MARK_CHECKS_TO_BE_PRINTED), + }; + API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_MARK_CHECKS_TO_BE_PRINTED, parameters, onyxData); +} + +function updateQuickbooksDesktopReimbursableExpensesAccount( + policyID: string, + settingValue: TSettingValue, + oldSettingValue?: TSettingValue, +) { + const onyxData = buildOnyxDataForQuickbooksExportConfiguration(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.REIMBURSABLE_ACCOUNT, settingValue, oldSettingValue); + + const parameters: UpdateQuickbooksDesktopGenericTypeParams = { + policyID, + settingValue, + idempotencyKey: String(CONST.QUICKBOOKS_DESKTOP_CONFIG.REIMBURSABLE_ACCOUNT), + }; + API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_REIMBURSABLE_EXPENSES_ACCOUNT, parameters, onyxData); +} + +function updateQuickbooksDesktopEnableNewCategories(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 f452cfef5ad6..d0da0f1ff012 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 new file mode 100644 index 000000000000..ad7e5d38698f --- /dev/null +++ b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx @@ -0,0 +1,190 @@ +import React, {useMemo, useState} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; +import FormHelpMessage from '@components/FormHelpMessage'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import ScreenWrapper from '@components/ScreenWrapper'; +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 navigateAfterOnboarding from '@libs/navigateAfterOnboarding'; +import variables from '@styles/variables'; +import * as Report from '@userActions/Report'; +import * as Welcome from '@userActions/Welcome'; +import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow'; +import CONST from '@src/CONST'; +import type {OnboardingAccountingType} from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {} from '@src/types/onyx/Bank'; +import type {BaseOnboardingAccountingProps} from './types'; + +type OnboardingListItem = ListItem & { + keyForList: OnboardingAccountingType; +}; + +function BaseOnboardingAccounting({shouldUseNativeStyles, route}: BaseOnboardingAccountingProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + 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(''); + + const accountingOptions: OnboardingListItem[] = useMemo(() => { + const policyAccountingOptions = Object.values(CONST.POLICY.CONNECTIONS.NAME) + .map((connectionName): OnboardingListItem | undefined => { + let text; + let accountingIcon; + switch (connectionName) { + case CONST.POLICY.CONNECTIONS.NAME.QBO: { + text = translate('workspace.accounting.qbo'); + accountingIcon = Expensicons.QBOCircle; + break; + } + case CONST.POLICY.CONNECTIONS.NAME.XERO: { + text = translate('workspace.accounting.xero'); + accountingIcon = Expensicons.XeroCircle; + break; + } + case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: { + text = translate('workspace.accounting.netsuite'); + accountingIcon = Expensicons.NetSuiteSquare; + break; + } + case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: { + text = translate('workspace.accounting.intacct'); + accountingIcon = Expensicons.IntacctSquare; + break; + } + default: { + return; + } + } + return { + keyForList: connectionName, + text, + leftElement: ( + + ), + isSelected: userReportedIntegration === connectionName, + }; + }) + .filter((item): item is OnboardingListItem => !!item); + const noneAccountingOption: OnboardingListItem = { + keyForList: null, + text: translate('onboarding.accounting.noneOfAbove'), + leftElement: ( + + ), + isSelected: userReportedIntegration === null, + }; + return [...policyAccountingOptions, noneAccountingOption]; + }, [StyleUtils, styles.mr3, styles.onboardingSmallIcon, theme.success, translate, userReportedIntegration]); + + const footerContent = ( + <> + {!!error && ( + + )} +