diff --git a/android/app/build.gradle b/android/app/build.gradle index ce5927fc2ad9..907813d56e2e 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 1009006504 - versionName "9.0.65-4" + versionCode 1009006600 + versionName "9.0.66-0" // 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/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md deleted file mode 100644 index b231984f61e2..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -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 -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 -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 workspace. - -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 -There are multiple ways to pay Bills in Expensify. Let’s go over each method below. - -## 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 Bill that needs to be paid. -3. Click the Pay button to be redirected to the Bill. -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 a bill 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 Bill 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% of the total amount paid. - -## 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 -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:** -1. Sign in to your [Expensify web account](www.expensify.com). -2. Click on the Bill you’d like to pay to see the details. -3. Click the Reimburse button. -4. Choose **I’ll do it manually**. - -**Fees:** None. - -{% include faq-begin.md %} - -## Who receives vendor bills in Expensify? -Bills are sent to the Primary Contact listed under **Settings > Domains > [Domain Name] > Domain Admins**. - -## Who can view and pay a Bill? -Only the primary domain contact can view and pay a Bill. - -## How can others access Bills? -The primary contact can share Bills or grant Copilot access for others to manage payments. - -## Is Bill Pay supported internationally? -Currently, payments are only supported in USD. - -## What's the difference between a Bill and an Invoice in Expensify? -A Bill represents a payable amount owed to a vendor, while an Invoice is a receivable amount owed to you. -{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md new file mode 100644 index 000000000000..328b7f2051bc --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md @@ -0,0 +1,111 @@ +--- +title: Receive and Pay Bills +description: Expensify bill management and payment methods. +--- + +Easily receive and pay vendor or supplier bills directly in Expensify. Your vendors don’t even need an Expensify account! Manage everything seamlessly in one place. + +# Receiving Bills + +Expensify makes it easy to receive bills in three simple ways: + +### 1. Directly from Vendors +Share your Expensify billing email with vendors to receive bills automatically. + +- Set a Primary Contact under **Settings > Domains > Domain Admins**. +- Ask vendors to email bills to your billing address: `domainname@expensify.cash` (e.g., for *expensify.com*, use `expensify@expensify.cash`). +- Once emailed, the bill is automatically created in Expensify, ready for payment. + +### 2. Forwarding Emails +Received a bill in your email? Forward it to Expensify. + +- Ensure your Primary Contact is set under **Settings > Domains > Domain Admins**. +- Forward bills to `domainname@expensify.cash`. Example: `domainname@expensify.cash` (e.g., for *expensify.com*, use `expensify@expensify.cash`). +- Expensify will create a bill automatically, ready for payment. + +### 3. Manual Upload +Got a paper bill? Create a bill manually in [Expensify](https://www.expensify.com/): + +1. Log in to [Expensify](https://www.expensify.com). +2. Go to **Reports > New Report > Bill**. +3. Enter the invoice details: sender’s email, merchant name, amount, and date. +4. Upload the invoice as a receipt. + + +# Paying Bills in Expensify + +Expensify makes it easy to manage and pay vendor bills with a straightforward workflow and flexible payment options. Here’s how it works: + +## Bill Pay Workflow + +1. **SmartScan & Create**: When a vendor sends a bill, Expensify automatically SmartScans the document and creates a bill. +2. **Submission to Primary Contact**: The bill is submitted to the primary contact, who can review it on the Reports page under their default group policy. +3. **Communication**: If the approver needs clarification, they can communicate directly with the sender via the invoice linked to the bill. +4. **Approval Workflow**: Once reviewed, the bill follows your workspace’s approval process. The final approver handles the payment. +5. **Accounting Integration**: During approval, the bill is coded with the correct GL codes from your connected accounting software. Once approved, it can be exported back to your accounting system. + +## Payment Methods + +Expensify offers several ways to pay bills. Choose the method that works best for you: + +### 1. ACH Bank-to-Bank Transfer + +Fast and fee-free, this method requires a connected [business bank account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account). + +**How to Pay via ACH:** +1. Log in to your [Expensify web account](https://www.expensify.com/). +2. Find the bill on the Home or Reports page. +3. Click **Pay** and select the ACH option. + +**Fees:** None. + +--- + +### 2. Credit or Debit Card + +Pay vendors using a credit or debit card. This option is available for US and international customers paying US vendors with a US business bank account. + +**How to Pay with a Card:** +1. Log in to your [Expensify web account](https://www.expensify.com/). +2. Open the bill details and click **Pay**. +3. Enter your card information to complete the payment. + +**Fees:** 2.9% of the total amount paid. + +--- + +### 3. Venmo + +If both you and the vendor have Venmo accounts connected to Expensify, you can pay through Venmo. Learn how to set up Venmo [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. + +--- + +### 4. Pay Outside Expensify + +If you prefer to pay outside Expensify, you can still track the payment within the platform. + +**How to Mark as Paid Outside Expensify:** +1. Log in to your [Expensify web account](https://www.expensify.com/). +2. Open the bill details and click **Pay**. +3. Select **Mark as Paid** to update its status. + +**Fees:** None. +{% include faq-begin.md %} + +## Who receives vendor bills in Expensify? +bills are sent to the Primary Contact listed under **Settings > Domains > [Domain Name] > Domain Admins**. + +## Who can view and pay a bill? +Only the primary domain contact can view and pay a bill. + +## How can others access bills? +The primary contact can share bills or grant Copilot access for others to manage payments. + +## Is bill Pay supported internationally? +Currently, payments are only supported in USD. + +## What's the difference between a bill and an Invoice in Expensify? +A bill represents a payable amount owed to a vendor, while an Invoice is a receivable amount owed to you. +{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/domains/Claim-And-Verify-A-Domain.md b/docs/articles/expensify-classic/domains/Claim-And-Verify-A-Domain.md index ed74224c622e..ecb0b938aa8e 100644 --- a/docs/articles/expensify-classic/domains/Claim-And-Verify-A-Domain.md +++ b/docs/articles/expensify-classic/domains/Claim-And-Verify-A-Domain.md @@ -46,6 +46,6 @@ After successful verification, an email will be sent to all members of the Expen # Add another domain -To add an additional domain, you’ll have to first add your email address that is connected with your domain as your [primary or secondary email] (https://help.expensify.com/articles/expensify-classic/settings/account-settings/Change-or-add-email-address) (for example, if your domain is yourcompany.com, then you want to add and verify your email address @yourcompany.com as your primary or secondary email address). Then you can complete the steps above to add the domain. +To add an additional domain, you’ll have to first add your email address that is connected with your domain as your [primary or secondary email](https://help.expensify.com/articles/expensify-classic/settings/account-settings/Change-or-add-email-address) (for example, if your domain is yourcompany.com, then you want to add and verify your email address @yourcompany.com as your primary or secondary email address). Then you can complete the steps above to add the domain. diff --git a/docs/articles/new-expensify/connections/netsuite/Netsuite-Troubleshooting.md b/docs/articles/new-expensify/connections/netsuite/Netsuite-Troubleshooting.md index 2ac1aaadbef4..15a74cf925fa 100644 --- a/docs/articles/new-expensify/connections/netsuite/Netsuite-Troubleshooting.md +++ b/docs/articles/new-expensify/connections/netsuite/Netsuite-Troubleshooting.md @@ -1,6 +1,442 @@ --- title: Netsuite Troubleshooting -description: Coming soon +description: Troubleshoot common NetSuite sync and export errors. --- -# Coming soon +Synchronizing and exporting data between Expensify and NetSuite can streamline your financial processes, but occasionally, users may encounter errors that prevent a smooth integration. These errors often arise from discrepancies in settings, missing data, or configuration issues within NetSuite or Expensify. + +This troubleshooting guide aims to help you identify and resolve common sync and export errors, ensuring a seamless connection between your financial management systems. By following the step-by-step solutions provided for each specific error, you can quickly address issues and maintain accurate and efficient expense reporting and data management. + +# ExpensiError NS0005: Please enter value(s) for Department, Location or Class + +**Why does this happen?** + +This error occurs when the classification (like Location) is required at the header level of your transaction form in NetSuite. + +For expense reports and journal entries, NetSuite uses classifications from the employee record default. Expensify only exports this information at the line item level. + +For vendor bills, these classifications can't be mandatory because we use the vendor record instead of the employee record, and vendor records don’t have default classifications. + +## How to fix it for vendor bills + +Note: When exporting as a Vendor Bill, we pull from the vendor record, not the employee. Therefore, employee defaults don’t apply at the header ("main") level. This error appears if your NetSuite transaction form requires those fields. + +1. Go to **Customization > Forms > Transaction Forms**. +2. Click **"Edit"** on your preferred vendor bill form. +3. Go to **Screen Fields > Main**. +4. Uncheck both **"Show"** and **"Mandatory"** for the listed fields in your error message. +5. Sync the NetSuite connection in Expensify (**Settings > Workspaces > Workspace Name > Accounting > three-dot menu > Sync Now**.) +6. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. +Click on the report in question and it will open in the right-hand panel. +Click on Export to NetSuite to try to export again. + +## How to fix it for journal entries and expense reports + +Note: If you see this error when exporting a Journal Entry or Expense Report, it might be because the report submitter doesn’t have default settings for Departments, Classes, or Locations. + +1. Go to **Lists > Employees** in NetSuite. +2. Click **"Edit"** next to the employee's name who submitted the report. +3. Scroll down to the **Classification** section. +4. Select a default **Department**, **Class**, and **Location** for the employee. +5. Click **Save**. +6. Sync the NetSuite connection in Expensify (**Settings > Workspaces > Workspace Name > Accounting > three-dot menu > Sync Now**.) +7. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. +Click on the report in question and it will open in the right-hand panel. +Click on Export to NetSuite to try to export again. + + +# ExpensiError NS0012: Currency Does Not Exist In NetSuite + +**Why does this happen? (scenario 1)** + +When dealing with foreign transactions, Expensify sends the conversion rate and currency of the original expense to NetSuite. If the currency isn't listed in your NetSuite subsidiary, you'll see an error message saying the currency does not exist in NetSuite. + +## How to fix it + +1. Ensure the currency in Expensify matches what's in your NetSuite subsidiary. +2. If you see an error saying 'The currency X does not exist in NetSuite', re-sync your connection to NetSuite through the workspace admin section in Expensify. +3. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. +Click on the report in question and it will open in the right-hand panel. +Click on Export to NetSuite to try to export again. + +**Why does this happen? (scenario 2)** + +This error can happen if you’re using a non-OneWorld NetSuite instance and exporting a currency other than EUR, GBP, USD, or CAD. + +## How to fix it + +1. Head to NetSuite. +2. Go to **Setup > Enable Features**. +3. Check the **Multiple Currencies** box. + +Once you've done this, you can add the offending currency by searching **New Currencies** in the NetSuite global search. + +# ExpensiError NS0021: Invalid tax code reference key + +**Why does this happen?** + +This error usually indicates an issue with the Tax Group settings in NetSuite, which can arise from several sources. + +## How to fix it + +If a Tax Code on Sales Transactions is mapped to a Tax Group, an error will occur. To fix this, the Tax Code must be mapped to a Tax Code on Purchase Transactions instead. + +To verify if a Tax Code is for Sales or Purchase transactions, view the relevant Tax Code(s). + +**For Australian Taxes:** + +Ensure your Tax Groups are mapped correctly: +- **GST 10%** to **NCT-AU** (not the Sales Transaction Tax Code TS-AU) +- **No GST 0%** to **NCF-AU** (not the Sales Transaction Tax Code TFS-AU) + +### Tax Group Type +Tax Groups can represent different types of taxes. For compatibility with Expensify, ensure the tax type is set to GST/VAT. + +### Enable Tax Groups +Some subsidiaries require you to enable Tax Groups. Go to **Set Up Taxes** for the subsidiary's country and ensure the Tax Code lists include both Tax Codes and Tax Groups. + +# ExpensiError NS0023: Employee Does Not Exist in NetSuite (Invalid Employee) + +**Why does this happen?** + +This can happen if the employee’s subsidiary in NetSuite doesn’t match the subsidiary selected for the connection in Expensify. + +## How to fix it + +1. **Check the Employee's Subsidiary** + - Go to the employee record in NetSuite. + - Confirm the employee's subsidiary matches what’s listed as the subsidiary at the workspace level. + - To find this in Expensify navigate to **Settings > Workspaces > click workspace name > Accounting > Subsidiary**. + - If the subsidiaries don’t match, update the subsidiary in Expensify to match what’s listed in NetSuite. + - Sync the NetSuite connection in Expensify (**Settings > Workspaces > click workspace name > Accounting > three-dot menu > Sync Now**.) +2. **Verify Access Restrictions:** + - Go to **Lists > Employees > Employees > [Select Employee] > Edit > Access**. + - Uncheck **Restrict Access to Expensify**. +3. **Additional Checks:** + - Ensure the email on the employee record in NetSuite matches the email address of the report submitter in Expensify. + - In NetSuite, make sure the employee's hire date is in the past and/or the termination date is in the future. +4. **Currency Match for Journal Entries:** + - If exporting as Journal Entries, ensure the currency for the NetSuite employee record, NetSuite subsidiary, and Expensify workspace all match. + - In NetSuite, go to the **Human Resources** tab > **Expense Report Currencies**, and add the subsidiary/policy currency if necessary. + +# ExpensiError NS0085: Expense Does Not Have Appropriate Permissions for Settings an Exchange Rate in NetSuite + +**Why does this happen?** + +This error occurs when the exchange rate settings in NetSuite aren't updated correctly. + +## How to fix it + +1. In NetSuite, go to Customization > Forms > Transaction Forms. +2. Search for the form type that the report is being exported as (Expense Report, Journal Entry, or Vendor Bill) and click Edit next to the form that has the Preferred checkbox checked. + - **For Expense Reports:** + - Go to Screen Fields > Expenses (the Expenses tab farthest to the right). + - Ensure the Exchange Rate field under the Description column has the Show checkbox checked. + - **For Vendor Bills:** + - Go to Screen Fields > Main. + - Ensure the Exchange Rate field under the Description column has the Show checkbox checked. + - **For Journal Entries:** + - Go to Screen Fields > Lines. + - Ensure the Exchange Rate field under the Description column has the Show checkbox checked. + - Go to Screen Fields > Main and ensure the Show checkbox is checked in the Exchange Rate field under the Description column. +3. Sync the NetSuite connection in Expensify (**Settings > Workspaces > Workspace Name > Accounting > three-dot menu > Sync Now**.) +4. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. +Click on the report in question and it will open in the right-hand panel. +Click on Export to NetSuite to try to export again. + +# ExpensiError NS0079: The Transaction Date is Not Within the Date Range of Your Accounting Period + +**Why does this happen?** + +The transaction date you specified is not within the date range of your accounting period. When the posting period settings in NetSuite are not configured to allow a transaction date outside the posting period, you can't export a report to the next open period, which is why you’ll run into this error. + +## How to fix it + +1. In NetSuite, navigate to Setup > Accounting > Accounting Preferences. +2. Under the General Ledger section, ensure the field Allow Transaction Date Outside of the Posting Period is set to Warn. +3. Then, choose whether to export your reports to the First Open Period or the Current Period. + +**Additionally, ensure the Export to Next Open Period feature is enabled within Expensify:** +1. Navigate to **Settings > Workspaces > Workspace Name > Accounting > Export. +2. Scroll down and confirm that the toggle for **Export to next open period** is enabled. + +If any configuration settings are updated on the NetSuite connection, be sure to sync the connection before trying the export again. + +# ExpensiError NS0055: The Vendor You are Trying to Export to Does Not Have Access to the Currency X + +**Why does this happen?** + +This error occurs when a vendor tied to a report in Expensify does not have access to a currency on the report in NetSuite. The vendor used in NetSuite depends on the type of expenses on the report you're exporting. + +- For **reimbursable** (out-of-pocket) expenses, this is the employee who submitted the report. +- For **non-reimbursable** (e.g., company card) expenses, this is the default vendor set via the Settings > Workspaces > click workspace name > Accounting > Export settings. + +## How to fix it + +To fix this, the vendor needs to be given access to the applicable currency: +1. In NetSuite, navigate to Lists > Relationships > Vendors to access the list of Vendors. +2. Click Edit next to the Vendor tied to the report: + - For reimbursable (out-of-pocket) expenses, this is the report's submitter. + - For non-reimbursable (e.g., company card) expenses, this is the default vendor set via **Settings > Workspaces > click workspace name > Accounting > Export > click Export company card expenses as > Default vendor.** +3. Navigate to the Financial tab. +4. Scroll down to the Currencies section and add all the currencies that are on the report you are trying to export. +5. Click Save. + +# ExpensiError NS0068: You do not have permission to set a value for element - “Created From” + +**Why does this happen?** + +This error typically occurs due to insufficient permissions or misconfigured settings in NetSuite on the preferred transaction form for your export type. + +## How to fix it + +1. In NetSuite, go to Customization > Forms > Transaction Forms. +2. Search for the form type that the report is being exported as in NetSuite (Expense Report, Journal Entry, Vendor Bill, or if the report total is negative, Vendor Credit). +3. Click Edit next to the form that has the Preferred checkbox checked. +4. Go to Screen Fields > Main and ensure the field Created From has the Show checkbox checked. +5. Sync the NetSuite connection in Expensify (**Settings > Workspaces > Workspace Name > Accounting > three-dot menu > Sync Now**.) +6. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. +Click on the report in question and it will open in the right-hand panel. +Click on Export to NetSuite to try to export again. + +## ExpensiError NS0068: Reports with Expensify Card expenses + +**Why does this happen?** + +Expensify Card expenses export as Journal Entries. If you encounter this error when exporting a report with Expensify Card non-reimbursable expenses, ensure the field Created From has the Show checkbox checked for Journal Entries in NetSuite. + +## How to fix it +1. In NetSuite, go to Customization > Forms > Transaction Forms. +2. Click Edit next to the journal entry form that has the Preferred checkbox checked. +3. Ensure the field Created From has the Show checkbox checked. +4. Sync the NetSuite connection in Expensify (**Settings > Workspaces > Workspace Name > Accounting > three-dot menu > Sync Now**.) +5. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. +Click on the report in question and it will open in the right-hand panel. +Click on Export to NetSuite to try to export again. + +# ExpensiError NS0037: You do not have permission to set a value for element - “Receipt URL” + +**Why does this happen?** + +This error typically occurs due to insufficient permissions or misconfigured settings in NetSuite on the preferred transaction form for your export type. + +## How to fix it + +1. In NetSuite, go to Customization > Forms > Transaction Forms. +2. Search for the form type that the report is being exported as in NetSuite (Expense Report, Journal Entry, or Vendor Bill). +3. Click Edit next to the form that has the Preferred checkbox checked. + - If the report is being exported as an Expense Report: + - Go to Screen Fields > Expenses (the Expenses tab farthest to the right). + - Ensure the field ReceiptURL has the Show checkbox checked. + - If the report is being exported as a Journal Entry: + - Go to Screen Fields > Lines. + - Ensure the field ReceiptURL has the Show checkbox checked. + - If the report is being exported as a Vendor Bill: + - Go to Screen Fields > Main. + - Ensure the field ReceiptURL has the Show checkbox checked. +4. Sync the NetSuite connection in Expensify (**Settings > Workspaces > click workspace name > Accounting > three-dot menu > Sync Now**.) +5. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. +Click on the report in question and it will open in the right-hand panel. +Click on Export to NetSuite to try to export again. + +# ExpensiError NS0042: Error creating vendor - this entity already exists + +**Why does this happen?** + +This error occurs when a vendor record already exists in NetSuite, but Expensify is still attempting to create a new one. This typically means that Expensify cannot find the existing vendor during export. +- The vendor record already exists in NetSuite, but there may be discrepancies preventing Expensify from recognizing it. +- The email on the NetSuite vendor record does not match the email of the report submitter in Expensify. +- The vendor record might not be associated with the correct subsidiary in NetSuite. + +## How to fix it + +1. **Check Email Matching:** + - Ensure the email on the NetSuite vendor record matches the email of the report submitter in Expensify. + - If it doesn’t match update the existing vendor record in NetSuite to match the report submitter's email and name. + - If there is no email listed, add the email address of the report’s submitter to the existing vendor record in NetSuite. +2. **Check Subsidiary Association:** + - Ensure the vendor record is associated with the same subsidiary selected in the connection configurations + - You can review this under **Settings > Workspaces > click workspace name > Accounting > Subsidiary.** +3. **Automatic Vendor Creation:** + - If you want Expensify to automatically create vendors, ensure the "Auto-create employees/vendors" option is enabled under **Settings > Workspaces > click workspace name > Accounting > Advanced.** + - If appropriate, delete the existing vendor record in NetSuite to allow Expensify to create a new one. +4. After making the necessary changes, sync the NetSuite connection in Expensify (**Settings > Workspaces > click workspace name > Accounting > three-dot menu > Sync Now**.) +5. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. +Click on the report in question and it will open in the right-hand panel. +Click on Export to NetSuite to try to export again. + +# ExpensiError NS0109: Failed to login to NetSuite, please verify your credentials + +**Why does this happen?** + +This error indicates a problem with the tokens created for the connection between Expensify and NetSuite. The error message will say, "Login Error. Please check your credentials." + +## How to fix it + +1. Review the [Connect to NetSuite](https://help.expensify.com/articles/new-expensify/connections/netsuite/Connect-to-NetSuite) guide and follow steps 1 and 2 exactly as outlined. +2. If you're using an existing token and encounter a problem, you may need to create a new token. + +# ExpensiError NS0123 Login Error: Please make sure that the Expensify integration is enabled + +**Why does this happen?** + +This error indicates that the Expensify integration is not enabled in NetSuite. + +## How to fix it + +1. **Enable the Expensify Integration:** + - In NetSuite, navigate to Setup > Integrations > Manage Integrations. + - Ensure that the Expensify Integration is listed and that the State is Enabled. +2. **If you can't find the Expensify integration:** + - Click "Show Inactives" to see if Expensify is listed as inactive. + - If Expensify is listed, update its state to Enabled. +3. Once the Expensify integration is enabled, sync the NetSuite connection in Expensify (**Settings > Workspaces > Workspace Name > Accounting > three-dot menu > Sync Now**.) + +# ExpensiError NS0045: Expenses Not Categorized with a NetSuite Account + +**Why does this happen?** + +This happens when approved expenses are categorized with an option that didn’t import from NetSuite. For NetSuite to accept expense coding, it must first exits and be imported into Expensify from NetSuite. + +## How to fix it + +1. Log into NetSuite +2. Do a global search for the missing record. + - Ensure the expense category is active and correctly named. + - Ensure the category is associated with the correct subsidiary that the Expensify workspace is linked to. +3. Sync the NetSuite connection in Expensify (**Settings > Workspaces > click workspace name > Accounting > three-dot menu > Sync Now**.) +4. Go back to the report, click on the offending expense(s), and re-apply the category in question. +5. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. +Click on the report in question and it will open in the right-hand panel. +Click on Export to NetSuite to try to export again. + + +# ExpensiError NS0061: Please Enter Value(s) for: Tax Code + +**Why does this happen?** + +This error typically occurs when attempting to export expense reports to a Canadian subsidiary in NetSuite for the first time and/or if your subsidiary in NetSuite has Tax enabled. + +## How to fix it + +To fix this, you need to enable Tax in the NetSuite configuration settings. + +1. Go to **Settings > Workspaces > click workspace name > Accounting > Export**. + - Select a Journal Entry tax posting account if you plan on exporting any expenses with taxes. +2. Wait for the connection to sync, it will automatically do so after you make a change. +3. Attempt the export again. + +**Note:** Expenses created before Tax was enabled might need to have the newly imported taxes applied to them retroactively to be exported. + +# Error creating employee: Your current role does not have permission to access this record. + +**Why does this happen?** + +This error indicates that the credentials or role used to connect NetSuite to Expensify do not have the necessary permissions within NetSuite. You can find setup instructions for configuring permissions in NetSuite [here](https://help.expensify.com/articles/new-expensify/connections/netsuite/Connect-to-NetSuite). + +## How to fix it + +1. If permissions are configured correctly, confirm the report submitter exists in the subsidiary set for the workspace connection and that their Expensify email address matches the email on the NetSuite Employee Record. +2. If the above is true, try toggling off _Auto create employees/vendors_ under the **Settings > Workspaces > Group > click workspace name > Accounting > Advanced tab of the NetSuite configuration window. +3. Sync the NetSuite connection in Expensify (**Settings > Workspaces > click workspace name > Accounting > three-dot menu > Sync Now**.) +4. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. +Click on the report in question and it will open in the right-hand panel. +Click on Export to NetSuite to try to export again. + +# Elimination Settings for X Do Not Match + +**Why does this happen?** + +This error occurs when an Intercompany Payable account is set as the default in the Default Payable Account field in the NetSuite subsidiary preferences, and the Accounting Approval option is enabled for Expense Reports. + +## How to fix it + +Set the Default Payable Account for Expense Reports on each subsidiary in NetSuite to ensure the correct payable account is active. + +1. Navigate to Subsidiaries: + - Go to Setup > Company > Subsidiaries. +2. Edit Subsidiary Preferences: + - Click Edit for the desired subsidiary. + - Go to the Preferences tab. +3. Set Default Payable Account: + - Choose the preferred account for Default Payable Account for Expense Reports. + +Repeat these steps for each subsidiary to ensure the settings are correct, and then sync the NetSuite connection in Expensify (**Settings > Workspaces > click workspace name > Accounting > three-dot menu > Sync Now**.) + +# ExpensiError NS0046: Billable Expenses Not Coded with a NetSuite Customer or Billable Project + +**Why does this happen?** + +NetSuite requires billable expenses to be assigned to a Customer or a Project that is configured as billable to a Customer. If this is not set up correctly in NetSuite, this error can occur. + +## How to fix it + +1. Check the billable expenses and confirm that a Customer or Project tag is selected. +2. Make any necessary adjustments to the billable expense. +3. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. +Click on the report in question and it will open in the right-hand panel. +Click on Export to NetSuite to try to export again. + +{% include faq-begin.md %} +## Why are reports exporting as _Accounting Approved_ instead of _Paid in Full_? + +**This can occur for two reasons:** +- Missing Locations, Classes, or Departments in the Bill Payment Form +- Incorrect Settings in Expensify Workspace Configuration + +**Missing Locations, Classes, or Departments in Bill Payment Form:** If locations, classes, or departments are required in your accounting classifications but are not marked as 'Show' on the preferred bill payment form, this error can occur, and you will need to update the bill payment form in NetSuite: + +1. Go to Customization > Forms > Transaction Forms. +2. Find your preferred (checkmarked) Bill Payment form. +3. Click Edit or Customize. +4. Under the Screen Fields > Main tab, check 'Show' near the department, class, and location options. + +**Incorrect Settings in Expensify Workspace Configuration:** To fix this, you'll want to confirm the NetSuite connection settings are set up correctly in Expensify: + +1. Head to **Settings > Workspaces > click workspace name > Accounting > Advanced.** +2. **Ensure the following settings are correct:** + - Sync Reimbursed Reports: Enabled and payment account chosen. + - Journal Entry Approval Level: Approved for Posting. + - A/P Approval Account: This must match the current account being used for bill payment. +3. **Verify A/P Approval Account:** + - To ensure the A/P Approval Account matches the account in NetSuite: + - Go to your bill/expense report causing the error. + - Click Make Payment. + - This account needs to match the account selected in your Expensify configuration. +4. **Check Expense Report List:** + - Make sure this is also the account selected on the expense report by looking at the expense report list. + +Following these steps will help ensure that reports are exported as "Paid in Full" instead of "Accounting Approved." + +## Why are reports exporting as _Pending Approval_? +If reports are exporting as "Pending Approval" instead of "Approved," you'll need to adjust the approval preferences in NetSuite. + +**Exporting as Journal Entries/Vendor Bills:** +1. In NetSuite, go to Setup > Accounting > Accounting Preferences. +2. On the **General** tab, uncheck **Require Approvals on Journal Entries**. +3. On the **Approval Routing** tab, uncheck Journal Entries/Vendor Bills to remove the approval requirement for Journal Entries created in NetSuite. + +**Note:** This change affects all Journal Entries, not just those created by Expensify. + +**Exporting as Expense Reports:** +1. In NetSuite, navigate to Setup > Company > Enable Features. +2. On the "Employee" tab, uncheck "Approval Routing" to remove the approval requirement for Expense Reports created in NetSuite. Please note that this setting also applies to purchase orders. + +## How do I Change the Default Payable Account for Reimbursable Expenses in NetSuite? + +NetSuite is set up with a default payable account that is credited each time reimbursable expenses are exported as Expense Reports to NetSuite (once approved by the supervisor and accounting). If you need to change this to credit a different account, follow the below steps: + +**For OneWorld Accounts:** +1. Navigate to Setup > Company > Subsidiaries in NetSuite. +2. Next to the subsidiary you want to update, click Edit. +3. Click the Preferences tab. +4. In the Default Payable Account for Expense Reports field, select the desired payable account. +5. Click Save. + +**For Non-OneWorld Accounts:** +1. Navigate to Setup > Accounting > Accounting Preferences in NetSuite. +2. Click the Time & Expenses tab. +3. Under the Expenses section, locate the Default Payable Account for Expense Reports field and choose the preferred account. +4. Click Save. + +{% include faq-end.md %} diff --git a/docs/redirects.csv b/docs/redirects.csv index 5c83d510ccb8..e1c0e12eb070 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -603,3 +603,4 @@ https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-T https://help.expensify.com/articles/expensify-classic/spending-insights/Default-Export-Templates,https://help.expensify.com/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports/ https://help.expensify.com/articles/expensify-classic/spending-insights/Other-Export-Options,https://help.expensify.com/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports/ https://help.expensify.com/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements,https://help.expensify.com/articles/expensify-classic/travel/Book-with-Expensify-Travel +https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills diff --git a/help/README.md b/help/README.md index d62513f07f53..c0fb4dbf524a 100644 --- a/help/README.md +++ b/help/README.md @@ -48,7 +48,7 @@ Every PR pushed by an authorized Expensify employee or representative will autom 3. Install Ruby and Jekyll 4. Build the entire site using Jekyll 5. Create a "preview" of the newly built site in Cloudflare -6. Record a link to that preview in the PR. +6. Record a link to that preview in the PR ## How to deploy the site for real Whenever a PR that touches the `/help` directory is merged, it will re-run the build just like before. However, it will detect that this build is being run from the `main` branch, and thus push the changes to the `production` Cloudflare environment -- meaning, it will replace the contents hosted at https://newhelp.expensify.com diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 91cb6a7f745b..0feab9ddaced 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.65 + 9.0.66 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.65.4 + 9.0.66.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 38b36ea381f6..d1921d0b1b65 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.65 + 9.0.66 CFBundleSignature ???? CFBundleVersion - 9.0.65.4 + 9.0.66.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index def1f5fd29dd..111c5363813a 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.65 + 9.0.66 CFBundleVersion - 9.0.65.4 + 9.0.66.0 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 90868fbb3a55..83d0c122ab2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.65-4", + "version": "9.0.66-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.65-4", + "version": "9.0.66-0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 5be8ade75636..4df25219e277 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.65-4", + "version": "9.0.66-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 2c44551acaa7..d8f8b0f91105 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1295,6 +1295,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/per-diem', getRoute: (policyID: string) => `settings/workspaces/${policyID}/per-diem` as const, }, + WORKSPACE_PER_DIEM_SETTINGS: { + route: 'settings/workspaces/:policyID/per-diem/settings', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/per-diem/settings` as const, + }, RULES_CUSTOM_NAME: { route: 'settings/workspaces/:policyID/rules/name', getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/name` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 536dddb1f637..5fd64b0fc0d0 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -546,6 +546,7 @@ const SCREENS = { RULES_MAX_EXPENSE_AGE: 'Rules_Max_Expense_Age', RULES_BILLABLE_DEFAULT: 'Rules_Billable_Default', PER_DIEM: 'Per_Diem', + PER_DIEM_SETTINGS: 'Per_Diem_Settings', }, EDIT_REQUEST: { diff --git a/src/components/CategoryPicker.tsx b/src/components/CategoryPicker.tsx index 0c855507371a..19bb98bff58e 100644 --- a/src/components/CategoryPicker.tsx +++ b/src/components/CategoryPicker.tsx @@ -3,6 +3,8 @@ import {useOnyx} from 'react-native-onyx'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import * as CategoryOptionsListUtils from '@libs/CategoryOptionListUtils'; +import type {Category} from '@libs/CategoryOptionListUtils'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -27,7 +29,7 @@ function CategoryPicker({selectedCategory, policyID, onSubmit}: CategoryPickerPr const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const offlineMessage = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; - const selectedOptions = useMemo(() => { + const selectedOptions = useMemo((): Category[] => { if (!selectedCategory) { return []; } @@ -35,8 +37,8 @@ function CategoryPicker({selectedCategory, policyID, onSubmit}: CategoryPickerPr return [ { name: selectedCategory, - accountID: undefined, isSelected: true, + enabled: true, }, ]; }, [selectedCategory]); @@ -44,11 +46,9 @@ function CategoryPicker({selectedCategory, policyID, onSubmit}: CategoryPickerPr const [sections, headerMessage, shouldShowTextInput] = useMemo(() => { const categories = policyCategories ?? policyCategoriesDraft ?? {}; const validPolicyRecentlyUsedCategories = policyRecentlyUsedCategories?.filter?.((p) => !isEmptyObject(p)); - const {categoryOptions} = OptionsListUtils.getFilteredOptions({ + const categoryOptions = CategoryOptionsListUtils.getCategoryListSections({ searchValue: debouncedSearchValue, selectedOptions, - includeP2P: false, - includeCategories: true, categories, recentlyUsedCategories: validPolicyRecentlyUsedCategories, }); diff --git a/src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx b/src/components/CategorySelector/CategorySelectorModal.tsx similarity index 100% rename from src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx rename to src/components/CategorySelector/CategorySelectorModal.tsx diff --git a/src/pages/workspace/distanceRates/CategorySelector/index.tsx b/src/components/CategorySelector/index.tsx similarity index 100% rename from src/pages/workspace/distanceRates/CategorySelector/index.tsx rename to src/components/CategorySelector/index.tsx diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 2f1d459e369a..a0143f87e789 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -1,6 +1,6 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'; import lodashIsEqual from 'lodash/isEqual'; -import React, {memo, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; @@ -46,7 +46,6 @@ import type {SectionListDataType} from './SelectionList/types'; import UserListItem from './SelectionList/UserListItem'; import SettlementButton from './SettlementButton'; import Text from './Text'; -import {KeyboardStateContext} from './withKeyboardState'; type MoneyRequestConfirmationListProps = { /** Callback to inform parent modal of success */ @@ -195,7 +194,7 @@ function MoneyRequestConfirmationList({ const styles = useThemeStyles(); const {translate, toLocaleDigit} = useLocalize(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {isKeyboardShown, isWindowHeightReducedByKeyboard} = useContext(KeyboardStateContext); + const isTypeRequest = iouType === CONST.IOU.TYPE.SUBMIT; const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT; const isTypeSend = iouType === CONST.IOU.TYPE.PAY; @@ -825,7 +824,7 @@ function MoneyRequestConfirmationList({ }, [routeError, isTypeSplit, shouldShowReadOnlySplits, debouncedFormError, formError, translate]); const footerContent = useMemo(() => { - if (isReadOnly || isKeyboardShown || isWindowHeightReducedByKeyboard) { + if (isReadOnly) { return; } @@ -877,20 +876,7 @@ function MoneyRequestConfirmationList({ {button} ); - }, [ - isReadOnly, - iouType, - confirm, - bankAccountRoute, - iouCurrencyCode, - policyID, - splitOrRequestOptions, - styles.ph1, - styles.mb2, - errorMessage, - isKeyboardShown, - isWindowHeightReducedByKeyboard, - ]); + }, [isReadOnly, iouType, confirm, bankAccountRoute, iouCurrencyCode, policyID, splitOrRequestOptions, styles.ph1, styles.mb2, errorMessage]); const listFooterContent = ( { - const newSections: OptionsListUtils.CategorySection[] = []; + const newSections: OptionsListUtils.Section[] = []; if (!areOptionsInitialized) { return {sections: [], headerMessage: undefined}; } diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 1e0c3ca8aae7..bb45e30cc5ad 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -25,7 +25,6 @@ const defaultListOptions = { personalDetails: [], currentUserOption: null, headerMessage: '', - categoryOptions: [], }; function getSelectedOptionData(option: Option): OptionData { @@ -73,7 +72,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: }, [defaultOptions, cleanSearchTerm, selectedOptions]); const {sections, headerMessage} = useMemo(() => { - const newSections: OptionsListUtils.CategorySection[] = []; + const newSections: OptionsListUtils.Section[] = []; if (!areOptionsInitialized) { return {sections: [], headerMessage: undefined}; } diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index b05d34b2351b..87cb065d5c88 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -75,7 +75,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) const {options, areOptionsInitialized} = useOptionsList(); const searchOptions = useMemo(() => { if (!areOptionsInitialized) { - return {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null, categoryOptions: []}; + return {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null}; } return OptionsListUtils.getSearchOptions(options, '', betas ?? []); }, [areOptionsInitialized, betas, options]); diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index 930d4139d388..a1c7dc9cf2a7 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -67,7 +67,7 @@ type ValidateCodeFormProps = { /** Function is called when validate code modal is mounted and on magic code resend */ sendValidateCode: () => void; - /** Wheather the form is loading or not */ + /** Whether the form is loading or not */ isLoading?: boolean; }; diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/index.android.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/index.android.tsx index 704405f93a2c..08c2b087b7d5 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/index.android.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/index.android.tsx @@ -1,4 +1,5 @@ import React, {forwardRef} from 'react'; +import {gestureHandlerRootHOC} from 'react-native-gesture-handler'; import BaseValidateCodeForm from './BaseValidateCodeForm'; import type {ValidateCodeFormHandle, ValidateCodeFormProps} from './BaseValidateCodeForm'; @@ -11,4 +12,4 @@ const ValidateCodeForm = forwardRef )); -export default ValidateCodeForm; +export default gestureHandlerRootHOC(ValidateCodeForm); diff --git a/src/components/ValidateCodeActionModal/index.tsx b/src/components/ValidateCodeActionModal/index.tsx index 470d846ccc76..020facff8c9c 100644 --- a/src/components/ValidateCodeActionModal/index.tsx +++ b/src/components/ValidateCodeActionModal/index.tsx @@ -77,6 +77,7 @@ function ValidateCodeActionModal({ {descriptionPrimary} {!!descriptionSecondary && {descriptionSecondary}} {footer?.()} diff --git a/src/components/ValidateCodeActionModal/type.ts b/src/components/ValidateCodeActionModal/type.ts index 2fbf88768e62..5537af67b89d 100644 --- a/src/components/ValidateCodeActionModal/type.ts +++ b/src/components/ValidateCodeActionModal/type.ts @@ -41,7 +41,7 @@ type ValidateCodeActionModalProps = { /** If the magic code has been resent previously */ hasMagicCodeBeenSent?: boolean; - /** Wheather the form is loading or not */ + /** Whether the form is loading or not */ isLoading?: boolean; }; diff --git a/src/components/withKeyboardState.tsx b/src/components/withKeyboardState.tsx index 6184213027af..72540ebceaa8 100755 --- a/src/components/withKeyboardState.tsx +++ b/src/components/withKeyboardState.tsx @@ -1,7 +1,6 @@ import type {ComponentType, ForwardedRef, ReactElement, RefAttributes} from 'react'; import React, {createContext, forwardRef, useEffect, useMemo, useState} from 'react'; import {Keyboard} from 'react-native'; -import useIsWindowHeightReducedByKeyboard from '@hooks/useIsWindowHeightReducedByKeyboard'; import getComponentDisplayName from '@libs/getComponentDisplayName'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; @@ -11,9 +10,6 @@ type KeyboardStateContextValue = { /** Height of the keyboard in pixels */ keyboardHeight: number; - - /** Whether window height is smaller than usual due to the keyboard being open */ - isWindowHeightReducedByKeyboard?: boolean; }; const KeyboardStateContext = createContext({ @@ -23,7 +19,6 @@ const KeyboardStateContext = createContext({ function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null { const [keyboardHeight, setKeyboardHeight] = useState(0); - const isWindowHeightReducedByKeyboard = useIsWindowHeightReducedByKeyboard(); useEffect(() => { const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', (e) => { @@ -43,9 +38,8 @@ function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null { () => ({ keyboardHeight, isKeyboardShown: keyboardHeight !== 0, - isWindowHeightReducedByKeyboard, }), - [keyboardHeight, isWindowHeightReducedByKeyboard], + [keyboardHeight], ); return {children}; } diff --git a/src/hooks/useIsWindowHeightReducedByKeyboard/index.ts b/src/hooks/useIsWindowHeightReducedByKeyboard/index.ts deleted file mode 100644 index 7895c7209115..000000000000 --- a/src/hooks/useIsWindowHeightReducedByKeyboard/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {useCallback, useEffect, useState} from 'react'; -import usePrevious from '@hooks/usePrevious'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useWindowDimensions from '@hooks/useWindowDimensions'; - -const useIsWindowHeightReducedByKeyboard = () => { - const [isWindowHeightReducedByKeyboard, setIsWindowHeightReducedByKeyboard] = useState(false); - const {windowHeight} = useWindowDimensions(); - const prevWindowHeight = usePrevious(windowHeight); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const toggleKeyboardOnSmallScreens = useCallback( - (isKBOpen: boolean) => { - if (!shouldUseNarrowLayout) { - return; - } - setIsWindowHeightReducedByKeyboard(isKBOpen); - }, - [shouldUseNarrowLayout], - ); - useEffect(() => { - // Use window height changes to toggle the keyboard. To maintain keyboard state - // on all platforms we also use focus/blur events. So we need to make sure here - // that we avoid redundant keyboard toggling. - // Minus 100px is needed to make sure that when the internet connection is - // disabled in android chrome and a small 'No internet connection' text box appears, - // we do not take it as a sign to open the keyboard - if (!isWindowHeightReducedByKeyboard && windowHeight < prevWindowHeight - 100) { - toggleKeyboardOnSmallScreens(true); - } else if (isWindowHeightReducedByKeyboard && windowHeight > prevWindowHeight) { - toggleKeyboardOnSmallScreens(false); - } - }, [isWindowHeightReducedByKeyboard, prevWindowHeight, toggleKeyboardOnSmallScreens, windowHeight]); - - return isWindowHeightReducedByKeyboard; -}; - -export default useIsWindowHeightReducedByKeyboard; diff --git a/src/languages/en.ts b/src/languages/en.ts index 9bea1261ddbd..3e1ea7f0d7cc 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -316,7 +316,6 @@ const translations = { owner: 'Owner', dateFormat: 'YYYY-MM-DD', send: 'Send', - notifications: 'Notifications', na: 'N/A', noResultsFound: 'No results found', recentDestinations: 'Recent destinations', @@ -1413,7 +1412,7 @@ const translations = { enableWalletToSendAndReceiveMoney: 'Enable your wallet to send and receive money with friends.', walletEnabledToSendAndReceiveMoney: 'Your wallet has been enabled to send and receive money with friends.', enableWallet: 'Enable wallet', - addBankAccountToSendAndReceive: 'Add a bank account to get paid back for expenses you submit to a workspace.', + addBankAccountToSendAndReceive: 'Get paid back for expenses you submit to a workspace.', addBankAccount: 'Add bank account', assignedCards: 'Assigned cards', assignedCardsDescription: 'These are cards assigned by a workspace admin to manage company spend.', @@ -1617,7 +1616,6 @@ const translations = { preferencesPage: { appSection: { title: 'App preferences', - subtitle: 'Customize your Expensify account.', }, testSection: { title: 'Test preferences', @@ -2526,6 +2524,7 @@ const translations = { return 'Member'; } }, + defaultCategory: 'Default category', }, perDiem: { subtitle: 'Set per diem rates to control daily employee spend. ', @@ -4091,7 +4090,6 @@ const translations = { unit: 'Unit', taxFeatureNotEnabledMessage: 'Taxes must be enabled on the workspace to use this feature. Head over to ', changePromptMessage: ' to make that change.', - defaultCategory: 'Default category', deleteDistanceRate: 'Delete distance rate', areYouSureDelete: () => ({ one: 'Are you sure you want to delete this rate?', diff --git a/src/languages/es.ts b/src/languages/es.ts index 7e6f8efc897a..d28a19fbb1be 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -307,7 +307,6 @@ const translations = { owner: 'Dueño', dateFormat: 'AAAA-MM-DD', send: 'Enviar', - notifications: 'Notificaciones', na: 'N/A', noResultsFound: 'No se han encontrado resultados', recentDestinations: 'Destinos recientes', @@ -642,8 +641,8 @@ const translations = { copyEmailToClipboard: 'Copiar email al portapapeles', markAsUnread: 'Marcar como no leído', markAsRead: 'Marcar como leído', - 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'}`, + editAction: ({action}: EditActionParams) => `Editar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'gasto' : 'comentario'}`, + deleteAction: ({action}: DeleteActionParams) => `Eliminar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'gasto' : 'comentario'}`, deleteConfirmation: ({action}: DeleteConfirmationParams) => `¿Estás seguro de que quieres eliminar este ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'gasto' : 'comentario'}?`, onlyVisible: 'Visible sólo para', @@ -1414,7 +1413,7 @@ const translations = { enableWalletToSendAndReceiveMoney: 'Habilita tu Billetera Expensify para comenzar a enviar y recibir dinero con amigos.', walletEnabledToSendAndReceiveMoney: 'Tu billetera ha sido habilitada para enviar y recibir dinero con amigos.', enableWallet: 'Habilitar billetera', - addBankAccountToSendAndReceive: 'Añade una cuenta bancaria para recibir reembolsos por los gastos que envíes a un espacio de trabajo.', + addBankAccountToSendAndReceive: 'Recibe el reembolso de los gastos que envíes a un espacio de trabajo.', addBankAccount: 'Añadir cuenta bancaria', assignedCards: 'Tarjetas asignadas', assignedCardsDescription: 'Son tarjetas asignadas por un administrador del espacio de trabajo para gestionar los gastos de la empresa.', @@ -1619,7 +1618,6 @@ const translations = { preferencesPage: { appSection: { title: 'Preferencias de la aplicación', - subtitle: 'Personaliza tu cuenta de Expensify.', }, testSection: { title: 'Preferencias para tests', @@ -2550,6 +2548,7 @@ const translations = { return 'Miembro'; } }, + defaultCategory: 'Categoría predeterminada', }, perDiem: { subtitle: 'Establece las tasas per diem para controlar los gastos diarios de los empleados. ', @@ -4136,7 +4135,6 @@ const translations = { unit: 'Unidad', taxFeatureNotEnabledMessage: 'Los impuestos deben estar activados en el área de trabajo para poder utilizar esta función. Dirígete a ', changePromptMessage: ' para hacer ese cambio.', - defaultCategory: 'Categoría predeterminada', deleteDistanceRate: 'Eliminar tasa de distancia', areYouSureDelete: () => ({ one: '¿Estás seguro de que quieres eliminar esta tasa?', diff --git a/src/libs/API/parameters/EnableDistanceRequestTaxParams.ts b/src/libs/API/parameters/EnableDistanceRequestTaxParams.ts new file mode 100644 index 000000000000..0d42d9ba84b4 --- /dev/null +++ b/src/libs/API/parameters/EnableDistanceRequestTaxParams.ts @@ -0,0 +1,6 @@ +type EnableDistanceRequestTaxParams = { + policyID: string; + customUnit: string; +}; + +export default EnableDistanceRequestTaxParams; diff --git a/src/libs/API/parameters/RequestPhysicalExpensifyCardParams.ts b/src/libs/API/parameters/RequestPhysicalExpensifyCardParams.ts index 91995b6e37aa..94e45a29b728 100644 --- a/src/libs/API/parameters/RequestPhysicalExpensifyCardParams.ts +++ b/src/libs/API/parameters/RequestPhysicalExpensifyCardParams.ts @@ -8,6 +8,7 @@ type RequestPhysicalExpensifyCardParams = { addressState: string; addressStreet: string; addressZip: string; + validateCode: string; }; export default RequestPhysicalExpensifyCardParams; diff --git a/src/libs/API/parameters/SetCustomUnitDefaultCategoryParams.ts b/src/libs/API/parameters/SetCustomUnitDefaultCategoryParams.ts new file mode 100644 index 000000000000..53eac3110af7 --- /dev/null +++ b/src/libs/API/parameters/SetCustomUnitDefaultCategoryParams.ts @@ -0,0 +1,7 @@ +type SetCustomUnitDefaultCategoryParams = { + policyID: string; + customUnitID: string; + category: string; +}; + +export default SetCustomUnitDefaultCategoryParams; diff --git a/src/libs/API/parameters/SetPolicyDistanceRatesDefaultCategoryParams.ts b/src/libs/API/parameters/SetPolicyDistanceRatesDefaultCategoryParams.ts deleted file mode 100644 index d2d11993a172..000000000000 --- a/src/libs/API/parameters/SetPolicyDistanceRatesDefaultCategoryParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -type SetPolicyDistanceRatesDefaultCategoryParams = { - policyID: string; - customUnit: string; -}; - -export default SetPolicyDistanceRatesDefaultCategoryParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 681114fd3b08..61b0c68f874f 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -206,7 +206,8 @@ export type {default as EnablePolicyTaxesParams} from './EnablePolicyTaxesParams export type {default as OpenPolicyMoreFeaturesPageParams} from './OpenPolicyMoreFeaturesPageParams'; export type {default as CreatePolicyDistanceRateParams} from './CreatePolicyDistanceRateParams'; export type {default as SetPolicyDistanceRatesUnitParams} from './SetPolicyDistanceRatesUnitParams'; -export type {default as SetPolicyDistanceRatesDefaultCategoryParams} from './SetPolicyDistanceRatesDefaultCategoryParams'; +export type {default as EnableDistanceRequestTaxParams} from './EnableDistanceRequestTaxParams'; +export type {default as SetCustomUnitDefaultCategoryParams} from './SetCustomUnitDefaultCategoryParams'; export type {default as UpdatePolicyDistanceRateValueParams} from './UpdatePolicyDistanceRateValueParams'; export type {default as SetPolicyDistanceRatesEnabledParams} from './SetPolicyDistanceRatesEnabledParams'; export type {default as DeletePolicyDistanceRatesParams} from './DeletePolicyDistanceRatesParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index bd8a58555617..d7258f1dd49e 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -285,7 +285,7 @@ const WRITE_COMMANDS = { REQUEST_WORKSPACE_OWNER_CHANGE: 'RequestWorkspaceOwnerChange', ADD_BILLING_CARD_AND_REQUEST_WORKSPACE_OWNER_CHANGE: 'AddBillingCardAndRequestPolicyOwnerChange', SET_POLICY_DISTANCE_RATES_UNIT: 'SetPolicyDistanceRatesUnit', - SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY: 'SetPolicyDistanceRatesDefaultCategory', + SET_CUSTOM_UNIT_DEFAULT_CATEGORY: 'SetCustomUnitDefaultCategory', ENABLE_DISTANCE_REQUEST_TAX: 'EnableDistanceRequestTax', UPDATE_POLICY_DISTANCE_RATE_VALUE: 'UpdatePolicyDistanceRateValue', UPDATE_POLICY_DISTANCE_TAX_RATE_VALUE: 'UpdateDistanceTaxRate', @@ -688,8 +688,8 @@ type WriteCommandParameters = { [WRITE_COMMANDS.RENAME_POLICY_TAX]: Parameters.RenamePolicyTaxParams; [WRITE_COMMANDS.UPDATE_POLICY_TAX_CODE]: Parameters.UpdatePolicyTaxCodeParams; [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT]: Parameters.SetPolicyDistanceRatesUnitParams; - [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; - [WRITE_COMMANDS.ENABLE_DISTANCE_REQUEST_TAX]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; + [WRITE_COMMANDS.SET_CUSTOM_UNIT_DEFAULT_CATEGORY]: Parameters.SetCustomUnitDefaultCategoryParams; + [WRITE_COMMANDS.ENABLE_DISTANCE_REQUEST_TAX]: Parameters.EnableDistanceRequestTaxParams; [WRITE_COMMANDS.REPORT_EXPORT]: Parameters.ReportExportParams; [WRITE_COMMANDS.MARK_AS_EXPORTED]: Parameters.MarkAsExportedParams; [WRITE_COMMANDS.REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE]: Parameters.RequestExpensifyCardLimitIncreaseParams; diff --git a/src/libs/Authentication.ts b/src/libs/Authentication.ts index 34630af81733..5e7b00472471 100644 --- a/src/libs/Authentication.ts +++ b/src/libs/Authentication.ts @@ -62,55 +62,44 @@ function reauthenticate(command = ''): Promise { partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD, partnerUserID: credentials?.autoGeneratedLogin, partnerUserSecret: credentials?.autoGeneratedPassword, - }) - .then((response) => { - if (response.jsonCode === CONST.JSON_CODE.UNABLE_TO_RETRY) { - // If authentication fails, then the network can be unpaused - NetworkStore.setIsAuthenticating(false); + }).then((response) => { + if (response.jsonCode === CONST.JSON_CODE.UNABLE_TO_RETRY) { + // When a fetch() fails due to a network issue and an error is thrown we won't log the user out. Most likely they + // have a spotty connection and will need to retry reauthenticate when they come back online. Error so it can be handled by the retry mechanism. + throw new Error('Unable to retry Authenticate request'); + } - // When a fetch() fails due to a network issue and an error is thrown we won't log the user out. Most likely they - // have a spotty connection and will need to try to reauthenticate when they come back online. We will error so it - // can be handled by callers of reauthenticate(). - throw new Error('Unable to retry Authenticate request'); - } - - // If authentication fails and we are online then log the user out - if (response.jsonCode !== 200) { - const errorMessage = ErrorUtils.getAuthenticateErrorMessage(response); - NetworkStore.setIsAuthenticating(false); - Log.hmmm('Redirecting to Sign In because we failed to reauthenticate', { - command, - error: errorMessage, - }); - redirectToSignIn(errorMessage); - return; - } + // If authentication fails and we are online then log the user out + if (response.jsonCode !== 200) { + const errorMessage = ErrorUtils.getAuthenticateErrorMessage(response); + NetworkStore.setIsAuthenticating(false); + Log.hmmm('Redirecting to Sign In because we failed to reauthenticate', { + command, + error: errorMessage, + }); + redirectToSignIn(errorMessage); + return; + } - // If we reauthenticated due to an expired delegate token, restore the delegate's original account. - // This is because the credentials used to reauthenticate were for the delegate's original account, and not for the account they were connected as. - if (Delegate.isConnectedAsDelegate()) { - Log.info('Reauthenticated while connected as a delegate. Restoring original account.'); - Delegate.restoreDelegateSession(response); - return; - } + // If we reauthenticated due to an expired delegate token, restore the delegate's original account. + // This is because the credentials used to reauthenticate were for the delegate's original account, and not for the account they were connected as. + if (Delegate.isConnectedAsDelegate()) { + Log.info('Reauthenticated while connected as a delegate. Restoring original account.'); + Delegate.restoreDelegateSession(response); + return; + } - // Update authToken in Onyx and in our local variables so that API requests will use the new authToken - updateSessionAuthTokens(response.authToken, response.encryptedAuthToken); + // Update authToken in Onyx and in our local variables so that API requests will use the new authToken + updateSessionAuthTokens(response.authToken, response.encryptedAuthToken); - // Note: It is important to manually set the authToken that is in the store here since any requests that are hooked into - // reauthenticate .then() will immediate post and use the local authToken. Onyx updates subscribers lately so it is not - // enough to do the updateSessionAuthTokens() call above. - NetworkStore.setAuthToken(response.authToken ?? null); + // Note: It is important to manually set the authToken that is in the store here since any requests that are hooked into + // reauthenticate .then() will immediate post and use the local authToken. Onyx updates subscribers lately so it is not + // enough to do the updateSessionAuthTokens() call above. + NetworkStore.setAuthToken(response.authToken ?? null); - // The authentication process is finished so the network can be unpaused to continue processing requests - NetworkStore.setIsAuthenticating(false); - }) - .catch((error) => { - // In case the authenticate call throws error, we need to sign user out as most likely they are missing credentials - NetworkStore.setIsAuthenticating(false); - Log.hmmm('Redirecting to Sign In because we failed to reauthenticate', {error}); - redirectToSignIn('passwordForm.error.fallback'); - }); + // The authentication process is finished so the network can be unpaused to continue processing requests + NetworkStore.setIsAuthenticating(false); + }); } export {reauthenticate, Authenticate}; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 46461edb5525..4144f0be94ec 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -213,14 +213,6 @@ function sortCardsByCardholderName(cardsList: OnyxEntry, per }); } -function getCompanyCardNumber(cardList: Record, lastFourPAN?: string, cardName = ''): string { - if (!lastFourPAN) { - return ''; - } - - return Object.keys(cardList).find((card) => card.endsWith(lastFourPAN)) ?? cardName; -} - function getCardFeedIcon(cardFeed: CompanyCardFeed | typeof CONST.EXPENSIFY_CARD.BANK): IconAsset { const feedIcons = { [CONST.COMPANY_CARD.FEED_BANK_NAME.VISA]: Illustrations.VisaCompanyCardDetailLarge, @@ -379,7 +371,6 @@ export { getTranslationKeyForLimitType, getEligibleBankAccountsForCard, sortCardsByCardholderName, - getCompanyCardNumber, getCardFeedIcon, getCardFeedName, getCompanyFeeds, diff --git a/src/libs/CategoryOptionListUtils.ts b/src/libs/CategoryOptionListUtils.ts new file mode 100644 index 000000000000..5e3feed05af3 --- /dev/null +++ b/src/libs/CategoryOptionListUtils.ts @@ -0,0 +1,282 @@ +// eslint-disable-next-line you-dont-need-lodash-underscore/get +import lodashGet from 'lodash/get'; +import lodashSet from 'lodash/set'; +import CONST from '@src/CONST'; +import type {PolicyCategories} from '@src/types/onyx'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import times from '@src/utils/times'; +import * as Localize from './Localize'; +import type {OptionTree, SectionBase} from './OptionsListUtils'; + +type CategoryTreeSection = SectionBase & { + data: OptionTree[]; + indexOffset?: number; +}; + +type Category = { + name: string; + enabled: boolean; + isSelected?: boolean; + pendingAction?: OnyxCommon.PendingAction; +}; + +type Hierarchy = Record; + +/** + * Builds the options for the category tree hierarchy via indents + * + * @param options - an initial object array + * @param options[].enabled - a flag to enable/disable option in a list + * @param options[].name - a name of an option + * @param [isOneLine] - a flag to determine if text should be one line + */ +function getCategoryOptionTree(options: Record | Category[], isOneLine = false, selectedOptions: Category[] = []): OptionTree[] { + const optionCollection = new Map(); + Object.values(options).forEach((option) => { + if (isOneLine) { + if (optionCollection.has(option.name)) { + return; + } + + optionCollection.set(option.name, { + text: option.name, + keyForList: option.name, + searchText: option.name, + tooltipText: option.name, + isDisabled: !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + isSelected: !!option.isSelected, + pendingAction: option.pendingAction, + }); + + return; + } + + option.name.split(CONST.PARENT_CHILD_SEPARATOR).forEach((optionName, index, array) => { + const indents = times(index, () => CONST.INDENTS).join(''); + const isChild = array.length - 1 === index; + const searchText = array.slice(0, index + 1).join(CONST.PARENT_CHILD_SEPARATOR); + const selectedParentOption = !isChild && Object.values(selectedOptions).find((op) => op.name === searchText); + const isParentOptionDisabled = !selectedParentOption || !selectedParentOption.enabled || selectedParentOption.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + + if (optionCollection.has(searchText)) { + return; + } + + optionCollection.set(searchText, { + text: `${indents}${optionName}`, + keyForList: searchText, + searchText, + tooltipText: optionName, + isDisabled: isChild ? !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : isParentOptionDisabled, + isSelected: isChild ? !!option.isSelected : !!selectedParentOption, + pendingAction: option.pendingAction, + }); + }); + }); + + return Array.from(optionCollection.values()); +} + +/** + * Builds the section list for categories + */ +function getCategoryListSections({ + categories, + searchValue, + selectedOptions = [], + recentlyUsedCategories = [], + maxRecentReportsToShow = CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, +}: { + categories: PolicyCategories; + selectedOptions?: Category[]; + searchValue?: string; + recentlyUsedCategories?: string[]; + maxRecentReportsToShow?: number; +}): CategoryTreeSection[] { + const sortedCategories = sortCategories(categories); + const enabledCategories = Object.values(sortedCategories).filter((category) => category.enabled); + const enabledCategoriesNames = enabledCategories.map((category) => category.name); + const selectedOptionsWithDisabledState: Category[] = []; + const categorySections: CategoryTreeSection[] = []; + const numberOfEnabledCategories = enabledCategories.length; + + selectedOptions.forEach((option) => { + if (enabledCategoriesNames.includes(option.name)) { + const categoryObj = enabledCategories.find((category) => category.name === option.name); + selectedOptionsWithDisabledState.push({...(categoryObj ?? option), isSelected: true, enabled: true}); + return; + } + selectedOptionsWithDisabledState.push({...option, isSelected: true, enabled: false}); + }); + + if (numberOfEnabledCategories === 0 && selectedOptions.length > 0) { + const data = getCategoryOptionTree(selectedOptionsWithDisabledState, true); + categorySections.push({ + // "Selected" section + title: '', + shouldShow: false, + data, + indexOffset: data.length, + }); + + return categorySections; + } + + if (searchValue) { + const categoriesForSearch = [...selectedOptionsWithDisabledState, ...enabledCategories]; + const searchCategories: Category[] = []; + + categoriesForSearch.forEach((category) => { + if (!category.name.toLowerCase().includes(searchValue.toLowerCase())) { + return; + } + searchCategories.push({ + ...category, + isSelected: selectedOptions.some((selectedOption) => selectedOption.name === category.name), + }); + }); + + const data = getCategoryOptionTree(searchCategories, true); + categorySections.push({ + // "Search" section + title: '', + shouldShow: true, + data, + indexOffset: data.length, + }); + + return categorySections; + } + + if (selectedOptions.length > 0) { + const data = getCategoryOptionTree(selectedOptionsWithDisabledState, true); + categorySections.push({ + // "Selected" section + title: '', + shouldShow: false, + data, + indexOffset: data.length, + }); + } + + const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name); + const filteredCategories = enabledCategories.filter((category) => !selectedOptionNames.includes(category.name)); + + if (numberOfEnabledCategories < CONST.STANDARD_LIST_ITEM_LIMIT) { + const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState); + categorySections.push({ + // "All" section when items amount less than the threshold + title: '', + shouldShow: false, + data, + indexOffset: data.length, + }); + + return categorySections; + } + + const filteredRecentlyUsedCategories = recentlyUsedCategories + .filter( + (categoryName) => + !selectedOptionNames.includes(categoryName) && categories[categoryName]?.enabled && categories[categoryName]?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ) + .map((categoryName) => ({ + name: categoryName, + enabled: categories[categoryName].enabled ?? false, + })); + + if (filteredRecentlyUsedCategories.length > 0) { + const cutRecentlyUsedCategories = filteredRecentlyUsedCategories.slice(0, maxRecentReportsToShow); + + const data = getCategoryOptionTree(cutRecentlyUsedCategories, true); + categorySections.push({ + // "Recent" section + title: Localize.translateLocal('common.recent'), + shouldShow: true, + data, + indexOffset: data.length, + }); + } + + const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState); + categorySections.push({ + // "All" section when items amount more than the threshold + title: Localize.translateLocal('common.all'), + shouldShow: true, + data, + indexOffset: data.length, + }); + + return categorySections; +} + +/** + * Sorts categories using a simple object. + * It builds an hierarchy (based on an object), where each category has a name and other keys as subcategories. + * Via the hierarchy we avoid duplicating and sort categories one by one. Subcategories are being sorted alphabetically. + */ +function sortCategories(categories: Record): Category[] { + // Sorts categories alphabetically by name. + const sortedCategories = Object.values(categories).sort((a, b) => a.name.localeCompare(b.name)); + + // An object that respects nesting of categories. Also, can contain only uniq categories. + const hierarchy: Hierarchy = {}; + /** + * Iterates over all categories to set each category in a proper place in hierarchy + * It gets a path based on a category name e.g. "Parent: Child: Subcategory" -> "Parent.Child.Subcategory". + * { + * Parent: { + * name: "Parent", + * Child: { + * name: "Child" + * Subcategory: { + * name: "Subcategory" + * } + * } + * } + * } + */ + sortedCategories.forEach((category) => { + const path = category.name.split(CONST.PARENT_CHILD_SEPARATOR); + const existedValue = lodashGet(hierarchy, path, {}) as Hierarchy; + lodashSet(hierarchy, path, { + ...existedValue, + name: category.name, + pendingAction: category.pendingAction, + }); + }); + + /** + * A recursive function to convert hierarchy into an array of category objects. + * The category object contains base 2 properties: "name" and "enabled". + * It iterates each key one by one. When a category has subcategories, goes deeper into them. Also, sorts subcategories alphabetically. + */ + const flatHierarchy = (initialHierarchy: Hierarchy) => + Object.values(initialHierarchy).reduce((acc: Category[], category) => { + const {name, pendingAction, ...subcategories} = category; + if (name) { + const categoryObject: Category = { + name, + pendingAction, + enabled: categories[name]?.enabled ?? false, + }; + + acc.push(categoryObject); + } + + if (!isEmptyObject(subcategories)) { + const nestedCategories = flatHierarchy(subcategories); + + acc.push(...nestedCategories.sort((a, b) => a.name.localeCompare(b.name))); + } + + return acc; + }, []); + + return flatHierarchy(hierarchy); +} + +export {getCategoryListSections, getCategoryOptionTree, sortCategories}; + +export type {Category, SectionBase as CategorySectionBase, CategoryTreeSection, Hierarchy}; diff --git a/src/libs/Middleware/Reauthentication.ts b/src/libs/Middleware/Reauthentication.ts index 09a01e821cb2..9d95fa8af873 100644 --- a/src/libs/Middleware/Reauthentication.ts +++ b/src/libs/Middleware/Reauthentication.ts @@ -1,33 +1,61 @@ +import redirectToSignIn from '@libs/actions/SignInRedirect'; import * as Authentication from '@libs/Authentication'; import Log from '@libs/Log'; import * as MainQueue from '@libs/Network/MainQueue'; import * as NetworkStore from '@libs/Network/NetworkStore'; +import type {RequestError} from '@libs/Network/SequentialQueue'; import NetworkConnection from '@libs/NetworkConnection'; import * as Request from '@libs/Request'; +import RequestThrottle from '@libs/RequestThrottle'; import CONST from '@src/CONST'; import type Middleware from './types'; // We store a reference to the active authentication request so that we are only ever making one request to authenticate at a time. let isAuthenticating: Promise | null = null; +const reauthThrottle = new RequestThrottle('Re-authentication'); + function reauthenticate(commandName?: string): Promise { if (isAuthenticating) { return isAuthenticating; } - isAuthenticating = Authentication.reauthenticate(commandName) + isAuthenticating = retryReauthenticate(commandName) .then((response) => { - isAuthenticating = null; return response; }) .catch((error) => { - isAuthenticating = null; throw error; + }) + .finally(() => { + isAuthenticating = null; }); return isAuthenticating; } +function retryReauthenticate(commandName?: string): Promise { + return Authentication.reauthenticate(commandName).catch((error: RequestError) => { + return reauthThrottle + .sleep(error, 'Authenticate') + .then(() => retryReauthenticate(commandName)) + .catch(() => { + NetworkStore.setIsAuthenticating(false); + Log.hmmm('Redirecting to Sign In because we failed to reauthenticate after multiple attempts', {error}); + redirectToSignIn('passwordForm.error.fallback'); + }); + }); +} + +// Used in tests to reset the reauthentication state +function resetReauthentication(): void { + // Resets the authentication state flag to allow new reauthentication flows to start fresh + isAuthenticating = null; + + // Clears any pending reauth timeouts set by reauthThrottle.sleep() + reauthThrottle.clear(); +} + const Reauthentication: Middleware = (response, request, isFromSequentialQueue) => response .then((data) => { @@ -118,3 +146,4 @@ const Reauthentication: Middleware = (response, request, isFromSequentialQueue) }); export default Reauthentication; +export {reauthenticate, resetReauthentication, reauthThrottle}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 2b6d0b84b460..9295281755e5 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -570,6 +570,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/RulesMaxExpenseAmountPage').default, [SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE]: () => require('../../../../pages/workspace/rules/RulesMaxExpenseAgePage').default, [SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT]: () => require('../../../../pages/workspace/rules/RulesBillableDefaultPage').default, + [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemSettingsPage').default, }); const EnablePaymentsStackNavigator = createModalStackNavigator({ 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 d19b23f5c00c..7ae8fb43178a 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -245,6 +245,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE, SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT, ], + [SCREENS.WORKSPACE.PER_DIEM]: [SCREENS.WORKSPACE.PER_DIEM_SETTINGS], }; export default FULL_SCREEN_TO_RHP_MAPPING; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 8473b8fa49c2..476711c7c116 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -944,6 +944,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT]: { path: ROUTES.RULES_BILLABLE_DEFAULT.route, }, + [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: { + path: ROUTES.WORKSPACE_PER_DIEM_SETTINGS.route, + }, }, }, [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 9abd3f78a3f9..5877c9c10218 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -898,6 +898,9 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT]: { policyID: string; }; + [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: { + policyID: string; + }; } & ReimbursementAccountNavigatorParamList; type NewChatNavigatorParamList = { diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index 3f4da20c16e1..b57eb2c8cecc 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -2,7 +2,7 @@ import Onyx from 'react-native-onyx'; import * as ActiveClientManager from '@libs/ActiveClientManager'; import Log from '@libs/Log'; import * as Request from '@libs/Request'; -import * as RequestThrottle from '@libs/RequestThrottle'; +import RequestThrottle from '@libs/RequestThrottle'; import * as PersistedRequests from '@userActions/PersistedRequests'; import * as QueuedOnyxUpdates from '@userActions/QueuedOnyxUpdates'; import CONST from '@src/CONST'; @@ -28,6 +28,7 @@ resolveIsReadyPromise?.(); let isSequentialQueueRunning = false; let currentRequestPromise: Promise | null = null; let isQueuePaused = false; +const sequentialQueueRequestThrottle = new RequestThrottle('SequentialQueue'); /** * Puts the queue into a paused state so that no requests will be processed @@ -99,7 +100,7 @@ function process(): Promise { Log.info('[SequentialQueue] Removing persisted request because it was processed successfully.', false, {request: requestToProcess}); PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess); - RequestThrottle.clear(); + sequentialQueueRequestThrottle.clear(); return process(); }) .catch((error: RequestError) => { @@ -108,17 +109,18 @@ function process(): Promise { if (error.name === CONST.ERROR.REQUEST_CANCELLED || error.message === CONST.ERROR.DUPLICATE_RECORD) { Log.info("[SequentialQueue] Removing persisted request because it failed and doesn't need to be retried.", false, {error, request: requestToProcess}); PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess); - RequestThrottle.clear(); + sequentialQueueRequestThrottle.clear(); return process(); } PersistedRequests.rollbackOngoingRequest(); - return RequestThrottle.sleep(error, requestToProcess.command) + return sequentialQueueRequestThrottle + .sleep(error, requestToProcess.command) .then(process) .catch(() => { Onyx.update(requestToProcess.failureData ?? []); Log.info('[SequentialQueue] Removing persisted request because it failed too many times.', false, {error, request: requestToProcess}); PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess); - RequestThrottle.clear(); + sequentialQueueRequestThrottle.clear(); return process(); }); }); @@ -271,5 +273,19 @@ function waitForIdle(): Promise { return isReadyPromise; } -export {flush, getCurrentRequest, isRunning, isPaused, push, waitForIdle, pause, unpause, process}; +/** + * Clear any pending requests during test runs + * This is to prevent previous requests interfering with other tests + */ +function resetQueue(): void { + isSequentialQueueRunning = false; + currentRequestPromise = null; + isQueuePaused = false; + isReadyPromise = new Promise((resolve) => { + resolveIsReadyPromise = resolve; + }); + resolveIsReadyPromise?.(); +} + +export {flush, getCurrentRequest, isRunning, isPaused, push, waitForIdle, pause, unpause, process, resetQueue, sequentialQueueRequestThrottle}; export type {RequestError}; diff --git a/src/libs/Network/index.ts b/src/libs/Network/index.ts index 2adb4a2da4c2..4d27f75ab1a7 100644 --- a/src/libs/Network/index.ts +++ b/src/libs/Network/index.ts @@ -6,14 +6,28 @@ import pkg from '../../../package.json'; import * as MainQueue from './MainQueue'; import * as SequentialQueue from './SequentialQueue'; +// React Native uses a number for the timer id, but Web/NodeJS uses a Timeout object +let processQueueInterval: NodeJS.Timeout | number; + // We must wait until the ActiveClientManager is ready so that we ensure only the "leader" tab processes any persisted requests ActiveClientManager.isReady().then(() => { SequentialQueue.flush(); // Start main queue and process once every n ms delay - setInterval(MainQueue.process, CONST.NETWORK.PROCESS_REQUEST_DELAY_MS); + processQueueInterval = setInterval(MainQueue.process, CONST.NETWORK.PROCESS_REQUEST_DELAY_MS); }); +/** + * Clear any existing intervals during test runs + * This is to prevent previous intervals interfering with other tests + */ +function clearProcessQueueInterval() { + if (!processQueueInterval) { + return; + } + clearInterval(processQueueInterval); +} + /** * Perform a queued post request */ @@ -55,7 +69,4 @@ function post(command: string, data: Record = {}, type = CONST. }); } -export { - // eslint-disable-next-line import/prefer-default-export - post, -}; +export {post, clearProcessQueueInterval}; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index bb2c6a6e92fb..e8bbe392e9aa 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1,9 +1,6 @@ /* eslint-disable no-continue */ import {Str} from 'expensify-common'; -// eslint-disable-next-line you-dont-need-lodash-underscore/get -import lodashGet from 'lodash/get'; import lodashOrderBy from 'lodash/orderBy'; -import lodashSet from 'lodash/set'; import lodashSortBy from 'lodash/sortBy'; import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -32,7 +29,6 @@ import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import times from '@src/utils/times'; import Timing from './actions/Timing'; import filterArrayByMatch from './filterArrayByMatch'; import * as LocalePhoneNumber from './LocalePhoneNumber'; @@ -84,29 +80,15 @@ type PayeePersonalDetails = { keyForList: string; }; -type CategorySectionBase = { +type SectionBase = { title: string | undefined; shouldShow: boolean; }; -type CategorySection = CategorySectionBase & { +type Section = SectionBase & { data: Option[]; }; -type CategoryTreeSection = CategorySectionBase & { - data: OptionTree[]; - indexOffset?: number; -}; - -type Category = { - name: string; - enabled: boolean; - isSelected?: boolean; - pendingAction?: OnyxCommon.PendingAction; -}; - -type Hierarchy = Record; - type GetOptionsConfig = { reportActions?: ReportActions; betas?: OnyxEntry; @@ -127,9 +109,6 @@ type GetOptionsConfig = { includeMoneyRequests?: boolean; excludeUnknownUsers?: boolean; includeP2P?: boolean; - includeCategories?: boolean; - categories?: PolicyCategories; - recentlyUsedCategories?: string[]; canInviteUser?: boolean; includeSelectedOptions?: boolean; transactionViolations?: OnyxCollection; @@ -165,14 +144,13 @@ type MemberForList = { }; type SectionForSearchTerm = { - section: CategorySection; + section: Section; }; type Options = { recentReports: ReportUtils.OptionData[]; personalDetails: ReportUtils.OptionData[]; userToInvite: ReportUtils.OptionData | null; currentUserOption: ReportUtils.OptionData | null | undefined; - categoryOptions: CategoryTreeSection[]; }; type PreviewConfig = {showChatPreviewLine?: boolean; forcePolicyNamePreview?: boolean; showPersonalDetails?: boolean}; @@ -874,254 +852,6 @@ function hasEnabledOptions(options: PolicyCategories | PolicyTag[]): boolean { return Object.values(options).some((option: PolicyTag | PolicyCategory) => option.enabled && option.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); } -/** - * Sorts categories using a simple object. - * It builds an hierarchy (based on an object), where each category has a name and other keys as subcategories. - * Via the hierarchy we avoid duplicating and sort categories one by one. Subcategories are being sorted alphabetically. - */ -function sortCategories(categories: Record): Category[] { - // Sorts categories alphabetically by name. - const sortedCategories = Object.values(categories).sort((a, b) => a.name.localeCompare(b.name)); - - // An object that respects nesting of categories. Also, can contain only uniq categories. - const hierarchy: Hierarchy = {}; - /** - * Iterates over all categories to set each category in a proper place in hierarchy - * It gets a path based on a category name e.g. "Parent: Child: Subcategory" -> "Parent.Child.Subcategory". - * { - * Parent: { - * name: "Parent", - * Child: { - * name: "Child" - * Subcategory: { - * name: "Subcategory" - * } - * } - * } - * } - */ - sortedCategories.forEach((category) => { - const path = category.name.split(CONST.PARENT_CHILD_SEPARATOR); - const existedValue = lodashGet(hierarchy, path, {}) as Hierarchy; - lodashSet(hierarchy, path, { - ...existedValue, - name: category.name, - pendingAction: category.pendingAction, - }); - }); - - /** - * A recursive function to convert hierarchy into an array of category objects. - * The category object contains base 2 properties: "name" and "enabled". - * It iterates each key one by one. When a category has subcategories, goes deeper into them. Also, sorts subcategories alphabetically. - */ - const flatHierarchy = (initialHierarchy: Hierarchy) => - Object.values(initialHierarchy).reduce((acc: Category[], category) => { - const {name, pendingAction, ...subcategories} = category; - if (name) { - const categoryObject: Category = { - name, - pendingAction, - enabled: categories[name]?.enabled ?? false, - }; - - acc.push(categoryObject); - } - - if (!isEmptyObject(subcategories)) { - const nestedCategories = flatHierarchy(subcategories); - - acc.push(...nestedCategories.sort((a, b) => a.name.localeCompare(b.name))); - } - - return acc; - }, []); - - return flatHierarchy(hierarchy); -} - -/** - * Builds the options for the category tree hierarchy via indents - * - * @param options - an initial object array - * @param options[].enabled - a flag to enable/disable option in a list - * @param options[].name - a name of an option - * @param [isOneLine] - a flag to determine if text should be one line - */ -function getCategoryOptionTree(options: Record | Category[], isOneLine = false, selectedOptions: Category[] = []): OptionTree[] { - const optionCollection = new Map(); - Object.values(options).forEach((option) => { - if (isOneLine) { - if (optionCollection.has(option.name)) { - return; - } - - optionCollection.set(option.name, { - text: option.name, - keyForList: option.name, - searchText: option.name, - tooltipText: option.name, - isDisabled: !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - isSelected: !!option.isSelected, - pendingAction: option.pendingAction, - }); - - return; - } - - option.name.split(CONST.PARENT_CHILD_SEPARATOR).forEach((optionName, index, array) => { - const indents = times(index, () => CONST.INDENTS).join(''); - const isChild = array.length - 1 === index; - const searchText = array.slice(0, index + 1).join(CONST.PARENT_CHILD_SEPARATOR); - const selectedParentOption = !isChild && Object.values(selectedOptions).find((op) => op.name === searchText); - const isParentOptionDisabled = !selectedParentOption || !selectedParentOption.enabled || selectedParentOption.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - - if (optionCollection.has(searchText)) { - return; - } - - optionCollection.set(searchText, { - text: `${indents}${optionName}`, - keyForList: searchText, - searchText, - tooltipText: optionName, - isDisabled: isChild ? !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : isParentOptionDisabled, - isSelected: isChild ? !!option.isSelected : !!selectedParentOption, - pendingAction: option.pendingAction, - }); - }); - }); - - return Array.from(optionCollection.values()); -} - -/** - * Builds the section list for categories - */ -function getCategoryListSections( - categories: PolicyCategories, - recentlyUsedCategories: string[], - selectedOptions: Category[], - searchInputValue: string, - maxRecentReportsToShow: number, -): CategoryTreeSection[] { - const sortedCategories = sortCategories(categories); - const enabledCategories = Object.values(sortedCategories).filter((category) => category.enabled); - const enabledCategoriesNames = enabledCategories.map((category) => category.name); - const selectedOptionsWithDisabledState: Category[] = []; - const categorySections: CategoryTreeSection[] = []; - const numberOfEnabledCategories = enabledCategories.length; - - selectedOptions.forEach((option) => { - if (enabledCategoriesNames.includes(option.name)) { - const categoryObj = enabledCategories.find((category) => category.name === option.name); - selectedOptionsWithDisabledState.push({...(categoryObj ?? option), isSelected: true, enabled: true}); - return; - } - selectedOptionsWithDisabledState.push({...option, isSelected: true, enabled: false}); - }); - - if (numberOfEnabledCategories === 0 && selectedOptions.length > 0) { - const data = getCategoryOptionTree(selectedOptionsWithDisabledState, true); - categorySections.push({ - // "Selected" section - title: '', - shouldShow: false, - data, - indexOffset: data.length, - }); - - return categorySections; - } - - if (searchInputValue) { - const categoriesForSearch = [...selectedOptionsWithDisabledState, ...enabledCategories]; - const searchCategories: Category[] = []; - - categoriesForSearch.forEach((category) => { - if (!category.name.toLowerCase().includes(searchInputValue.toLowerCase())) { - return; - } - searchCategories.push({ - ...category, - isSelected: selectedOptions.some((selectedOption) => selectedOption.name === category.name), - }); - }); - - const data = getCategoryOptionTree(searchCategories, true); - categorySections.push({ - // "Search" section - title: '', - shouldShow: true, - data, - indexOffset: data.length, - }); - - return categorySections; - } - - if (selectedOptions.length > 0) { - const data = getCategoryOptionTree(selectedOptionsWithDisabledState, true); - categorySections.push({ - // "Selected" section - title: '', - shouldShow: false, - data, - indexOffset: data.length, - }); - } - - const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name); - const filteredCategories = enabledCategories.filter((category) => !selectedOptionNames.includes(category.name)); - - if (numberOfEnabledCategories < CONST.STANDARD_LIST_ITEM_LIMIT) { - const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState); - categorySections.push({ - // "All" section when items amount less than the threshold - title: '', - shouldShow: false, - data, - indexOffset: data.length, - }); - - return categorySections; - } - - const filteredRecentlyUsedCategories = recentlyUsedCategories - .filter( - (categoryName) => - !selectedOptionNames.includes(categoryName) && categories[categoryName]?.enabled && categories[categoryName]?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - ) - .map((categoryName) => ({ - name: categoryName, - enabled: categories[categoryName].enabled ?? false, - })); - - if (filteredRecentlyUsedCategories.length > 0) { - const cutRecentlyUsedCategories = filteredRecentlyUsedCategories.slice(0, maxRecentReportsToShow); - - const data = getCategoryOptionTree(cutRecentlyUsedCategories, true); - categorySections.push({ - // "Recent" section - title: Localize.translateLocal('common.recent'), - shouldShow: true, - data, - indexOffset: data.length, - }); - } - - const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState); - categorySections.push({ - // "All" section when items amount more than the threshold - title: Localize.translateLocal('common.all'), - shouldShow: true, - data, - indexOffset: data.length, - }); - - return categorySections; -} - /** * Checks if a report option is selected based on matching accountID or reportID. * @@ -1347,9 +1077,6 @@ function getOptions( includeMoneyRequests = false, excludeUnknownUsers = false, includeP2P = true, - includeCategories = false, - categories = {}, - recentlyUsedCategories = [], canInviteUser = true, includeSelectedOptions = false, transactionViolations = {}, @@ -1361,18 +1088,6 @@ function getOptions( shouldBoldTitleByDefault = true, }: GetOptionsConfig, ): Options { - if (includeCategories) { - const categoryOptions = getCategoryListSections(categories, recentlyUsedCategories, selectedOptions as Category[], searchInputValue, maxRecentReportsToShow); - - return { - recentReports: [], - personalDetails: [], - userToInvite: null, - currentUserOption: null, - categoryOptions, - }; - } - const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchInputValue))); const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number?.e164 ?? '' : searchInputValue.toLowerCase(); const topmostReportId = Navigation.getTopmostReportId() ?? '-1'; @@ -1628,7 +1343,6 @@ function getOptions( recentReports: recentReportOptions, userToInvite: canInviteUser ? userToInvite : null, currentUserOption, - categoryOptions: [], }; } @@ -1711,9 +1425,6 @@ type FilteredOptionsParams = { excludeLogins?: string[]; includeOwnedWorkspaceChats?: boolean; includeP2P?: boolean; - includeCategories?: boolean; - categories?: PolicyCategories; - recentlyUsedCategories?: string[]; canInviteUser?: boolean; includeSelectedOptions?: boolean; maxRecentReportsToShow?: number; @@ -1744,9 +1455,6 @@ function getFilteredOptions(params: FilteredOptionsParamsWithDefaultSearchValue excludeLogins = [], includeOwnedWorkspaceChats = false, includeP2P = true, - includeCategories = false, - categories = {}, - recentlyUsedCategories = [], canInviteUser = true, includeSelectedOptions = false, maxRecentReportsToShow = CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, @@ -1766,9 +1474,6 @@ function getFilteredOptions(params: FilteredOptionsParamsWithDefaultSearchValue excludeLogins, includeOwnedWorkspaceChats, includeP2P, - includeCategories, - categories, - recentlyUsedCategories, canInviteUser, includeSelectedOptions, includeSelfDM, @@ -1802,9 +1507,6 @@ function getAttendeeOptions( includeOwnedWorkspaceChats, includeRecentReports: false, includeP2P, - includeCategories: false, - categories: {}, - recentlyUsedCategories: [], canInviteUser, includeSelectedOptions: false, maxRecentReportsToShow: 0, @@ -2102,7 +1804,6 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt personalDetails: personalDetails ?? [], userToInvite: null, currentUserOption, - categoryOptions: [], }; }, options); @@ -2138,7 +1839,6 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt recentReports: sortedRecentReports, userToInvite, currentUserOption: matchResults.currentUserOption, - categoryOptions: [], }; } @@ -2152,7 +1852,6 @@ function getEmptyOptions(): Options { personalDetails: [], userToInvite: null, currentUserOption: null, - categoryOptions: [], }; } @@ -2184,9 +1883,7 @@ export { getLastMessageTextForReport, getEnabledCategoriesCount, hasEnabledOptions, - sortCategories, sortAlphabetically, - getCategoryOptionTree, formatMemberForList, formatSectionsFromSearchTerm, getShareLogOptions, @@ -2207,4 +1904,4 @@ export { hasReportErrors, }; -export type {MemberForList, CategorySection, CategoryTreeSection, Options, OptionList, SearchOption, PayeePersonalDetails, Category, Option, OptionTree}; +export type {Section, SectionBase, MemberForList, Options, OptionList, SearchOption, PayeePersonalDetails, Option, OptionTree}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index db4e8c2faa6d..36095b7d88ec 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -5164,6 +5164,7 @@ function buildOptimisticReportPreview( actorAccountID: hasReceipt ? currentUserAccountID : reportActorAccountID, childReportID: childReportID ?? iouReport?.reportID, childMoneyRequestCount: 1, + childLastActorAccountID: currentUserAccountID, childLastMoneyRequestComment: comment, childRecentReceiptTransactionIDs: hasReceipt && !isEmptyObject(transaction) ? {[transaction?.transactionID ?? '-1']: created} : undefined, }; diff --git a/src/libs/RequestThrottle.ts b/src/libs/RequestThrottle.ts index 3bbc82ff5b45..c4589bb07afa 100644 --- a/src/libs/RequestThrottle.ts +++ b/src/libs/RequestThrottle.ts @@ -3,41 +3,57 @@ import Log from './Log'; import type {RequestError} from './Network/SequentialQueue'; import {generateRandomInt} from './NumberUtils'; -let requestWaitTime = 0; -let requestRetryCount = 0; +class RequestThrottle { + private requestWaitTime = 0; -function clear() { - requestWaitTime = 0; - requestRetryCount = 0; - Log.info(`[RequestThrottle] in clear()`); -} + private requestRetryCount = 0; + + private timeoutID?: NodeJS.Timeout; + + private name: string; -function getRequestWaitTime() { - if (requestWaitTime) { - requestWaitTime = Math.min(requestWaitTime * 2, CONST.NETWORK.MAX_RETRY_WAIT_TIME_MS); - } else { - requestWaitTime = generateRandomInt(CONST.NETWORK.MIN_RETRY_WAIT_TIME_MS, CONST.NETWORK.MAX_RANDOM_RETRY_WAIT_TIME_MS); + constructor(name: string) { + this.name = name; } - return requestWaitTime; -} -function getLastRequestWaitTime() { - return requestWaitTime; -} + clear() { + this.requestWaitTime = 0; + this.requestRetryCount = 0; + if (this.timeoutID) { + Log.info(`[RequestThrottle - ${this.name}] clearing timeoutID: ${String(this.timeoutID)}`); + clearTimeout(this.timeoutID); + this.timeoutID = undefined; + } + Log.info(`[RequestThrottle - ${this.name}] cleared`); + } -function sleep(error: RequestError, command: string): Promise { - requestRetryCount++; - return new Promise((resolve, reject) => { - if (requestRetryCount <= CONST.NETWORK.MAX_REQUEST_RETRIES) { - const currentRequestWaitTime = getRequestWaitTime(); - Log.info( - `[RequestThrottle] Retrying request after error: '${error.name}', '${error.message}', '${error.status}'. Command: ${command}. Retry count: ${requestRetryCount}. Wait time: ${currentRequestWaitTime}`, - ); - setTimeout(resolve, currentRequestWaitTime); - return; + getRequestWaitTime() { + if (this.requestWaitTime) { + this.requestWaitTime = Math.min(this.requestWaitTime * 2, CONST.NETWORK.MAX_RETRY_WAIT_TIME_MS); + } else { + this.requestWaitTime = generateRandomInt(CONST.NETWORK.MIN_RETRY_WAIT_TIME_MS, CONST.NETWORK.MAX_RANDOM_RETRY_WAIT_TIME_MS); } - reject(); - }); + return this.requestWaitTime; + } + + getLastRequestWaitTime() { + return this.requestWaitTime; + } + + sleep(error: RequestError, command: string): Promise { + this.requestRetryCount++; + return new Promise((resolve, reject) => { + if (this.requestRetryCount <= CONST.NETWORK.MAX_REQUEST_RETRIES) { + const currentRequestWaitTime = this.getRequestWaitTime(); + Log.info( + `[RequestThrottle - ${this.name}] Retrying request after error: '${error.name}', '${error.message}', '${error.status}'. Command: ${command}. Retry count: ${this.requestRetryCount}. Wait time: ${currentRequestWaitTime}`, + ); + this.timeoutID = setTimeout(resolve, currentRequestWaitTime); + } else { + reject(); + } + }); + } } -export {clear, getRequestWaitTime, sleep, getLastRequestWaitTime}; +export default RequestThrottle; diff --git a/src/libs/actions/CompanyCards.ts b/src/libs/actions/CompanyCards.ts index 4a102ab9bb72..8e83b9192a71 100644 --- a/src/libs/actions/CompanyCards.ts +++ b/src/libs/actions/CompanyCards.ts @@ -151,27 +151,33 @@ function setWorkspaceCompanyCardTransactionLiability(workspaceAccountID: number, API.write(WRITE_COMMANDS.SET_COMPANY_CARD_TRANSACTION_LIABILITY, parameters, onyxData); } -function deleteWorkspaceCompanyCardFeed(policyID: string, workspaceAccountID: number, bankName: CompanyCardFeed) { +function deleteWorkspaceCompanyCardFeed(policyID: string, workspaceAccountID: number, bankName: CompanyCardFeed, feedToOpen?: CompanyCardFeed) { const authToken = NetworkStore.getAuthToken(); const isCustomFeed = CardUtils.isCustomFeed(bankName); const feedUpdates = {[bankName]: null}; - const onyxData: OnyxData = { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`, - value: { - settings: { - ...(isCustomFeed ? {companyCards: feedUpdates} : {oAuthAccountDetails: feedUpdates}), - companyCardNicknames: { - [bankName]: null, - }, + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`, + value: { + settings: { + ...(isCustomFeed ? {companyCards: feedUpdates} : {oAuthAccountDetails: feedUpdates}), + companyCardNicknames: { + [bankName]: null, }, }, }, - ], - }; + }, + ]; + + if (feedToOpen) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`, + value: feedToOpen, + }); + } const parameters = { authToken, @@ -179,7 +185,7 @@ function deleteWorkspaceCompanyCardFeed(policyID: string, workspaceAccountID: nu bankName, }; - API.write(WRITE_COMMANDS.DELETE_COMPANY_CARD_FEED, parameters, onyxData); + API.write(WRITE_COMMANDS.DELETE_COMPANY_CARD_FEED, parameters, {optimisticData}); } function assignWorkspaceCompanyCard(policyID: string, data?: Partial) { diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 73dfcd4f26b5..10eee66428e8 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -165,6 +165,46 @@ type GPSPoint = { long: number; }; +type RequestMoneyTransactionParams = { + attendees: Attendee[] | undefined; + amount: number; + currency: string; + comment?: string; + receipt?: Receipt; + category?: string; + tag?: string; + taxCode?: string; + taxAmount?: number; + billable?: boolean; + merchant: string; + created: string; + actionableWhisperReportActionID?: string; + linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction; + linkedTrackedExpenseReportID?: string; +}; + +type RequestMoneyPolicyParams = { + policy?: OnyxEntry; + policyTagList?: OnyxEntry; + policyCategories?: OnyxEntry; +}; + +type RequestMoneyParticipantParams = { + payeeEmail: string | undefined; + payeeAccountID: number; + participant: Participant; +}; + +type RequestMoneyInformation = { + report: OnyxEntry; + participantParams: RequestMoneyParticipantParams; + policyParams?: RequestMoneyPolicyParams; + gpsPoints?: GPSPoint; + action?: IOUAction; + reimbursible?: boolean; + transactionParams: RequestMoneyTransactionParams; +}; + let allPersonalDetails: OnyxTypes.PersonalDetailsList = {}; Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -3524,33 +3564,28 @@ function shareTrackedExpense( /** * Submit expense to another user */ -function requestMoney( - report: OnyxEntry, - amount: number, - attendees: Attendee[] | undefined, - currency: string, - created: string, - merchant: string, - payeeEmail: string | undefined, - payeeAccountID: number, - participant: Participant, - comment: string, - receipt: Receipt | undefined, - category?: string, - tag?: string, - taxCode = '', - taxAmount = 0, - billable?: boolean, - policy?: OnyxEntry, - policyTagList?: OnyxEntry, - policyCategories?: OnyxEntry, - gpsPoints?: GPSPoint, - action?: IOUAction, - actionableWhisperReportActionID?: string, - linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction, - linkedTrackedExpenseReportID?: string, - reimbursible?: boolean, -) { +function requestMoney(requestMoneyInformation: RequestMoneyInformation) { + const {report, participantParams, policyParams = {}, transactionParams, gpsPoints, action, reimbursible} = requestMoneyInformation; + const {participant, payeeAccountID, payeeEmail} = participantParams; + const {policy, policyCategories, policyTagList} = policyParams; + const { + amount, + currency, + merchant, + comment = '', + receipt, + category, + tag, + taxCode = '', + taxAmount = 0, + billable, + created, + attendees, + actionableWhisperReportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + } = transactionParams; + // If the report is iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); const currentChatReport = isMoneyRequestReport ? ReportUtils.getReportOrDraftReport(report?.chatReportID) : report; diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index dced49976c5a..2f663ac204d2 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -13,7 +13,6 @@ import type { SetPolicyCategoryMaxAmountParams, SetPolicyCategoryReceiptsRequiredParams, SetPolicyCategoryTaxParams, - SetPolicyDistanceRatesDefaultCategoryParams, SetWorkspaceCategoryDescriptionHintParams, UpdatePolicyCategoryGLCodeParams, } from '@libs/API/parameters'; @@ -28,13 +27,13 @@ import {translateLocal} from '@libs/Localize'; import Log from '@libs/Log'; import enhanceParameters from '@libs/Network/enhanceParameters'; import * as OptionsListUtils from '@libs/OptionsListUtils'; -import {navigateWhenEnableFeature, removePendingFieldsFromCustomUnit} from '@libs/PolicyUtils'; +import {navigateWhenEnableFeature} from '@libs/PolicyUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyCategory, RecentlyUsedCategories, Report} from '@src/types/onyx'; -import type {ApprovalRule, CustomUnit, ExpenseRule} from '@src/types/onyx/Policy'; +import type {ApprovalRule, ExpenseRule} from '@src/types/onyx/Policy'; import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -1015,15 +1014,15 @@ function enablePolicyCategories(policyID: string, enabled: boolean) { } } -function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit) { +function setPolicyCustomUnitDefaultCategory(policyID: string, customUnitID: string, oldCategory: string | undefined, category: string) { const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { customUnits: { - [newCustomUnit.customUnitID]: { - ...newCustomUnit, + [customUnitID]: { + defaultCategory: category, pendingFields: {defaultCategory: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, }, }, @@ -1037,7 +1036,7 @@ function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUn key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { customUnits: { - [newCustomUnit.customUnitID]: { + [customUnitID]: { pendingFields: {defaultCategory: null}, }, }, @@ -1051,8 +1050,8 @@ function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUn key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { customUnits: { - [currentCustomUnit.customUnitID]: { - ...currentCustomUnit, + [customUnitID]: { + defaultCategory: oldCategory, errorFields: {defaultCategory: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, pendingFields: {defaultCategory: null}, }, @@ -1061,12 +1060,13 @@ function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUn }, ]; - const params: SetPolicyDistanceRatesDefaultCategoryParams = { + const params = { policyID, - customUnit: JSON.stringify(removePendingFieldsFromCustomUnit(newCustomUnit)), + customUnitID, + category, }; - API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY, params, {optimisticData, successData, failureData}); + API.write(WRITE_COMMANDS.SET_CUSTOM_UNIT_DEFAULT_CATEGORY, params, {optimisticData, successData, failureData}); } function downloadCategoriesCSV(policyID: string, onDownloadFailed: () => void) { @@ -1364,7 +1364,7 @@ export { setPolicyCategoryGLCode, clearCategoryErrors, enablePolicyCategories, - setPolicyDistanceRatesDefaultCategory, + setPolicyCustomUnitDefaultCategory, deleteWorkspaceCategories, buildOptimisticPolicyCategories, setPolicyCategoryReceiptsRequired, diff --git a/src/libs/actions/Policy/PerDiem.ts b/src/libs/actions/Policy/PerDiem.ts index 1f6f0cf3dc9a..9dfb78b11564 100644 --- a/src/libs/actions/Policy/PerDiem.ts +++ b/src/libs/actions/Policy/PerDiem.ts @@ -9,6 +9,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report} from '@src/types/onyx'; +import type {ErrorFields} from '@src/types/onyx/OnyxCommon'; import type {OnyxData} from '@src/types/onyx/Request'; const allPolicies: OnyxCollection = {}; @@ -119,4 +120,14 @@ function openPolicyPerDiemPage(policyID?: string) { API.read(READ_COMMANDS.OPEN_POLICY_PER_DIEM_RATES_PAGE, params); } -export {enablePerDiem, openPolicyPerDiemPage}; +function clearPolicyPerDiemRatesErrorFields(policyID: string, customUnitID: string, updatedErrorFields: ErrorFields) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + customUnits: { + [customUnitID]: { + errorFields: updatedErrorFields, + }, + }, + }); +} + +export {enablePerDiem, openPolicyPerDiemPage, clearPolicyPerDiemRatesErrorFields}; diff --git a/src/libs/actions/Wallet.ts b/src/libs/actions/Wallet.ts index b1f97421eea0..ea7e86ef49b7 100644 --- a/src/libs/actions/Wallet.ts +++ b/src/libs/actions/Wallet.ts @@ -10,12 +10,14 @@ import type { VerifyIdentityParams, } from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import * as ErrorUtils from '@libs/ErrorUtils'; import type {PrivatePersonalDetails} from '@libs/GetPhysicalCardUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import type CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {WalletAdditionalQuestionDetails} from '@src/types/onyx'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import * as FormActions from './FormActions'; type WalletQuestionAnswer = { question: string; @@ -257,7 +259,7 @@ function answerQuestionsForWallet(answers: WalletQuestionAnswer[], idNumber: str }); } -function requestPhysicalExpensifyCard(cardID: number, authToken: string, privatePersonalDetails: PrivatePersonalDetails) { +function requestPhysicalExpensifyCard(cardID: number, authToken: string, privatePersonalDetails: PrivatePersonalDetails, validateCode: string) { const {legalFirstName = '', legalLastName = '', phoneNumber = ''} = privatePersonalDetails; const {city = '', country = '', state = '', street = '', zip = ''} = PersonalDetailsUtils.getCurrentAddress(privatePersonalDetails) ?? {}; @@ -271,6 +273,7 @@ function requestPhysicalExpensifyCard(cardID: number, authToken: string, private addressState: state, addressStreet: street, addressZip: zip, + validateCode, }; const optimisticData: OnyxUpdate[] = [ @@ -279,7 +282,7 @@ function requestPhysicalExpensifyCard(cardID: number, authToken: string, private key: ONYXKEYS.CARD_LIST, value: { [cardID]: { - state: 4, // NOT_ACTIVATED + errors: null, }, }, }, @@ -288,15 +291,96 @@ function requestPhysicalExpensifyCard(cardID: number, authToken: string, private key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, value: privatePersonalDetails, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, + value: { + isLoading: true, + errors: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.VALIDATE_ACTION_CODE, + value: { + validateCodeSent: false, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CARD_LIST, + value: { + [cardID]: { + state: 4, // NOT_ACTIVATED + errors: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, + value: { + isLoading: false, + errors: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.VALIDATE_ACTION_CODE, + value: { + validateCodeSent: false, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CARD_LIST, + value: { + [cardID]: { + state: 2, + isLoading: false, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, + value: { + isLoading: false, + }, + }, ]; - API.write(WRITE_COMMANDS.REQUEST_PHYSICAL_EXPENSIFY_CARD, requestParams, {optimisticData}); + API.write(WRITE_COMMANDS.REQUEST_PHYSICAL_EXPENSIFY_CARD, requestParams, {optimisticData, failureData, successData}); } function resetWalletAdditionalDetailsDraft() { Onyx.set(ONYXKEYS.FORMS.WALLET_ADDITIONAL_DETAILS_DRAFT, null); } +/** + * Clear the error of specific card + * @param cardID The card id of the card that you want to clear the errors. + */ +function clearPhysicalCardError(cardID?: string) { + if (!cardID) { + return; + } + + FormActions.clearErrors(ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM); + Onyx.merge(ONYXKEYS.CARD_LIST, { + [cardID]: { + errors: null, + }, + }); +} + export { openOnfidoFlow, openInitialSettingsPage, @@ -311,4 +395,5 @@ export { setKYCWallSource, requestPhysicalExpensifyCard, resetWalletAdditionalDetailsDraft, + clearPhysicalCardError, }; diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index c28290e353e7..8ab5e209a904 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -139,7 +139,7 @@ function NewChatPage() { useOptions(); const [sections, firstKeyForList] = useMemo(() => { - const sectionsList: OptionsListUtils.CategorySection[] = []; + const sectionsList: OptionsListUtils.Section[] = []; let firstKey = ''; const formatResults = OptionsListUtils.formatSectionsFromSearchTerm(debouncedSearchTerm, selectedOptions, recentReports, personalDetails); diff --git a/src/pages/RoomInvitePage.tsx b/src/pages/RoomInvitePage.tsx index 3fafc163e5ff..5294ba3b566d 100644 --- a/src/pages/RoomInvitePage.tsx +++ b/src/pages/RoomInvitePage.tsx @@ -72,7 +72,7 @@ function RoomInvitePage({ const defaultOptions = useMemo(() => { if (!areOptionsInitialized) { - return {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null, categoryOptions: []}; + return {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null}; } const inviteOptions = OptionsListUtils.getMemberInviteOptions(options.personalDetails, betas ?? [], '', excludedUsers); @@ -95,7 +95,6 @@ function RoomInvitePage({ selectedOptions: newSelectedOptions, recentReports: [], currentUserOption: null, - categoryOptions: [], }; }, [areOptionsInitialized, betas, excludedUsers, options.personalDetails, selectedOptions]); diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 92d07c3caffc..8e178f94029c 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -10,7 +10,7 @@ import SelectionList from '@components/SelectionList'; import CardListItem from '@components/SelectionList/CardListItem'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {CategorySection} from '@libs/OptionsListUtils'; +import type {Section} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; import * as SearchActions from '@userActions/Search'; @@ -28,7 +28,7 @@ function SearchFiltersCardPage() { const [newCards, setNewCards] = useState(currentCards ?? []); const sections = useMemo(() => { - const newSections: CategorySection[] = []; + const newSections: Section[] = []; const cards = Object.values(cardList ?? {}) .sort((a, b) => a.bank.localeCompare(b.bank)) .map((card) => { diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 57d3c48e3b61..d562669a6bac 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -283,7 +283,7 @@ function ReportActionsList({ } // If no unread marker exists, don't set an unread marker for newly added messages from the current user. - const isFromCurrentUser = accountID === (ReportActionsUtils.isReportPreviewAction(message) ? !message.childLastActorAccountID : message.actorAccountID); + const isFromCurrentUser = accountID === (ReportActionsUtils.isReportPreviewAction(message) ? message.childLastActorAccountID : message.actorAccountID); const isNewMessage = !prevSortedVisibleReportActionsObjects[message.reportActionID]; // The unread marker will show if the action's `created` time is later than `unreadMarkerTime`. diff --git a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx index ca655caea123..9d5253385604 100644 --- a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx @@ -113,7 +113,6 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde personalDetails: [], currentUserOption: null, headerMessage: '', - categoryOptions: [], }; } const newOptions = OptionsListUtils.filterOptions(defaultOptions, debouncedSearchTerm, { @@ -129,7 +128,7 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde * Returns the sections needed for the OptionsSelector */ const [sections, header] = useMemo(() => { - const newSections: OptionsListUtils.CategorySection[] = []; + const newSections: OptionsListUtils.Section[] = []; if (!areOptionsInitialized || !didScreenTransitionEnd) { return [newSections, '']; } diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index a17cffd0cdd9..638c58708971 100644 --- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx +++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx @@ -102,7 +102,6 @@ function MoneyRequestParticipantsSelector({ personalDetails: [], currentUserOption: null, headerMessage: '', - categoryOptions: [], }; } @@ -137,7 +136,6 @@ function MoneyRequestParticipantsSelector({ personalDetails: [], currentUserOption: null, headerMessage: '', - categoryOptions: [], }; } @@ -158,7 +156,7 @@ function MoneyRequestParticipantsSelector({ * @returns {Array} */ const [sections, header] = useMemo(() => { - const newSections: OptionsListUtils.CategorySection[] = []; + const newSections: OptionsListUtils.Section[] = []; if (!areOptionsInitialized || !didScreenTransitionEnd) { return [newSections, '']; } diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 70a9c545dc8e..72a931bf359c 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -210,19 +210,21 @@ function IOURequestStepAmount({ } if (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.REQUEST) { playSound(SOUNDS.DONE); - IOU.requestMoney( + IOU.requestMoney({ report, - backendAmount, - transaction?.attendees, - currency, - transaction?.created ?? '', - CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, - currentUserPersonalDetails.login, - currentUserPersonalDetails.accountID, - participants.at(0) ?? {}, - '', - {}, - ); + participantParams: { + participant: participants.at(0) ?? {}, + payeeEmail: currentUserPersonalDetails.login, + payeeAccountID: currentUserPersonalDetails.accountID, + }, + transactionParams: { + amount: backendAmount, + currency, + created: transaction?.created ?? '', + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + attendees: transaction?.attendees, + }, + }); return; } if (iouType === CONST.IOU.TYPE.TRACK) { diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 06e4ed83d936..d8c48dc3d587 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -238,33 +238,38 @@ function IOURequestStepConfirmation({ if (!participant) { return; } - - IOU.requestMoney( + IOU.requestMoney({ report, - transaction.amount, - transaction.attendees, - transaction.currency, - transaction.created, - transaction.merchant, - currentUserPersonalDetails.login, - currentUserPersonalDetails.accountID, - participant, - trimmedComment, - receiptObj, - transaction.category, - transaction.tag, - transactionTaxCode, - transactionTaxAmount, - transaction.billable, - policy, - policyTags, - policyCategories, + participantParams: { + payeeEmail: currentUserPersonalDetails.login, + payeeAccountID: currentUserPersonalDetails.accountID, + participant, + }, + policyParams: { + policy, + policyTagList: policyTags, + policyCategories, + }, gpsPoints, action, - transaction.actionableWhisperReportActionID, - transaction.linkedTrackedExpenseReportAction, - transaction.linkedTrackedExpenseReportID, - ); + transactionParams: { + amount: transaction.amount, + attendees: transaction.attendees, + currency: transaction.currency, + created: transaction.created, + merchant: transaction.merchant, + comment: trimmedComment, + receipt: receiptObj, + category: transaction.category, + tag: transaction.tag, + taxCode: transactionTaxCode, + taxAmount: transactionTaxAmount, + billable: transaction.billable, + actionableWhisperReportActionID: transaction.actionableWhisperReportActionID, + linkedTrackedExpenseReportAction: transaction.linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID: transaction.linkedTrackedExpenseReportID, + }, + }); }, [report, transaction, transactionTaxCode, transactionTaxAmount, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, policy, policyTags, policyCategories, action], ); diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index 418474e117ed..d491c196e9c2 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -255,19 +255,22 @@ function IOURequestStepScan({ receipt, ); } else { - IOU.requestMoney( + IOU.requestMoney({ report, - 0, - transaction?.attendees, - transaction?.currency ?? 'USD', - transaction?.created ?? '', - '', - currentUserPersonalDetails.login, - currentUserPersonalDetails.accountID, - participant, - '', - receipt, - ); + participantParams: { + payeeEmail: currentUserPersonalDetails.login, + payeeAccountID: currentUserPersonalDetails.accountID, + participant, + }, + transactionParams: { + amount: 0, + attendees: transaction?.attendees, + currency: transaction?.currency ?? 'USD', + created: transaction?.created ?? '', + merchant: '', + receipt, + }, + }); } }, [currentUserPersonalDetails.accountID, currentUserPersonalDetails.login, iouType, report, transaction?.attendees, transaction?.created, transaction?.currency], @@ -352,31 +355,30 @@ function IOURequestStepScan({ }, ); } else { - IOU.requestMoney( + IOU.requestMoney({ report, - 0, - transaction?.attendees, - transaction?.currency ?? 'USD', - transaction?.created ?? '', - '', - currentUserPersonalDetails.login, - currentUserPersonalDetails.accountID, - participant, - '', - receipt, - '', - '', - '', - 0, - false, - policy, - {}, - {}, - { + participantParams: { + payeeEmail: currentUserPersonalDetails.login, + payeeAccountID: currentUserPersonalDetails.accountID, + participant, + }, + policyParams: { + policy, + }, + gpsPoints: { lat: successData.coords.latitude, long: successData.coords.longitude, }, - ); + transactionParams: { + amount: 0, + attendees: transaction?.attendees, + currency: transaction?.currency ?? 'USD', + created: transaction?.created ?? '', + merchant: '', + receipt, + billable: false, + }, + }); } }, (errorData) => { diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 375936b05c1a..bc8622072226 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -284,19 +284,22 @@ function IOURequestStepScan({ receipt, ); } else { - IOU.requestMoney( + IOU.requestMoney({ report, - 0, - transaction?.attendees, - transaction?.currency ?? 'USD', - transaction?.created ?? '', - '', - currentUserPersonalDetails.login, - currentUserPersonalDetails.accountID, - participant, - '', - receipt, - ); + participantParams: { + payeeEmail: currentUserPersonalDetails.login, + payeeAccountID: currentUserPersonalDetails.accountID, + participant, + }, + transactionParams: { + amount: 0, + attendees: transaction?.attendees, + currency: transaction?.currency ?? 'USD', + created: transaction?.created ?? '', + merchant: '', + receipt, + }, + }); } }, [currentUserPersonalDetails.accountID, currentUserPersonalDetails.login, iouType, report, transaction?.attendees, transaction?.created, transaction?.currency], @@ -382,31 +385,30 @@ function IOURequestStepScan({ }, ); } else { - IOU.requestMoney( + IOU.requestMoney({ report, - 0, - transaction?.attendees, - transaction?.currency ?? 'USD', - transaction?.created ?? '', - '', - currentUserPersonalDetails.login, - currentUserPersonalDetails.accountID, - participant, - '', - receipt, - '', - '', - '', - 0, - false, - policy, - {}, - {}, - { + participantParams: { + payeeEmail: currentUserPersonalDetails.login, + payeeAccountID: currentUserPersonalDetails.accountID, + participant, + }, + policyParams: { + policy, + }, + gpsPoints: { lat: successData.coords.latitude, long: successData.coords.longitude, }, - ); + transactionParams: { + amount: 0, + attendees: transaction?.attendees, + currency: transaction?.currency ?? 'USD', + created: transaction?.created ?? '', + merchant: '', + receipt, + billable: false, + }, + }); } }, (errorData) => { diff --git a/src/pages/settings/AboutPage/ShareLogList/BaseShareLogList.tsx b/src/pages/settings/AboutPage/ShareLogList/BaseShareLogList.tsx index cd4674ce0165..50e0fb9aed97 100644 --- a/src/pages/settings/AboutPage/ShareLogList/BaseShareLogList.tsx +++ b/src/pages/settings/AboutPage/ShareLogList/BaseShareLogList.tsx @@ -34,7 +34,6 @@ function BaseShareLogList({onAttachLogToReport}: BaseShareLogListProps) { personalDetails: [], userToInvite: null, currentUserOption: null, - categoryOptions: [], headerMessage: '', }; } diff --git a/src/pages/settings/Preferences/PreferencesPage.tsx b/src/pages/settings/Preferences/PreferencesPage.tsx index 6616d342aa3c..9a7f1872b79a 100755 --- a/src/pages/settings/Preferences/PreferencesPage.tsx +++ b/src/pages/settings/Preferences/PreferencesPage.tsx @@ -52,19 +52,11 @@ function PreferencesPage() {
- - {translate('common.notifications')} - {translate('preferencesPage.receiveRelevantFeatureUpdatesAndExpensifyNews')} diff --git a/src/pages/settings/Security/AddDelegate/AddDelegatePage.tsx b/src/pages/settings/Security/AddDelegate/AddDelegatePage.tsx index 6f54fc098633..1b5fc3ce246c 100644 --- a/src/pages/settings/Security/AddDelegate/AddDelegatePage.tsx +++ b/src/pages/settings/Security/AddDelegate/AddDelegatePage.tsx @@ -50,7 +50,6 @@ function useOptions() { personalDetails, currentUserOption, headerMessage, - categoryOptions: [], }; }, [optionsList.reports, optionsList.personalDetails, betas, existingDelegates, isLoading]); diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx index ae003c4afbe2..eef5024180e7 100644 --- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx @@ -1,24 +1,28 @@ -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import type {ReactNode} from 'react'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import FormProvider from '@components/Form/FormProvider'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; +import ValidateCodeActionModal from '@components/ValidateCodeActionModal'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as FormActions from '@libs/actions/FormActions'; +import * as User from '@libs/actions/User'; import * as Wallet from '@libs/actions/Wallet'; import * as CardUtils from '@libs/CardUtils'; +import * as ErrorUtils from '@libs/ErrorUtils'; import * as GetPhysicalCardUtils from '@libs/GetPhysicalCardUtils'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {GetPhysicalCardForm} from '@src/types/form'; -import type {CardList, LoginList, PrivatePersonalDetails, Session} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; type OnValidate = (values: OnyxEntry) => Errors; @@ -28,24 +32,7 @@ type RenderContentProps = ChildrenProps & { onValidate: OnValidate; }; -type BaseGetPhysicalCardOnyxProps = { - /** List of available assigned cards */ - cardList: OnyxEntry; - - /** User's private personal details */ - privatePersonalDetails: OnyxEntry; - - /** Draft values used by the get physical card form */ - draftValues: OnyxEntry; - - /** Session info for the currently logged in user. */ - session: OnyxEntry; - - /** List of available login methods */ - loginList: OnyxEntry; -}; - -type BaseGetPhysicalCardProps = BaseGetPhysicalCardOnyxProps & { +type BaseGetPhysicalCardProps = { /** Text displayed below page title */ headline: string; @@ -91,27 +78,32 @@ function DefaultRenderContent({onSubmit, submitButtonText, children, onValidate} } function BaseGetPhysicalCard({ - cardList, children, currentRoute, domain, - draftValues, - privatePersonalDetails, headline, isConfirmation = false, - loginList, renderContent = DefaultRenderContent, - session, submitButtonText, title, onValidate = () => ({}), }: BaseGetPhysicalCardProps) { const styles = useThemeStyles(); const isRouteSet = useRef(false); - + const {translate} = useLocalize(); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); + const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); + const [session] = useOnyx(ONYXKEYS.SESSION); + const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); + const [validateCodeAction] = useOnyx(ONYXKEYS.VALIDATE_ACTION_CODE); + const [draftValues] = useOnyx(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const [isActionCodeModalVisible, setActionCodeModalVisible] = useState(false); + const [formData] = useOnyx(ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM); const domainCards = CardUtils.getDomainCards(cardList)[domain] || []; const cardToBeIssued = domainCards.find((card) => !card?.nameValuePairs?.isVirtual && card?.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED); - const cardID = cardToBeIssued?.cardID.toString() ?? '-1'; + const [currentCardID, setCurrentCardID] = useState(cardToBeIssued?.cardID.toString()); + const errorMessage = ErrorUtils.getLatestErrorMessageField(cardToBeIssued); useEffect(() => { if (isRouteSet.current || !privatePersonalDetails || !cardList) { @@ -144,19 +136,39 @@ function BaseGetPhysicalCard({ isRouteSet.current = true; }, [cardList, currentRoute, domain, domainCards.length, draftValues, loginList, cardToBeIssued, privatePersonalDetails]); + useEffect(() => { + // Current step of the get physical card flow should be the confirmation page; and + // Card has NOT_ACTIVATED state when successfully being issued so cardToBeIssued should be undefined + if (!isConfirmation || !!cardToBeIssued || !currentCardID) { + return; + } + + // Form draft data needs to be erased when the flow is complete, + // so that no stale data is left on Onyx + FormActions.clearDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM); + Wallet.clearPhysicalCardError(currentCardID); + Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(currentCardID)); + setCurrentCardID(undefined); + }, [currentCardID, isConfirmation, cardToBeIssued]); + const onSubmit = useCallback(() => { const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues, privatePersonalDetails); - // If the current step of the get physical card flow is the confirmation page if (isConfirmation) { - Wallet.requestPhysicalExpensifyCard(cardToBeIssued?.cardID ?? -1, session?.authToken ?? '', updatedPrivatePersonalDetails); - // Form draft data needs to be erased when the flow is complete, - // so that no stale data is left on Onyx - FormActions.clearDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM); - Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(cardID.toString())); + setActionCodeModalVisible(true); return; } GetPhysicalCardUtils.goToNextPhysicalCardRoute(domain, updatedPrivatePersonalDetails); - }, [cardID, cardToBeIssued?.cardID, domain, draftValues, isConfirmation, session?.authToken, privatePersonalDetails]); + }, [isConfirmation, domain, draftValues, privatePersonalDetails]); + + const handleIssuePhysicalCard = useCallback( + (validateCode: string) => { + setCurrentCardID(cardToBeIssued?.cardID.toString()); + const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues, privatePersonalDetails); + Wallet.requestPhysicalExpensifyCard(cardToBeIssued?.cardID ?? -1, session?.authToken ?? '', updatedPrivatePersonalDetails, validateCode); + }, + [cardToBeIssued?.cardID, draftValues, session?.authToken, privatePersonalDetails], + ); + return ( Navigation.goBack(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(cardID))} + onBackButtonPress={() => { + if (currentCardID) { + Navigation.goBack(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(currentCardID)); + } + Navigation.goBack(); + }} /> {headline} {renderContent({onSubmit, submitButtonText, children, onValidate})} + User.requestValidateCodeAction()} + clearError={() => Wallet.clearPhysicalCardError(currentCardID)} + validateError={!isEmptyObject(formData?.errors) ? formData?.errors : errorMessage} + handleSubmitForm={handleIssuePhysicalCard} + title={translate('cardPage.validateCardTitle')} + onClose={() => setActionCodeModalVisible(false)} + descriptionPrimary={translate('cardPage.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})} + /> ); } BaseGetPhysicalCard.displayName = 'BaseGetPhysicalCard'; -export default withOnyx({ - cardList: { - key: ONYXKEYS.CARD_LIST, - }, - loginList: { - key: ONYXKEYS.LOGIN_LIST, - }, - session: { - key: ONYXKEYS.SESSION, - }, - privatePersonalDetails: { - key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, - }, - draftValues: { - key: ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT, - }, -})(BaseGetPhysicalCard); +export default BaseGetPhysicalCard; export type {RenderContentProps}; diff --git a/src/pages/tasks/TaskAssigneeSelectorModal.tsx b/src/pages/tasks/TaskAssigneeSelectorModal.tsx index 33ca336206fe..27da040fa654 100644 --- a/src/pages/tasks/TaskAssigneeSelectorModal.tsx +++ b/src/pages/tasks/TaskAssigneeSelectorModal.tsx @@ -62,7 +62,6 @@ function useOptions() { personalDetails, currentUserOption, headerMessage, - categoryOptions: [], }; }, [optionsList.reports, optionsList.personalDetails, betas, isLoading]); diff --git a/src/pages/tasks/TaskShareDestinationSelectorModal.tsx b/src/pages/tasks/TaskShareDestinationSelectorModal.tsx index 8465474ef609..7ce89e666300 100644 --- a/src/pages/tasks/TaskShareDestinationSelectorModal.tsx +++ b/src/pages/tasks/TaskShareDestinationSelectorModal.tsx @@ -63,7 +63,6 @@ function TaskShareDestinationSelectorModal() { personalDetails: [], userToInvite: null, currentUserOption: null, - categoryOptions: [], header: '', }; } @@ -75,7 +74,6 @@ function TaskShareDestinationSelectorModal() { personalDetails: [], userToInvite: null, currentUserOption: null, - categoryOptions: [], header, }; }, [areOptionsInitialized, optionList.personalDetails, optionList.reports]); diff --git a/src/pages/workspace/WorkspaceInvitePage.tsx b/src/pages/workspace/WorkspaceInvitePage.tsx index 7fef254d3ca9..64f60f42bbed 100644 --- a/src/pages/workspace/WorkspaceInvitePage.tsx +++ b/src/pages/workspace/WorkspaceInvitePage.tsx @@ -77,12 +77,12 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { const defaultOptions = useMemo(() => { if (!areOptionsInitialized) { - return {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null, categoryOptions: []}; + return {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null}; } const inviteOptions = OptionsListUtils.getMemberInviteOptions(options.personalDetails, betas ?? [], '', excludedUsers, true); - return {...inviteOptions, recentReports: [], currentUserOption: null, categoryOptions: []}; + return {...inviteOptions, recentReports: [], currentUserOption: null}; }, [areOptionsInitialized, betas, excludedUsers, options.personalDetails]); const inviteOptions = useMemo( diff --git a/src/pages/workspace/WorkspaceProfilePage.tsx b/src/pages/workspace/WorkspaceProfilePage.tsx index 979df0099d82..482d66846e66 100644 --- a/src/pages/workspace/WorkspaceProfilePage.tsx +++ b/src/pages/workspace/WorkspaceProfilePage.tsx @@ -61,7 +61,9 @@ function WorkspaceProfilePage({policyDraft, policy: policyProp, route}: Workspac const workspaceAccountID = PolicyUtils.getWorkspaceAccountID(policy?.id ?? '-1'); const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`); const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`); - const hasCardFeedOrExpensifyCard = !isEmptyObject(cardFeeds) || !isEmptyObject(cardsList); + const hasCardFeedOrExpensifyCard = + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + !isEmptyObject(cardFeeds) || !isEmptyObject(cardsList) || ((policy?.areExpensifyCardsEnabled || policy?.areCompanyCardsEnabled) && policy?.workspaceAccountID); const [street1, street2] = (policy?.address?.addressStreet ?? '').split('\n'); const formattedAddress = diff --git a/src/pages/workspace/WorkspaceProfileSharePage.tsx b/src/pages/workspace/WorkspaceProfileSharePage.tsx index 16a076205ad3..58c4564cc842 100644 --- a/src/pages/workspace/WorkspaceProfileSharePage.tsx +++ b/src/pages/workspace/WorkspaceProfileSharePage.tsx @@ -86,7 +86,7 @@ function WorkspaceProfileSharePage({policy}: WithPolicyProps) { if (!adminRoom?.reportID) { return; } - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(adminRoom.reportID)); + Navigation.dismissModal(adminRoom.reportID); }} > {CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS} diff --git a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx index aea6f596badd..12502787d9df 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx @@ -1,6 +1,7 @@ import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import CategorySelectorModal from '@components/CategorySelector/CategorySelectorModal'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; @@ -12,7 +13,6 @@ import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import CategorySelectorModal from '@pages/workspace/distanceRates/CategorySelector/CategorySelectorModal'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index 75b4f44fc843..7959879609e7 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -36,7 +36,6 @@ function WorkspaceCompanyCardsList({cardsList, policyID}: WorkspaceCompanyCardsL const renderItem = useCallback( ({item, index}: ListRenderItemInfo) => { const cardID = Object.keys(cardsList ?? {}).find((id) => cardsList?.[id].cardID === item.cardID); - const cardName = CardUtils.getCompanyCardNumber(cardsList?.cardList ?? {}, item.lastFourPAN); const isCardDeleted = item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; return ( diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx index 41f698f61dab..8215a3d0bf40 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx @@ -1,5 +1,5 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useState} from 'react'; +import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import ConfirmModal from '@components/ConfirmModal'; @@ -23,6 +23,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import type {CompanyCardFeed} from '@src/types/onyx'; type WorkspaceCompanyCardsSettingsPageProps = StackScreenProps; @@ -39,7 +40,8 @@ function WorkspaceCompanyCardsSettingsPage({ const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`); const [lastSelectedFeed] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`); - const selectedFeed = CardUtils.getSelectedFeed(lastSelectedFeed, cardFeeds); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we want to run the hook only once to escape unexpected feed change + const selectedFeed = useMemo(() => CardUtils.getSelectedFeed(lastSelectedFeed, cardFeeds), []); const feedName = CardUtils.getCustomOrFormattedFeedName(selectedFeed, cardFeeds?.settings?.companyCardNicknames); const companyFeeds = CardUtils.getCompanyFeeds(cardFeeds); const liabilityType = selectedFeed && companyFeeds[selectedFeed]?.liabilityType; @@ -51,7 +53,8 @@ function WorkspaceCompanyCardsSettingsPage({ const deleteCompanyCardFeed = () => { if (selectedFeed) { - CompanyCards.deleteWorkspaceCompanyCardFeed(policyID, workspaceAccountID, selectedFeed); + const feedToOpen = (Object.keys(companyFeeds) as CompanyCardFeed[]).filter((feed) => feed !== selectedFeed).at(0); + CompanyCards.deleteWorkspaceCompanyCardFeed(policyID, workspaceAccountID, selectedFeed, feedToOpen); } setDeleteCompanyCardConfirmModalVisible(false); Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx index eed24a4ea13f..fbbdf5ee382f 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx @@ -3,6 +3,7 @@ import React, {useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import CategorySelector from '@components/CategorySelector'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -28,7 +29,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {CustomUnit} from '@src/types/onyx/Policy'; -import CategorySelector from './CategorySelector'; import UnitSelector from './UnitSelector'; type PolicyDistanceRatesSettingsPageProps = StackScreenProps; @@ -61,14 +61,11 @@ function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPag }; const setNewCategory = (category: ListItem) => { - if (!category.searchText || !customUnit) { + if (!category.searchText || !customUnit || defaultCategory === category.searchText) { return; } - Category.setPolicyDistanceRatesDefaultCategory(policyID, customUnit, { - ...customUnit, - defaultCategory: defaultCategory === category.searchText ? '' : category.searchText, - }); + Category.setPolicyCustomUnitDefaultCategory(policyID, customUnitID, customUnit.defaultCategory, category.searchText); }; const clearErrorFields = (fieldName: keyof CustomUnit) => { @@ -125,7 +122,7 @@ function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPag > { - // TODO: Uncomment this when the import feature is ready - // Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_RATES_SETTINGS.getRoute(policyID)); + Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_SETTINGS.getRoute(policyID)); }; // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/src/pages/workspace/perDiem/WorkspacePerDiemSettingsPage.tsx b/src/pages/workspace/perDiem/WorkspacePerDiemSettingsPage.tsx new file mode 100644 index 000000000000..1fd28b4ff75a --- /dev/null +++ b/src/pages/workspace/perDiem/WorkspacePerDiemSettingsPage.tsx @@ -0,0 +1,101 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useState} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import CategorySelector from '@components/CategorySelector'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import type {ListItem} from '@components/SelectionList/types'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {clearPolicyPerDiemRatesErrorFields} from '@libs/actions/Policy/PerDiem'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import {getPerDiemCustomUnit} from '@libs/PolicyUtils'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as Category from '@userActions/Policy/Category'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type {CustomUnit} from '@src/types/onyx/Policy'; + +type WorkspacePerDiemSettingsPageProps = StackScreenProps; + +function WorkspacePerDiemSettingsPage({route}: WorkspacePerDiemSettingsPageProps) { + const policyID = route.params.policyID; + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + + const styles = useThemeStyles(); + const [isCategoryPickerVisible, setIsCategoryPickerVisible] = useState(false); + const {translate} = useLocalize(); + const customUnit = getPerDiemCustomUnit(policy); + const customUnitID = customUnit?.customUnitID ?? ''; + + const defaultCategory = customUnit?.defaultCategory; + const errorFields = customUnit?.errorFields; + + const FullPageBlockingView = !customUnit ? FullPageOfflineBlockingView : View; + + const setNewCategory = (category: ListItem) => { + if (!category.searchText || !customUnit || defaultCategory === category.searchText) { + return; + } + + Category.setPolicyCustomUnitDefaultCategory(policyID, customUnitID, customUnit.defaultCategory, category.searchText); + }; + + const clearErrorFields = (fieldName: keyof CustomUnit) => { + clearPolicyPerDiemRatesErrorFields(policyID, customUnitID, {...errorFields, [fieldName]: null}); + }; + + return ( + + + + + + {!!policy?.areCategoriesEnabled && OptionsListUtils.hasEnabledOptions(policyCategories ?? {}) && ( + clearErrorFields('defaultCategory')} + > + setIsCategoryPickerVisible(true)} + hidePickerModal={() => setIsCategoryPickerVisible(false)} + /> + + )} + + + + + ); +} + +WorkspacePerDiemSettingsPage.displayName = 'WorkspacePerDiemSettingsPage'; + +export default WorkspacePerDiemSettingsPage; diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 1c26390f7b09..4430ec0ce052 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -80,7 +80,22 @@ describe('actions/IOU', () => { let transactionThread: OnyxEntry; let transactionThreadCreatedAction: OnyxEntry; mockFetch?.pause?.(); - IOU.requestMoney({reportID: ''}, amount, [], CONST.CURRENCY.USD, '', merchant, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment, {}); + IOU.requestMoney({ + report: {reportID: ''}, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + }); return waitForBatchedUpdates() .then( () => @@ -279,7 +294,22 @@ describe('actions/IOU', () => { }), ) .then(() => { - IOU.requestMoney(chatReport, amount, [], CONST.CURRENCY.USD, '', '', RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment, {}); + IOU.requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment, + }, + }); return waitForBatchedUpdates(); }) .then( @@ -483,7 +513,22 @@ describe('actions/IOU', () => { .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransaction.transactionID}`, existingTransaction)) .then(() => { if (chatReport) { - IOU.requestMoney(chatReport, amount, [], CONST.CURRENCY.USD, '', '', RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment, {}); + IOU.requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment, + }, + }); } return waitForBatchedUpdates(); }) @@ -623,7 +668,22 @@ describe('actions/IOU', () => { let transactionThreadReport: OnyxEntry; let transactionThreadAction: OnyxEntry; mockFetch?.pause?.(); - IOU.requestMoney({reportID: ''}, amount, [], CONST.CURRENCY.USD, '', '', RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment, {}); + IOU.requestMoney({ + report: {reportID: ''}, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment, + }, + }); return ( waitForBatchedUpdates() .then( @@ -1424,7 +1484,22 @@ describe('actions/IOU', () => { let createIOUAction: OnyxEntry>; let payIOUAction: OnyxEntry; let transaction: OnyxEntry; - IOU.requestMoney({reportID: ''}, amount, [], CONST.CURRENCY.USD, '', '', RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment, {}); + IOU.requestMoney({ + report: {reportID: ''}, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment, + }, + }); return waitForBatchedUpdates() .then( () => @@ -1645,19 +1720,22 @@ describe('actions/IOU', () => { ) .then(() => { if (chatReport) { - IOU.requestMoney( - chatReport, - amount, - [], - CONST.CURRENCY.USD, - '', - merchant, - RORY_EMAIL, - RORY_ACCOUNT_ID, - {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - comment, - {}, - ); + IOU.requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + }); } return waitForBatchedUpdates(); }) @@ -1770,19 +1848,22 @@ describe('actions/IOU', () => { ) .then(() => { if (chatReport) { - IOU.requestMoney( - chatReport, - amount, - [], - CONST.CURRENCY.USD, - '', - merchant, - RORY_EMAIL, - RORY_ACCOUNT_ID, - {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - comment, - {}, - ); + IOU.requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + }); } return waitForBatchedUpdates(); }) @@ -1868,7 +1949,22 @@ describe('actions/IOU', () => { await TestHelper.setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID); // When a submit IOU expense is made - IOU.requestMoney({reportID: ''}, amount, [], CONST.CURRENCY.USD, '', '', TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID, {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}, comment, {}); + IOU.requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: TEST_USER_LOGIN, + payeeAccountID: TEST_USER_ACCOUNT_ID, + participant: {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment, + }, + }); await waitForBatchedUpdates(); // When fetching all reports from Onyx @@ -2602,7 +2698,22 @@ describe('actions/IOU', () => { const amount2 = 20000; const comment2 = 'Send me money please 2'; if (chatReport) { - IOU.requestMoney(chatReport, amount2, [], CONST.CURRENCY.USD, '', '', TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID, {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}, comment2, {}); + IOU.requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: TEST_USER_LOGIN, + payeeAccountID: TEST_USER_ACCOUNT_ID, + participant: {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}, + }, + transactionParams: { + amount: amount2, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment: comment2, + }, + }); } await waitForBatchedUpdates(); @@ -2805,19 +2916,22 @@ describe('actions/IOU', () => { ) .then(() => { if (chatReport) { - IOU.requestMoney( - chatReport, - amount, - [], - CONST.CURRENCY.USD, - '', - merchant, - RORY_EMAIL, - RORY_ACCOUNT_ID, - {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, - comment, - {}, - ); + IOU.requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + }); } return waitForBatchedUpdates(); }) @@ -2909,19 +3023,22 @@ describe('actions/IOU', () => { ) .then(() => { if (chatReport) { - IOU.requestMoney( - chatReport, - amount, - [], - CONST.CURRENCY.USD, - '', - merchant, - RORY_EMAIL, - RORY_ACCOUNT_ID, - {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, - comment, - {}, - ); + IOU.requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + }); } return waitForBatchedUpdates(); }) @@ -3014,19 +3131,22 @@ describe('actions/IOU', () => { ) .then(() => { if (chatReport) { - IOU.requestMoney( - chatReport, - amount, - [], - CONST.CURRENCY.USD, - '', - merchant, - RORY_EMAIL, - RORY_ACCOUNT_ID, - {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, - comment, - {}, - ); + IOU.requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + }); } return waitForBatchedUpdates(); }) diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index c279079b995b..1cd17e33829d 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -55,7 +55,7 @@ describe('actions/Report', () => { const promise = Onyx.clear().then(jest.useRealTimers); if (getIsUsingFakeTimers()) { // flushing pending timers - // Onyx.clear() promise is resolved in batch which happends after the current microtasks cycle + // Onyx.clear() promise is resolved in batch which happens after the current microtasks cycle setImmediate(jest.runOnlyPendingTimers); } diff --git a/tests/unit/APITest.ts b/tests/unit/APITest.ts index 14c4cadcb26d..ced9d5e68c4b 100644 --- a/tests/unit/APITest.ts +++ b/tests/unit/APITest.ts @@ -8,8 +8,8 @@ import HttpUtils from '@src/libs/HttpUtils'; import * as MainQueue from '@src/libs/Network/MainQueue'; import * as NetworkStore from '@src/libs/Network/NetworkStore'; import * as SequentialQueue from '@src/libs/Network/SequentialQueue'; +import {sequentialQueueRequestThrottle} from '@src/libs/Network/SequentialQueue'; import * as Request from '@src/libs/Request'; -import * as RequestThrottle from '@src/libs/RequestThrottle'; import ONYXKEYS from '@src/ONYXKEYS'; import type ReactNativeOnyxMock from '../../__mocks__/react-native-onyx'; import * as TestHelper from '../utils/TestHelper'; @@ -47,6 +47,7 @@ beforeEach(() => { MainQueue.clear(); HttpUtils.cancelPendingRequests(); PersistedRequests.clear(); + sequentialQueueRequestThrottle.clear(); NetworkStore.checkRequiredData(); // Wait for any Log command to finish and Onyx to fully clear @@ -242,7 +243,7 @@ describe('APITests', () => { // We let the SequentialQueue process again after its wait time return new Promise((resolve) => { - setTimeout(resolve, RequestThrottle.getLastRequestWaitTime()); + setTimeout(resolve, sequentialQueueRequestThrottle.getLastRequestWaitTime()); }); }) .then(() => { @@ -255,7 +256,7 @@ describe('APITests', () => { // We let the SequentialQueue process again after its wait time return new Promise((resolve) => { - setTimeout(resolve, RequestThrottle.getLastRequestWaitTime()); + setTimeout(resolve, sequentialQueueRequestThrottle.getLastRequestWaitTime()); }).then(waitForBatchedUpdates); }) .then(() => { diff --git a/tests/unit/CategoryOptionListUtilsTest.ts b/tests/unit/CategoryOptionListUtilsTest.ts new file mode 100644 index 000000000000..2537094511ce --- /dev/null +++ b/tests/unit/CategoryOptionListUtilsTest.ts @@ -0,0 +1,1242 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import * as CategoryOptionsListUtils from '@libs/CategoryOptionListUtils'; +import type {PolicyCategories} from '@src/types/onyx'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; + +describe('CategoryOptionListUtils', () => { + it('getCategoryListSections()', () => { + const search = 'Food'; + const emptySearch = ''; + const wrongSearch = 'bla bla'; + const recentlyUsedCategories = ['Taxi', 'Restaurant']; + const selectedOptions: CategoryOptionsListUtils.Category[] = [ + { + name: 'Medical', + enabled: true, + }, + ]; + const smallCategoriesList: PolicyCategories = { + Taxi: { + enabled: false, + name: 'Taxi', + unencodedName: 'Taxi', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + pendingAction: undefined, + }, + Restaurant: { + enabled: true, + name: 'Restaurant', + unencodedName: 'Restaurant', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + pendingAction: 'delete', + }, + Food: { + enabled: true, + name: 'Food', + unencodedName: 'Food', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + pendingAction: undefined, + }, + 'Food: Meat': { + enabled: true, + name: 'Food: Meat', + unencodedName: 'Food: Meat', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + pendingAction: undefined, + }, + }; + const smallResultList: CategoryOptionsListUtils.CategoryTreeSection[] = [ + { + title: '', + shouldShow: false, + data: [ + { + text: 'Food', + keyForList: 'Food', + searchText: 'Food', + tooltipText: 'Food', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Meat', + keyForList: 'Food: Meat', + searchText: 'Food: Meat', + tooltipText: 'Meat', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Restaurant', + keyForList: 'Restaurant', + searchText: 'Restaurant', + tooltipText: 'Restaurant', + isDisabled: true, + isSelected: false, + pendingAction: 'delete', + }, + ], + indexOffset: 3, + }, + ]; + const smallSearchResultList: CategoryOptionsListUtils.CategoryTreeSection[] = [ + { + title: '', + shouldShow: true, + indexOffset: 2, + data: [ + { + text: 'Food', + keyForList: 'Food', + searchText: 'Food', + tooltipText: 'Food', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Food: Meat', + keyForList: 'Food: Meat', + searchText: 'Food: Meat', + tooltipText: 'Food: Meat', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + ], + }, + ]; + const smallWrongSearchResultList: CategoryOptionsListUtils.CategoryTreeSection[] = [ + { + title: '', + shouldShow: true, + indexOffset: 0, + data: [], + }, + ]; + const largeCategoriesList: PolicyCategories = { + Taxi: { + enabled: false, + name: 'Taxi', + unencodedName: 'Taxi', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + Restaurant: { + enabled: true, + name: 'Restaurant', + unencodedName: 'Restaurant', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + Food: { + enabled: true, + name: 'Food', + unencodedName: 'Food', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Food: Meat': { + enabled: true, + name: 'Food: Meat', + unencodedName: 'Food: Meat', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Food: Milk': { + enabled: true, + name: 'Food: Milk', + unencodedName: 'Food: Milk', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Food: Vegetables': { + enabled: false, + name: 'Food: Vegetables', + unencodedName: 'Food: Vegetables', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Cars: Audi': { + enabled: true, + name: 'Cars: Audi', + unencodedName: 'Cars: Audi', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Cars: BMW': { + enabled: false, + name: 'Cars: BMW', + unencodedName: 'Cars: BMW', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Cars: Mercedes-Benz': { + enabled: true, + name: 'Cars: Mercedes-Benz', + unencodedName: 'Cars: Mercedes-Benz', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + Medical: { + enabled: false, + name: 'Medical', + unencodedName: 'Medical', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Travel: Meals': { + enabled: true, + name: 'Travel: Meals', + unencodedName: 'Travel: Meals', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Travel: Meals: Breakfast': { + enabled: true, + name: 'Travel: Meals: Breakfast', + unencodedName: 'Travel: Meals: Breakfast', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Travel: Meals: Dinner': { + enabled: false, + name: 'Travel: Meals: Dinner', + unencodedName: 'Travel: Meals: Dinner', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Travel: Meals: Lunch': { + enabled: true, + name: 'Travel: Meals: Lunch', + unencodedName: 'Travel: Meals: Lunch', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + }; + const largeResultList: CategoryOptionsListUtils.CategoryTreeSection[] = [ + { + title: '', + shouldShow: false, + indexOffset: 1, + data: [ + { + text: 'Medical', + keyForList: 'Medical', + searchText: 'Medical', + tooltipText: 'Medical', + isDisabled: true, + isSelected: true, + pendingAction: undefined, + }, + ], + }, + { + title: 'Recent', + shouldShow: true, + indexOffset: 1, + data: [ + { + text: 'Restaurant', + keyForList: 'Restaurant', + searchText: 'Restaurant', + tooltipText: 'Restaurant', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + ], + }, + { + title: 'All', + shouldShow: true, + indexOffset: 11, + data: [ + { + text: 'Cars', + keyForList: 'Cars', + searchText: 'Cars', + tooltipText: 'Cars', + isDisabled: true, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Audi', + keyForList: 'Cars: Audi', + searchText: 'Cars: Audi', + tooltipText: 'Audi', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Mercedes-Benz', + keyForList: 'Cars: Mercedes-Benz', + searchText: 'Cars: Mercedes-Benz', + tooltipText: 'Mercedes-Benz', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Food', + keyForList: 'Food', + searchText: 'Food', + tooltipText: 'Food', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Meat', + keyForList: 'Food: Meat', + searchText: 'Food: Meat', + tooltipText: 'Meat', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Milk', + keyForList: 'Food: Milk', + searchText: 'Food: Milk', + tooltipText: 'Milk', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Restaurant', + keyForList: 'Restaurant', + searchText: 'Restaurant', + tooltipText: 'Restaurant', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Travel', + keyForList: 'Travel', + searchText: 'Travel', + tooltipText: 'Travel', + isDisabled: true, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Meals', + keyForList: 'Travel: Meals', + searchText: 'Travel: Meals', + tooltipText: 'Meals', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Breakfast', + keyForList: 'Travel: Meals: Breakfast', + searchText: 'Travel: Meals: Breakfast', + tooltipText: 'Breakfast', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Lunch', + keyForList: 'Travel: Meals: Lunch', + searchText: 'Travel: Meals: Lunch', + tooltipText: 'Lunch', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + ], + }, + ]; + const largeSearchResultList: CategoryOptionsListUtils.CategoryTreeSection[] = [ + { + title: '', + shouldShow: true, + indexOffset: 3, + data: [ + { + text: 'Food', + keyForList: 'Food', + searchText: 'Food', + tooltipText: 'Food', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Food: Meat', + keyForList: 'Food: Meat', + searchText: 'Food: Meat', + tooltipText: 'Food: Meat', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Food: Milk', + keyForList: 'Food: Milk', + searchText: 'Food: Milk', + tooltipText: 'Food: Milk', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + ], + }, + ]; + const largeWrongSearchResultList: CategoryOptionsListUtils.CategoryTreeSection[] = [ + { + title: '', + shouldShow: true, + indexOffset: 0, + data: [], + }, + ]; + const emptyCategoriesList = {}; + const emptySelectedResultList: CategoryOptionsListUtils.CategoryTreeSection[] = [ + { + title: '', + shouldShow: false, + indexOffset: 1, + data: [ + { + text: 'Medical', + keyForList: 'Medical', + searchText: 'Medical', + tooltipText: 'Medical', + isDisabled: true, + isSelected: true, + pendingAction: undefined, + }, + ], + }, + ]; + + const smallResult = CategoryOptionsListUtils.getCategoryListSections({ + searchValue: emptySearch, + categories: smallCategoriesList, + }); + expect(smallResult).toStrictEqual(smallResultList); + + const smallSearchResult = CategoryOptionsListUtils.getCategoryListSections({searchValue: search, categories: smallCategoriesList}); + expect(smallSearchResult).toStrictEqual(smallSearchResultList); + + const smallWrongSearchResult = CategoryOptionsListUtils.getCategoryListSections({searchValue: wrongSearch, categories: smallCategoriesList}); + expect(smallWrongSearchResult).toStrictEqual(smallWrongSearchResultList); + + const largeResult = CategoryOptionsListUtils.getCategoryListSections({ + searchValue: emptySearch, + selectedOptions, + categories: largeCategoriesList, + recentlyUsedCategories, + }); + expect(largeResult).toStrictEqual(largeResultList); + + const largeSearchResult = CategoryOptionsListUtils.getCategoryListSections({ + searchValue: search, + selectedOptions, + categories: largeCategoriesList, + recentlyUsedCategories, + }); + expect(largeSearchResult).toStrictEqual(largeSearchResultList); + + const largeWrongSearchResult = CategoryOptionsListUtils.getCategoryListSections({ + searchValue: wrongSearch, + selectedOptions, + categories: largeCategoriesList, + recentlyUsedCategories, + }); + expect(largeWrongSearchResult).toStrictEqual(largeWrongSearchResultList); + + const emptyResult = CategoryOptionsListUtils.getCategoryListSections({searchValue: search, selectedOptions, categories: emptyCategoriesList}); + expect(emptyResult).toStrictEqual(emptySelectedResultList); + }); + + it('getCategoryOptionTree()', () => { + const categories = { + Meals: { + enabled: true, + name: 'Meals', + }, + Restaurant: { + enabled: true, + name: 'Restaurant', + }, + Food: { + enabled: true, + name: 'Food', + }, + 'Food: Meat': { + enabled: true, + name: 'Food: Meat', + }, + 'Food: Milk': { + enabled: true, + name: 'Food: Milk', + }, + 'Cars: Audi': { + enabled: true, + name: 'Cars: Audi', + }, + 'Cars: Mercedes-Benz': { + enabled: true, + name: 'Cars: Mercedes-Benz', + }, + 'Travel: Meals': { + enabled: true, + name: 'Travel: Meals', + }, + 'Travel: Meals: Breakfast': { + enabled: true, + name: 'Travel: Meals: Breakfast', + }, + 'Travel: Meals: Lunch': { + enabled: true, + name: 'Travel: Meals: Lunch', + }, + Plain: { + enabled: true, + name: 'Plain', + }, + Audi: { + enabled: true, + name: 'Audi', + }, + Health: { + enabled: true, + name: 'Health', + }, + 'A: B: C': { + enabled: true, + name: 'A: B: C', + }, + 'A: B: C: D: E': { + enabled: true, + name: 'A: B: C: D: E', + }, + }; + const result = [ + { + text: 'Meals', + keyForList: 'Meals', + searchText: 'Meals', + tooltipText: 'Meals', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Restaurant', + keyForList: 'Restaurant', + searchText: 'Restaurant', + tooltipText: 'Restaurant', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Food', + keyForList: 'Food', + searchText: 'Food', + tooltipText: 'Food', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Meat', + keyForList: 'Food: Meat', + searchText: 'Food: Meat', + tooltipText: 'Meat', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Milk', + keyForList: 'Food: Milk', + searchText: 'Food: Milk', + tooltipText: 'Milk', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Cars', + keyForList: 'Cars', + searchText: 'Cars', + tooltipText: 'Cars', + isDisabled: true, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Audi', + keyForList: 'Cars: Audi', + searchText: 'Cars: Audi', + tooltipText: 'Audi', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Mercedes-Benz', + keyForList: 'Cars: Mercedes-Benz', + searchText: 'Cars: Mercedes-Benz', + tooltipText: 'Mercedes-Benz', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Travel', + keyForList: 'Travel', + searchText: 'Travel', + tooltipText: 'Travel', + isDisabled: true, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Meals', + keyForList: 'Travel: Meals', + searchText: 'Travel: Meals', + tooltipText: 'Meals', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Breakfast', + keyForList: 'Travel: Meals: Breakfast', + searchText: 'Travel: Meals: Breakfast', + tooltipText: 'Breakfast', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Lunch', + keyForList: 'Travel: Meals: Lunch', + searchText: 'Travel: Meals: Lunch', + tooltipText: 'Lunch', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Plain', + keyForList: 'Plain', + searchText: 'Plain', + tooltipText: 'Plain', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Audi', + keyForList: 'Audi', + searchText: 'Audi', + tooltipText: 'Audi', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Health', + keyForList: 'Health', + searchText: 'Health', + tooltipText: 'Health', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'A', + keyForList: 'A', + searchText: 'A', + tooltipText: 'A', + isDisabled: true, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' B', + keyForList: 'A: B', + searchText: 'A: B', + tooltipText: 'B', + isDisabled: true, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' C', + keyForList: 'A: B: C', + searchText: 'A: B: C', + tooltipText: 'C', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' D', + keyForList: 'A: B: C: D', + searchText: 'A: B: C: D', + tooltipText: 'D', + isDisabled: true, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' E', + keyForList: 'A: B: C: D: E', + searchText: 'A: B: C: D: E', + tooltipText: 'E', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + ]; + const resultOneLine = [ + { + text: 'Meals', + keyForList: 'Meals', + searchText: 'Meals', + tooltipText: 'Meals', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Restaurant', + keyForList: 'Restaurant', + searchText: 'Restaurant', + tooltipText: 'Restaurant', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Food', + keyForList: 'Food', + searchText: 'Food', + tooltipText: 'Food', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Food: Meat', + keyForList: 'Food: Meat', + searchText: 'Food: Meat', + tooltipText: 'Food: Meat', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Food: Milk', + keyForList: 'Food: Milk', + searchText: 'Food: Milk', + tooltipText: 'Food: Milk', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Cars: Audi', + keyForList: 'Cars: Audi', + searchText: 'Cars: Audi', + tooltipText: 'Cars: Audi', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Cars: Mercedes-Benz', + keyForList: 'Cars: Mercedes-Benz', + searchText: 'Cars: Mercedes-Benz', + tooltipText: 'Cars: Mercedes-Benz', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Travel: Meals', + keyForList: 'Travel: Meals', + searchText: 'Travel: Meals', + tooltipText: 'Travel: Meals', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Travel: Meals: Breakfast', + keyForList: 'Travel: Meals: Breakfast', + searchText: 'Travel: Meals: Breakfast', + tooltipText: 'Travel: Meals: Breakfast', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Travel: Meals: Lunch', + keyForList: 'Travel: Meals: Lunch', + searchText: 'Travel: Meals: Lunch', + tooltipText: 'Travel: Meals: Lunch', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Plain', + keyForList: 'Plain', + searchText: 'Plain', + tooltipText: 'Plain', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Audi', + keyForList: 'Audi', + searchText: 'Audi', + tooltipText: 'Audi', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Health', + keyForList: 'Health', + searchText: 'Health', + tooltipText: 'Health', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'A: B: C', + keyForList: 'A: B: C', + searchText: 'A: B: C', + tooltipText: 'A: B: C', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'A: B: C: D: E', + keyForList: 'A: B: C: D: E', + searchText: 'A: B: C: D: E', + tooltipText: 'A: B: C: D: E', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + ]; + + expect(CategoryOptionsListUtils.getCategoryOptionTree(categories)).toStrictEqual(result); + expect(CategoryOptionsListUtils.getCategoryOptionTree(categories, true)).toStrictEqual(resultOneLine); + }); + + it('sortCategories', () => { + const categoriesIncorrectOrdering = { + Taxi: { + name: 'Taxi', + enabled: false, + }, + 'Test1: Subtest2': { + name: 'Test1: Subtest2', + enabled: true, + }, + 'Test: Test1: Subtest4': { + name: 'Test: Test1: Subtest4', + enabled: true, + }, + Taxes: { + name: 'Taxes', + enabled: true, + }, + Test: { + name: 'Test', + enabled: true, + pendingAction: 'delete' as PendingAction, + }, + Test1: { + name: 'Test1', + enabled: true, + }, + 'Travel: Nested-Travel': { + name: 'Travel: Nested-Travel', + enabled: true, + }, + 'Test1: Subtest1': { + name: 'Test1: Subtest1', + enabled: true, + }, + 'Test: Test1': { + name: 'Test: Test1', + enabled: true, + }, + 'Test: Test1: Subtest1': { + name: 'Test: Test1: Subtest1', + enabled: true, + }, + 'Test: Test1: Subtest3': { + name: 'Test: Test1: Subtest3', + enabled: false, + }, + 'Test: Test1: Subtest2': { + name: 'Test: Test1: Subtest2', + enabled: true, + }, + 'Test: Test2': { + name: 'Test: Test2', + enabled: true, + }, + Travel: { + name: 'Travel', + enabled: true, + }, + Utilities: { + name: 'Utilities', + enabled: true, + }, + 'Test: Test3: Subtest1': { + name: 'Test: Test3: Subtest1', + enabled: true, + }, + 'Test1: Subtest3': { + name: 'Test1: Subtest3', + enabled: true, + }, + }; + const result = [ + { + name: 'Taxes', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Taxi', + enabled: false, + pendingAction: undefined, + }, + { + name: 'Test', + enabled: true, + pendingAction: 'delete', + }, + { + name: 'Test: Test1', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test: Test1: Subtest1', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test: Test1: Subtest2', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test: Test1: Subtest3', + enabled: false, + pendingAction: undefined, + }, + { + name: 'Test: Test1: Subtest4', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test: Test2', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test: Test3: Subtest1', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test1', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test1: Subtest1', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test1: Subtest2', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test1: Subtest3', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Travel', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Travel: Nested-Travel', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Utilities', + enabled: true, + pendingAction: undefined, + }, + ]; + const categoriesIncorrectOrdering2 = { + 'Cars: BMW': { + enabled: false, + name: 'Cars: BMW', + }, + Medical: { + enabled: false, + name: 'Medical', + }, + 'Travel: Meals: Lunch': { + enabled: true, + name: 'Travel: Meals: Lunch', + }, + 'Cars: Mercedes-Benz': { + enabled: true, + name: 'Cars: Mercedes-Benz', + }, + Food: { + enabled: true, + name: 'Food', + }, + 'Food: Meat': { + enabled: true, + name: 'Food: Meat', + }, + 'Travel: Meals: Dinner': { + enabled: false, + name: 'Travel: Meals: Dinner', + }, + 'Food: Vegetables': { + enabled: false, + name: 'Food: Vegetables', + }, + Restaurant: { + enabled: true, + name: 'Restaurant', + }, + Taxi: { + enabled: false, + name: 'Taxi', + }, + 'Food: Milk': { + enabled: true, + name: 'Food: Milk', + }, + 'Travel: Meals': { + enabled: true, + name: 'Travel: Meals', + }, + 'Travel: Meals: Breakfast': { + enabled: true, + name: 'Travel: Meals: Breakfast', + }, + 'Cars: Audi': { + enabled: true, + name: 'Cars: Audi', + }, + }; + const result2 = [ + { + enabled: true, + name: 'Cars: Audi', + pendingAction: undefined, + }, + { + enabled: false, + name: 'Cars: BMW', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Cars: Mercedes-Benz', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Food', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Food: Meat', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Food: Milk', + pendingAction: undefined, + }, + { + enabled: false, + name: 'Food: Vegetables', + pendingAction: undefined, + }, + { + enabled: false, + name: 'Medical', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Restaurant', + pendingAction: undefined, + }, + { + enabled: false, + name: 'Taxi', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Travel: Meals', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Travel: Meals: Breakfast', + pendingAction: undefined, + }, + { + enabled: false, + name: 'Travel: Meals: Dinner', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Travel: Meals: Lunch', + pendingAction: undefined, + }, + ]; + const categoriesIncorrectOrdering3 = { + 'Movies: Mr. Nobody': { + enabled: true, + name: 'Movies: Mr. Nobody', + }, + Movies: { + enabled: true, + name: 'Movies', + }, + 'House, M.D.': { + enabled: true, + name: 'House, M.D.', + }, + 'Dr. House': { + enabled: true, + name: 'Dr. House', + }, + 'Many.dots.on.the.way.': { + enabled: true, + name: 'Many.dots.on.the.way.', + }, + 'More.Many.dots.on.the.way.': { + enabled: false, + name: 'More.Many.dots.on.the.way.', + }, + }; + const result3 = [ + { + enabled: true, + name: 'Dr. House', + pendingAction: undefined, + }, + { + enabled: true, + name: 'House, M.D.', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Many.dots.on.the.way.', + pendingAction: undefined, + }, + { + enabled: false, + name: 'More.Many.dots.on.the.way.', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Movies', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Movies: Mr. Nobody', + pendingAction: undefined, + }, + ]; + + expect(CategoryOptionsListUtils.sortCategories(categoriesIncorrectOrdering)).toStrictEqual(result); + expect(CategoryOptionsListUtils.sortCategories(categoriesIncorrectOrdering2)).toStrictEqual(result2); + expect(CategoryOptionsListUtils.sortCategories(categoriesIncorrectOrdering3)).toStrictEqual(result3); + }); +}); diff --git a/tests/unit/NetworkTest.ts b/tests/unit/NetworkTest.ts index e482cc3261d4..2998aa0e8a25 100644 --- a/tests/unit/NetworkTest.ts +++ b/tests/unit/NetworkTest.ts @@ -2,6 +2,7 @@ import type {Mock} from 'jest-mock'; import type {OnyxEntry} from 'react-native-onyx'; import MockedOnyx from 'react-native-onyx'; import * as App from '@libs/actions/App'; +import {resetReauthentication} from '@libs/Middleware/Reauthentication'; import CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import * as PersistedRequests from '@src/libs/actions/PersistedRequests'; @@ -12,6 +13,7 @@ import Log from '@src/libs/Log'; import * as Network from '@src/libs/Network'; import * as MainQueue from '@src/libs/Network/MainQueue'; import * as NetworkStore from '@src/libs/Network/NetworkStore'; +import * as SequentialQueue from '@src/libs/Network/SequentialQueue'; import NetworkConnection from '@src/libs/NetworkConnection'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Session as OnyxSession} from '@src/types/onyx'; @@ -35,176 +37,157 @@ const originalXHR = HttpUtils.xhr; beforeEach(() => { global.fetch = TestHelper.getGlobalFetchMock(); HttpUtils.xhr = originalXHR; + + // Reset any pending requests MainQueue.clear(); HttpUtils.cancelPendingRequests(); NetworkStore.checkRequiredData(); + NetworkStore.setIsAuthenticating(false); + resetReauthentication(); + Network.clearProcessQueueInterval(); + SequentialQueue.resetQueue(); - // Wait for any Log command to finish and Onyx to fully clear - return waitForBatchedUpdates() - .then(() => PersistedRequests.clear()) - .then(() => Onyx.clear()) - .then(waitForBatchedUpdates); + return Promise.all([SequentialQueue.waitForIdle(), waitForBatchedUpdates(), PersistedRequests.clear(), Onyx.clear()]).then(() => { + return waitForBatchedUpdates(); + }); }); afterEach(() => { NetworkStore.resetHasReadRequiredDataFromStorage(); Onyx.addDelayToConnectCallback(0); jest.clearAllMocks(); + jest.clearAllTimers(); + jest.useRealTimers(); }); describe('NetworkTests', () => { test('failing to reauthenticate should not log out user', () => { - // Given a test user login and account ID + // Use fake timers to control timing in the test + jest.useFakeTimers(); + const TEST_USER_LOGIN = 'test@testguy.com'; const TEST_USER_ACCOUNT_ID = 1; + const NEW_AUTH_TOKEN = 'qwerty12345'; - let isOffline: boolean; - + let sessionState: OnyxEntry; Onyx.connect({ - key: ONYXKEYS.NETWORK, - callback: (val) => { - isOffline = !!val?.isOffline; - }, + key: ONYXKEYS.SESSION, + callback: (val) => (sessionState = val), }); - // Given a test user login and account ID - return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN).then(() => { - expect(isOffline).toBe(false); - - // Mock fetch() so that it throws a TypeError to simulate a bad network connection - global.fetch = jest.fn().mockRejectedValue(new TypeError(CONST.ERROR.FAILED_TO_FETCH)); - - const actualXhr = HttpUtils.xhr; - - const mockedXhr = jest.fn(); - mockedXhr - .mockImplementationOnce(() => - Promise.resolve({ - jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED, - }), - ) - - // Fail the call to re-authenticate - .mockImplementationOnce(actualXhr) - - // The next call should still be using the old authToken - .mockImplementationOnce(() => - Promise.resolve({ - jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED, - }), - ) - - // Succeed the call to set a new authToken - .mockImplementationOnce(() => - Promise.resolve({ - jsonCode: CONST.JSON_CODE.SUCCESS, - authToken: 'qwerty12345', - }), - ) - - // All remaining requests should succeed - .mockImplementation(() => - Promise.resolve({ - jsonCode: CONST.JSON_CODE.SUCCESS, - }), - ); - - HttpUtils.xhr = mockedXhr; - - // This should first trigger re-authentication and then a Failed to fetch - PersonalDetails.openPublicProfilePage(TEST_USER_ACCOUNT_ID); - return waitForBatchedUpdates() - .then(() => Onyx.set(ONYXKEYS.NETWORK, {isOffline: false})) - .then(() => { - expect(isOffline).toBe(false); - - // Advance the network request queue by 1 second so that it can realize it's back online - jest.advanceTimersByTime(CONST.NETWORK.PROCESS_REQUEST_DELAY_MS); - return waitForBatchedUpdates(); - }) - .then(() => { - // Then we will eventually have 1 call to OpenPublicProfilePage and 1 calls to Authenticate - const callsToOpenPublicProfilePage = (HttpUtils.xhr as Mock).mock.calls.filter(([command]) => command === 'OpenPublicProfilePage'); - const callsToAuthenticate = (HttpUtils.xhr as Mock).mock.calls.filter(([command]) => command === 'Authenticate'); - - expect(callsToOpenPublicProfilePage.length).toBe(1); - expect(callsToAuthenticate.length).toBe(1); - }); - }); + return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN) + .then(() => { + // Mock XHR with a sequence of responses: + // 1. First call fails with NOT_AUTHENTICATED + // 2. Second call fails with network error + // 3. Third call succeeds with new auth token + const mockedXhr = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve({ + jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED, + }), + ) + .mockImplementationOnce(() => Promise.reject(new Error(CONST.ERROR.FAILED_TO_FETCH))) + .mockImplementationOnce(() => + Promise.resolve({ + jsonCode: CONST.JSON_CODE.SUCCESS, + authToken: NEW_AUTH_TOKEN, + }), + ); + + HttpUtils.xhr = mockedXhr; + + // Trigger an API call that will cause reauthentication flow + PersonalDetails.openPublicProfilePage(TEST_USER_ACCOUNT_ID); + return waitForBatchedUpdates(); + }) + .then(() => { + // Process pending retry request + jest.runAllTimers(); + return waitForBatchedUpdates(); + }) + .then(() => { + // Verify: + // 1. We attempted to authenticate twice (first failed, retry succeeded) + // 2. The session has the new auth token (user wasn't logged out) + const callsToAuthenticate = (HttpUtils.xhr as Mock).mock.calls.filter(([command]) => command === 'Authenticate'); + expect(callsToAuthenticate.length).toBe(2); + expect(sessionState?.authToken).toBe(NEW_AUTH_TOKEN); + }); }); test('failing to reauthenticate while offline should not log out user', async () => { + // 1. Setup Phase - Initialize test user and state variables const TEST_USER_LOGIN = 'test@testguy.com'; const TEST_USER_ACCOUNT_ID = 1; - let session: OnyxEntry; - Onyx.connect({ - key: ONYXKEYS.SESSION, - callback: (val) => (session = val), - }); + let sessionState: OnyxEntry; + // Set up listeners for session and network state changes Onyx.connect({ - key: ONYXKEYS.NETWORK, + key: ONYXKEYS.SESSION, + callback: (val) => (sessionState = val), }); + // Sign in test user and wait for updates await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN); await waitForBatchedUpdates(); - expect(session?.authToken).not.toBeUndefined(); + const initialAuthToken = sessionState?.authToken; + expect(initialAuthToken).toBeDefined(); - // Turn off the network - await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + // Create a promise that we can resolve later to control the timing + let resolveAuthRequest: (value: unknown) => void = () => {}; + const pendingAuthRequest = new Promise((resolve) => { + resolveAuthRequest = resolve; + }); - const mockedXhr = jest.fn(); - mockedXhr - // Call ReconnectApp with an expired token + // 2. Mock Setup Phase - Configure XHR mocks for the test sequence + const mockedXhr = jest + .fn() + // First call: Return NOT_AUTHENTICATED to trigger reauthentication .mockImplementationOnce(() => Promise.resolve({ jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED, }), ) - // Call Authenticate - .mockImplementationOnce(() => - Promise.resolve({ - jsonCode: CONST.JSON_CODE.SUCCESS, - authToken: 'newAuthToken', - }), - ) - // Call ReconnectApp again, it should connect with a new token - .mockImplementationOnce(() => - Promise.resolve({ - jsonCode: CONST.JSON_CODE.SUCCESS, - }), - ); + // Second call: Return a pending promise that we'll resolve later + .mockImplementationOnce(() => pendingAuthRequest); HttpUtils.xhr = mockedXhr; - // Initiate the requests + // 3. Test Execution Phase - Start with online network + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + + // Trigger reconnect which will fail due to expired token App.confirmReadyToOpenApp(); App.reconnectApp(); await waitForBatchedUpdates(); - // Turn the network back online - await Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + // 4. First API Call Verification - Check ReconnectApp + const firstCall = mockedXhr.mock.calls.at(0) as [string, Record]; + expect(firstCall[0]).toBe('ReconnectApp'); - // Filter requests results by request name - const reconnectResults = (HttpUtils.xhr as Mock).mock.results.filter((_, index) => (HttpUtils.xhr as Mock)?.mock?.calls?.at(index)?.[0] === 'ReconnectApp'); - const authenticateResults = (HttpUtils.xhr as Mock).mock.results.filter((_, index) => (HttpUtils.xhr as Mock)?.mock?.calls?.at(index)?.[0] === 'Authenticate'); - - // Get the response code of Authenticate call - const authenticateResponse = await (authenticateResults?.at(0)?.value as Promise<{jsonCode: string}>); + // 5. Authentication Start - Verify authenticate was triggered + await waitForBatchedUpdates(); + const secondCall = mockedXhr.mock.calls.at(1) as [string, Record]; + expect(secondCall[0]).toBe('Authenticate'); - // Get the response code of the second Reconnect call - const reconnectResponse = await (reconnectResults?.at(1)?.value as Promise<{jsonCode: string}>); + // 6. Network State Change - Set offline and back online while authenticate is pending + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); - // Authenticate request should return 200 - expect(authenticateResponse.jsonCode).toBe(CONST.JSON_CODE.SUCCESS); + // 7.Trigger another reconnect due to network change + App.confirmReadyToOpenApp(); + App.reconnectApp(); - // The second ReconnectApp should return 200 - expect(reconnectResponse.jsonCode).toBe(CONST.JSON_CODE.SUCCESS); + // 8. Now fail the pending authentication request + resolveAuthRequest(Promise.reject(new Error('Network request failed'))); + await waitForBatchedUpdates(); // Now we wait for all updates after the auth request fails - // check if the user is still logged in - expect(session?.authToken).not.toBeUndefined(); + // 9. Verify the session remained intact and wasn't cleared + expect(sessionState?.authToken).toBe(initialAuthToken); }); test('consecutive API calls eventually succeed when authToken is expired', () => { diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index d71b5522e11b..0e66993bc2cf 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -6,8 +6,7 @@ import CONST from '@src/CONST'; import * as OptionsListUtils from '@src/libs/OptionsListUtils'; import * as ReportUtils from '@src/libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, Policy, PolicyCategories, Report} from '@src/types/onyx'; -import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {PersonalDetails, Policy, Report} from '@src/types/onyx'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; type PersonalDetailsList = Record; @@ -638,1253 +637,6 @@ describe('OptionsListUtils', () => { expect(results.personalDetails.at(3)?.text).toBe('Invisible Woman'); }); - it('getFilteredOptions() for categories', () => { - const search = 'Food'; - const emptySearch = ''; - const wrongSearch = 'bla bla'; - const recentlyUsedCategories = ['Taxi', 'Restaurant']; - const selectedOptions: Array> = [ - { - name: 'Medical', - enabled: true, - }, - ]; - const smallCategoriesList: PolicyCategories = { - Taxi: { - enabled: false, - name: 'Taxi', - unencodedName: 'Taxi', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - pendingAction: undefined, - }, - Restaurant: { - enabled: true, - name: 'Restaurant', - unencodedName: 'Restaurant', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - pendingAction: 'delete', - }, - Food: { - enabled: true, - name: 'Food', - unencodedName: 'Food', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - pendingAction: undefined, - }, - 'Food: Meat': { - enabled: true, - name: 'Food: Meat', - unencodedName: 'Food: Meat', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - pendingAction: undefined, - }, - }; - const smallResultList: OptionsListUtils.CategoryTreeSection[] = [ - { - title: '', - shouldShow: false, - data: [ - { - text: 'Food', - keyForList: 'Food', - searchText: 'Food', - tooltipText: 'Food', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Meat', - keyForList: 'Food: Meat', - searchText: 'Food: Meat', - tooltipText: 'Meat', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Restaurant', - keyForList: 'Restaurant', - searchText: 'Restaurant', - tooltipText: 'Restaurant', - isDisabled: true, - isSelected: false, - pendingAction: 'delete', - }, - ], - indexOffset: 3, - }, - ]; - const smallSearchResultList: OptionsListUtils.CategoryTreeSection[] = [ - { - title: '', - shouldShow: true, - indexOffset: 2, - data: [ - { - text: 'Food', - keyForList: 'Food', - searchText: 'Food', - tooltipText: 'Food', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Food: Meat', - keyForList: 'Food: Meat', - searchText: 'Food: Meat', - tooltipText: 'Food: Meat', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - ], - }, - ]; - const smallWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [ - { - title: '', - shouldShow: true, - indexOffset: 0, - data: [], - }, - ]; - const largeCategoriesList: PolicyCategories = { - Taxi: { - enabled: false, - name: 'Taxi', - unencodedName: 'Taxi', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - }, - Restaurant: { - enabled: true, - name: 'Restaurant', - unencodedName: 'Restaurant', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - }, - Food: { - enabled: true, - name: 'Food', - unencodedName: 'Food', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - }, - 'Food: Meat': { - enabled: true, - name: 'Food: Meat', - unencodedName: 'Food: Meat', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - }, - 'Food: Milk': { - enabled: true, - name: 'Food: Milk', - unencodedName: 'Food: Milk', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - }, - 'Food: Vegetables': { - enabled: false, - name: 'Food: Vegetables', - unencodedName: 'Food: Vegetables', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - }, - 'Cars: Audi': { - enabled: true, - name: 'Cars: Audi', - unencodedName: 'Cars: Audi', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - }, - 'Cars: BMW': { - enabled: false, - name: 'Cars: BMW', - unencodedName: 'Cars: BMW', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - }, - 'Cars: Mercedes-Benz': { - enabled: true, - name: 'Cars: Mercedes-Benz', - unencodedName: 'Cars: Mercedes-Benz', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - }, - Medical: { - enabled: false, - name: 'Medical', - unencodedName: 'Medical', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - }, - 'Travel: Meals': { - enabled: true, - name: 'Travel: Meals', - unencodedName: 'Travel: Meals', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - }, - 'Travel: Meals: Breakfast': { - enabled: true, - name: 'Travel: Meals: Breakfast', - unencodedName: 'Travel: Meals: Breakfast', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - }, - 'Travel: Meals: Dinner': { - enabled: false, - name: 'Travel: Meals: Dinner', - unencodedName: 'Travel: Meals: Dinner', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - }, - 'Travel: Meals: Lunch': { - enabled: true, - name: 'Travel: Meals: Lunch', - unencodedName: 'Travel: Meals: Lunch', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - }, - }; - const largeResultList: OptionsListUtils.CategoryTreeSection[] = [ - { - title: '', - shouldShow: false, - indexOffset: 1, - data: [ - { - text: 'Medical', - keyForList: 'Medical', - searchText: 'Medical', - tooltipText: 'Medical', - isDisabled: true, - isSelected: true, - pendingAction: undefined, - }, - ], - }, - { - title: 'Recent', - shouldShow: true, - indexOffset: 1, - data: [ - { - text: 'Restaurant', - keyForList: 'Restaurant', - searchText: 'Restaurant', - tooltipText: 'Restaurant', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - ], - }, - { - title: 'All', - shouldShow: true, - indexOffset: 11, - data: [ - { - text: 'Cars', - keyForList: 'Cars', - searchText: 'Cars', - tooltipText: 'Cars', - isDisabled: true, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Audi', - keyForList: 'Cars: Audi', - searchText: 'Cars: Audi', - tooltipText: 'Audi', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Mercedes-Benz', - keyForList: 'Cars: Mercedes-Benz', - searchText: 'Cars: Mercedes-Benz', - tooltipText: 'Mercedes-Benz', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Food', - keyForList: 'Food', - searchText: 'Food', - tooltipText: 'Food', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Meat', - keyForList: 'Food: Meat', - searchText: 'Food: Meat', - tooltipText: 'Meat', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Milk', - keyForList: 'Food: Milk', - searchText: 'Food: Milk', - tooltipText: 'Milk', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Restaurant', - keyForList: 'Restaurant', - searchText: 'Restaurant', - tooltipText: 'Restaurant', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Travel', - keyForList: 'Travel', - searchText: 'Travel', - tooltipText: 'Travel', - isDisabled: true, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Meals', - keyForList: 'Travel: Meals', - searchText: 'Travel: Meals', - tooltipText: 'Meals', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Breakfast', - keyForList: 'Travel: Meals: Breakfast', - searchText: 'Travel: Meals: Breakfast', - tooltipText: 'Breakfast', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Lunch', - keyForList: 'Travel: Meals: Lunch', - searchText: 'Travel: Meals: Lunch', - tooltipText: 'Lunch', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - ], - }, - ]; - const largeSearchResultList: OptionsListUtils.CategoryTreeSection[] = [ - { - title: '', - shouldShow: true, - indexOffset: 3, - data: [ - { - text: 'Food', - keyForList: 'Food', - searchText: 'Food', - tooltipText: 'Food', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Food: Meat', - keyForList: 'Food: Meat', - searchText: 'Food: Meat', - tooltipText: 'Food: Meat', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Food: Milk', - keyForList: 'Food: Milk', - searchText: 'Food: Milk', - tooltipText: 'Food: Milk', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - ], - }, - ]; - const largeWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [ - { - title: '', - shouldShow: true, - indexOffset: 0, - data: [], - }, - ]; - const emptyCategoriesList = {}; - const emptySelectedResultList: OptionsListUtils.CategoryTreeSection[] = [ - { - title: '', - shouldShow: false, - indexOffset: 1, - data: [ - { - text: 'Medical', - keyForList: 'Medical', - searchText: 'Medical', - tooltipText: 'Medical', - isDisabled: true, - isSelected: true, - pendingAction: undefined, - }, - ], - }, - ]; - - const smallResult = OptionsListUtils.getFilteredOptions({ - reports: OPTIONS.reports, - personalDetails: OPTIONS.personalDetails, - searchValue: emptySearch, - includeP2P: false, - includeCategories: true, - categories: smallCategoriesList, - }); - expect(smallResult.categoryOptions).toStrictEqual(smallResultList); - - const smallSearchResult = OptionsListUtils.getFilteredOptions({searchValue: search, includeP2P: false, includeCategories: true, categories: smallCategoriesList}); - expect(smallSearchResult.categoryOptions).toStrictEqual(smallSearchResultList); - - const smallWrongSearchResult = OptionsListUtils.getFilteredOptions({searchValue: wrongSearch, includeP2P: false, includeCategories: true, categories: smallCategoriesList}); - expect(smallWrongSearchResult.categoryOptions).toStrictEqual(smallWrongSearchResultList); - - const largeResult = OptionsListUtils.getFilteredOptions({ - searchValue: emptySearch, - selectedOptions, - includeP2P: false, - includeCategories: true, - categories: largeCategoriesList, - recentlyUsedCategories, - }); - expect(largeResult.categoryOptions).toStrictEqual(largeResultList); - - const largeSearchResult = OptionsListUtils.getFilteredOptions({ - searchValue: search, - selectedOptions, - - includeP2P: false, - includeCategories: true, - categories: largeCategoriesList, - recentlyUsedCategories, - }); - expect(largeSearchResult.categoryOptions).toStrictEqual(largeSearchResultList); - - const largeWrongSearchResult = OptionsListUtils.getFilteredOptions({ - searchValue: wrongSearch, - selectedOptions, - includeP2P: false, - includeCategories: true, - categories: largeCategoriesList, - recentlyUsedCategories, - }); - expect(largeWrongSearchResult.categoryOptions).toStrictEqual(largeWrongSearchResultList); - - const emptyResult = OptionsListUtils.getFilteredOptions({searchValue: search, selectedOptions, includeP2P: false, includeCategories: true, categories: emptyCategoriesList}); - expect(emptyResult.categoryOptions).toStrictEqual(emptySelectedResultList); - }); - - it('getCategoryOptionTree()', () => { - const categories = { - Meals: { - enabled: true, - name: 'Meals', - }, - Restaurant: { - enabled: true, - name: 'Restaurant', - }, - Food: { - enabled: true, - name: 'Food', - }, - 'Food: Meat': { - enabled: true, - name: 'Food: Meat', - }, - 'Food: Milk': { - enabled: true, - name: 'Food: Milk', - }, - 'Cars: Audi': { - enabled: true, - name: 'Cars: Audi', - }, - 'Cars: Mercedes-Benz': { - enabled: true, - name: 'Cars: Mercedes-Benz', - }, - 'Travel: Meals': { - enabled: true, - name: 'Travel: Meals', - }, - 'Travel: Meals: Breakfast': { - enabled: true, - name: 'Travel: Meals: Breakfast', - }, - 'Travel: Meals: Lunch': { - enabled: true, - name: 'Travel: Meals: Lunch', - }, - Plain: { - enabled: true, - name: 'Plain', - }, - Audi: { - enabled: true, - name: 'Audi', - }, - Health: { - enabled: true, - name: 'Health', - }, - 'A: B: C': { - enabled: true, - name: 'A: B: C', - }, - 'A: B: C: D: E': { - enabled: true, - name: 'A: B: C: D: E', - }, - }; - const result = [ - { - text: 'Meals', - keyForList: 'Meals', - searchText: 'Meals', - tooltipText: 'Meals', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Restaurant', - keyForList: 'Restaurant', - searchText: 'Restaurant', - tooltipText: 'Restaurant', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Food', - keyForList: 'Food', - searchText: 'Food', - tooltipText: 'Food', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Meat', - keyForList: 'Food: Meat', - searchText: 'Food: Meat', - tooltipText: 'Meat', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Milk', - keyForList: 'Food: Milk', - searchText: 'Food: Milk', - tooltipText: 'Milk', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Cars', - keyForList: 'Cars', - searchText: 'Cars', - tooltipText: 'Cars', - isDisabled: true, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Audi', - keyForList: 'Cars: Audi', - searchText: 'Cars: Audi', - tooltipText: 'Audi', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Mercedes-Benz', - keyForList: 'Cars: Mercedes-Benz', - searchText: 'Cars: Mercedes-Benz', - tooltipText: 'Mercedes-Benz', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Travel', - keyForList: 'Travel', - searchText: 'Travel', - tooltipText: 'Travel', - isDisabled: true, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Meals', - keyForList: 'Travel: Meals', - searchText: 'Travel: Meals', - tooltipText: 'Meals', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Breakfast', - keyForList: 'Travel: Meals: Breakfast', - searchText: 'Travel: Meals: Breakfast', - tooltipText: 'Breakfast', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Lunch', - keyForList: 'Travel: Meals: Lunch', - searchText: 'Travel: Meals: Lunch', - tooltipText: 'Lunch', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Plain', - keyForList: 'Plain', - searchText: 'Plain', - tooltipText: 'Plain', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Audi', - keyForList: 'Audi', - searchText: 'Audi', - tooltipText: 'Audi', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Health', - keyForList: 'Health', - searchText: 'Health', - tooltipText: 'Health', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'A', - keyForList: 'A', - searchText: 'A', - tooltipText: 'A', - isDisabled: true, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' B', - keyForList: 'A: B', - searchText: 'A: B', - tooltipText: 'B', - isDisabled: true, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' C', - keyForList: 'A: B: C', - searchText: 'A: B: C', - tooltipText: 'C', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' D', - keyForList: 'A: B: C: D', - searchText: 'A: B: C: D', - tooltipText: 'D', - isDisabled: true, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' E', - keyForList: 'A: B: C: D: E', - searchText: 'A: B: C: D: E', - tooltipText: 'E', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - ]; - const resultOneLine = [ - { - text: 'Meals', - keyForList: 'Meals', - searchText: 'Meals', - tooltipText: 'Meals', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Restaurant', - keyForList: 'Restaurant', - searchText: 'Restaurant', - tooltipText: 'Restaurant', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Food', - keyForList: 'Food', - searchText: 'Food', - tooltipText: 'Food', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Food: Meat', - keyForList: 'Food: Meat', - searchText: 'Food: Meat', - tooltipText: 'Food: Meat', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Food: Milk', - keyForList: 'Food: Milk', - searchText: 'Food: Milk', - tooltipText: 'Food: Milk', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Cars: Audi', - keyForList: 'Cars: Audi', - searchText: 'Cars: Audi', - tooltipText: 'Cars: Audi', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Cars: Mercedes-Benz', - keyForList: 'Cars: Mercedes-Benz', - searchText: 'Cars: Mercedes-Benz', - tooltipText: 'Cars: Mercedes-Benz', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Travel: Meals', - keyForList: 'Travel: Meals', - searchText: 'Travel: Meals', - tooltipText: 'Travel: Meals', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Travel: Meals: Breakfast', - keyForList: 'Travel: Meals: Breakfast', - searchText: 'Travel: Meals: Breakfast', - tooltipText: 'Travel: Meals: Breakfast', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Travel: Meals: Lunch', - keyForList: 'Travel: Meals: Lunch', - searchText: 'Travel: Meals: Lunch', - tooltipText: 'Travel: Meals: Lunch', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Plain', - keyForList: 'Plain', - searchText: 'Plain', - tooltipText: 'Plain', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Audi', - keyForList: 'Audi', - searchText: 'Audi', - tooltipText: 'Audi', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Health', - keyForList: 'Health', - searchText: 'Health', - tooltipText: 'Health', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'A: B: C', - keyForList: 'A: B: C', - searchText: 'A: B: C', - tooltipText: 'A: B: C', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'A: B: C: D: E', - keyForList: 'A: B: C: D: E', - searchText: 'A: B: C: D: E', - tooltipText: 'A: B: C: D: E', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - ]; - - expect(OptionsListUtils.getCategoryOptionTree(categories)).toStrictEqual(result); - expect(OptionsListUtils.getCategoryOptionTree(categories, true)).toStrictEqual(resultOneLine); - }); - - it('sortCategories', () => { - const categoriesIncorrectOrdering = { - Taxi: { - name: 'Taxi', - enabled: false, - }, - 'Test1: Subtest2': { - name: 'Test1: Subtest2', - enabled: true, - }, - 'Test: Test1: Subtest4': { - name: 'Test: Test1: Subtest4', - enabled: true, - }, - Taxes: { - name: 'Taxes', - enabled: true, - }, - Test: { - name: 'Test', - enabled: true, - pendingAction: 'delete' as PendingAction, - }, - Test1: { - name: 'Test1', - enabled: true, - }, - 'Travel: Nested-Travel': { - name: 'Travel: Nested-Travel', - enabled: true, - }, - 'Test1: Subtest1': { - name: 'Test1: Subtest1', - enabled: true, - }, - 'Test: Test1': { - name: 'Test: Test1', - enabled: true, - }, - 'Test: Test1: Subtest1': { - name: 'Test: Test1: Subtest1', - enabled: true, - }, - 'Test: Test1: Subtest3': { - name: 'Test: Test1: Subtest3', - enabled: false, - }, - 'Test: Test1: Subtest2': { - name: 'Test: Test1: Subtest2', - enabled: true, - }, - 'Test: Test2': { - name: 'Test: Test2', - enabled: true, - }, - Travel: { - name: 'Travel', - enabled: true, - }, - Utilities: { - name: 'Utilities', - enabled: true, - }, - 'Test: Test3: Subtest1': { - name: 'Test: Test3: Subtest1', - enabled: true, - }, - 'Test1: Subtest3': { - name: 'Test1: Subtest3', - enabled: true, - }, - }; - const result = [ - { - name: 'Taxes', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Taxi', - enabled: false, - pendingAction: undefined, - }, - { - name: 'Test', - enabled: true, - pendingAction: 'delete', - }, - { - name: 'Test: Test1', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test: Test1: Subtest1', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test: Test1: Subtest2', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test: Test1: Subtest3', - enabled: false, - pendingAction: undefined, - }, - { - name: 'Test: Test1: Subtest4', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test: Test2', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test: Test3: Subtest1', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test1', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test1: Subtest1', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test1: Subtest2', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test1: Subtest3', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Travel', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Travel: Nested-Travel', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Utilities', - enabled: true, - pendingAction: undefined, - }, - ]; - const categoriesIncorrectOrdering2 = { - 'Cars: BMW': { - enabled: false, - name: 'Cars: BMW', - }, - Medical: { - enabled: false, - name: 'Medical', - }, - 'Travel: Meals: Lunch': { - enabled: true, - name: 'Travel: Meals: Lunch', - }, - 'Cars: Mercedes-Benz': { - enabled: true, - name: 'Cars: Mercedes-Benz', - }, - Food: { - enabled: true, - name: 'Food', - }, - 'Food: Meat': { - enabled: true, - name: 'Food: Meat', - }, - 'Travel: Meals: Dinner': { - enabled: false, - name: 'Travel: Meals: Dinner', - }, - 'Food: Vegetables': { - enabled: false, - name: 'Food: Vegetables', - }, - Restaurant: { - enabled: true, - name: 'Restaurant', - }, - Taxi: { - enabled: false, - name: 'Taxi', - }, - 'Food: Milk': { - enabled: true, - name: 'Food: Milk', - }, - 'Travel: Meals': { - enabled: true, - name: 'Travel: Meals', - }, - 'Travel: Meals: Breakfast': { - enabled: true, - name: 'Travel: Meals: Breakfast', - }, - 'Cars: Audi': { - enabled: true, - name: 'Cars: Audi', - }, - }; - const result2 = [ - { - enabled: true, - name: 'Cars: Audi', - pendingAction: undefined, - }, - { - enabled: false, - name: 'Cars: BMW', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Cars: Mercedes-Benz', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Food', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Food: Meat', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Food: Milk', - pendingAction: undefined, - }, - { - enabled: false, - name: 'Food: Vegetables', - pendingAction: undefined, - }, - { - enabled: false, - name: 'Medical', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Restaurant', - pendingAction: undefined, - }, - { - enabled: false, - name: 'Taxi', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Travel: Meals', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Travel: Meals: Breakfast', - pendingAction: undefined, - }, - { - enabled: false, - name: 'Travel: Meals: Dinner', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Travel: Meals: Lunch', - pendingAction: undefined, - }, - ]; - const categoriesIncorrectOrdering3 = { - 'Movies: Mr. Nobody': { - enabled: true, - name: 'Movies: Mr. Nobody', - }, - Movies: { - enabled: true, - name: 'Movies', - }, - 'House, M.D.': { - enabled: true, - name: 'House, M.D.', - }, - 'Dr. House': { - enabled: true, - name: 'Dr. House', - }, - 'Many.dots.on.the.way.': { - enabled: true, - name: 'Many.dots.on.the.way.', - }, - 'More.Many.dots.on.the.way.': { - enabled: false, - name: 'More.Many.dots.on.the.way.', - }, - }; - const result3 = [ - { - enabled: true, - name: 'Dr. House', - pendingAction: undefined, - }, - { - enabled: true, - name: 'House, M.D.', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Many.dots.on.the.way.', - pendingAction: undefined, - }, - { - enabled: false, - name: 'More.Many.dots.on.the.way.', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Movies', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Movies: Mr. Nobody', - pendingAction: undefined, - }, - ]; - - expect(OptionsListUtils.sortCategories(categoriesIncorrectOrdering)).toStrictEqual(result); - expect(OptionsListUtils.sortCategories(categoriesIncorrectOrdering2)).toStrictEqual(result2); - expect(OptionsListUtils.sortCategories(categoriesIncorrectOrdering3)).toStrictEqual(result3); - }); - it('formatMemberForList()', () => { const formattedMembers = Object.values(PERSONAL_DETAILS).map((personalDetail) => OptionsListUtils.formatMemberForList(personalDetail)); diff --git a/tests/unit/TagsOptionsListUtilsTest.ts b/tests/unit/TagsOptionsListUtilsTest.ts index f3051c63be6a..57ebebc218fd 100644 --- a/tests/unit/TagsOptionsListUtilsTest.ts +++ b/tests/unit/TagsOptionsListUtilsTest.ts @@ -39,7 +39,7 @@ describe('TagsOptionsListUtils', () => { pendingAction: 'delete', }, }; - const smallResultList: OptionsListUtils.CategorySection[] = [ + const smallResultList: OptionsListUtils.Section[] = [ { title: '', shouldShow: false, @@ -75,7 +75,7 @@ describe('TagsOptionsListUtils', () => { ], }, ]; - const smallSearchResultList: OptionsListUtils.CategorySection[] = [ + const smallSearchResultList: OptionsListUtils.Section[] = [ { title: '', shouldShow: true, @@ -92,7 +92,7 @@ describe('TagsOptionsListUtils', () => { ], }, ]; - const smallWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [ + const smallWrongSearchResultList: OptionsListUtils.Section[] = [ { title: '', shouldShow: true, @@ -157,7 +157,7 @@ describe('TagsOptionsListUtils', () => { accountID: undefined, }, }; - const largeResultList: OptionsListUtils.CategorySection[] = [ + const largeResultList: OptionsListUtils.Section[] = [ { title: '', shouldShow: true, @@ -259,7 +259,7 @@ describe('TagsOptionsListUtils', () => { ], }, ]; - const largeSearchResultList: OptionsListUtils.CategorySection[] = [ + const largeSearchResultList: OptionsListUtils.Section[] = [ { title: '', shouldShow: true, @@ -285,7 +285,7 @@ describe('TagsOptionsListUtils', () => { ], }, ]; - const largeWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [ + const largeWrongSearchResultList: OptionsListUtils.Section[] = [ { title: '', shouldShow: true, diff --git a/tests/unit/TaxOptionsListUtilsTest.ts b/tests/unit/TaxOptionsListUtilsTest.ts index 255cedd7c7d5..f0e1eac4826a 100644 --- a/tests/unit/TaxOptionsListUtilsTest.ts +++ b/tests/unit/TaxOptionsListUtilsTest.ts @@ -1,4 +1,4 @@ -import type {CategorySection} from '@libs/OptionsListUtils'; +import type {Section} from '@libs/OptionsListUtils'; import * as TaxOptionsListUtils from '@libs/TaxOptionsListUtils'; import type {Policy, TaxRatesWithDefault, Transaction} from '@src/types/onyx'; @@ -45,7 +45,7 @@ describe('TaxOptionsListUtils', () => { taxCode: 'CODE1', } as Transaction; - const resultList: CategorySection[] = [ + const resultList: Section[] = [ { data: [ { @@ -84,7 +84,7 @@ describe('TaxOptionsListUtils', () => { }, ]; - const searchResultList: CategorySection[] = [ + const searchResultList: Section[] = [ { data: [ { @@ -103,7 +103,7 @@ describe('TaxOptionsListUtils', () => { }, ]; - const wrongSearchResultList: CategorySection[] = [ + const wrongSearchResultList: Section[] = [ { data: [], shouldShow: true, diff --git a/web/index.html b/web/index.html index 12d2c6c67782..1dc0f7836fa4 100644 --- a/web/index.html +++ b/web/index.html @@ -133,7 +133,7 @@ <% if (htmlWebpackPlugin.options.useThirdPartyScripts) { %> - + <% } %> diff --git a/web/thirdPartyScripts.js b/web/thirdPartyScripts.js index 15e77dbd490e..c45defd89e69 100644 --- a/web/thirdPartyScripts.js +++ b/web/thirdPartyScripts.js @@ -153,3 +153,5 @@ window['_fs_namespace'] = 'FS'; }), (g._v = '2.0.0')); })(window, document, window._fs_namespace, 'script', window._fs_script); + +console.log('thirdPartyScripts.js loaded');