diff --git a/android/app/build.gradle b/android/app/build.gradle index 9d6aed6e96c9..0512e06a90a4 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 1009006100 - versionName "9.0.61-0" + versionCode 1009006200 + versionName "9.0.62-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/assets/images/companyCards/large/card-amex-large.svg b/assets/images/companyCards/large/card-amex-large.svg new file mode 100644 index 000000000000..06f0f57e16d2 --- /dev/null +++ b/assets/images/companyCards/large/card-amex-large.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/companyCards/large/card-bofa-large.svg b/assets/images/companyCards/large/card-bofa-large.svg new file mode 100644 index 000000000000..a842bc93d80b --- /dev/null +++ b/assets/images/companyCards/large/card-bofa-large.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/companyCards/large/card-brex-large.svg b/assets/images/companyCards/large/card-brex-large.svg new file mode 100644 index 000000000000..e1a48c3dbe39 --- /dev/null +++ b/assets/images/companyCards/large/card-brex-large.svg @@ -0,0 +1,23 @@ + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/companyCards/large/card-capital_one-large.svg b/assets/images/companyCards/large/card-capital_one-large.svg new file mode 100644 index 000000000000..b71e209a4c11 --- /dev/null +++ b/assets/images/companyCards/large/card-capital_one-large.svg @@ -0,0 +1,23 @@ + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/companyCards/large/card-chase-large.svg b/assets/images/companyCards/large/card-chase-large.svg new file mode 100644 index 000000000000..2b0904ae225d --- /dev/null +++ b/assets/images/companyCards/large/card-chase-large.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/companyCards/large/card-citi-large.svg b/assets/images/companyCards/large/card-citi-large.svg new file mode 100644 index 000000000000..14e3ecd36850 --- /dev/null +++ b/assets/images/companyCards/large/card-citi-large.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/companyCards/large/card-expensify-large.svg b/assets/images/companyCards/large/card-expensify-large.svg new file mode 100644 index 000000000000..2cef4a59ca20 --- /dev/null +++ b/assets/images/companyCards/large/card-expensify-large.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/companyCards/large/card-generic-large.svg b/assets/images/companyCards/large/card-generic-large.svg new file mode 100644 index 000000000000..542d34fada88 --- /dev/null +++ b/assets/images/companyCards/large/card-generic-large.svg @@ -0,0 +1,26 @@ + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/companyCards/large/card-mastercard-large.svg b/assets/images/companyCards/large/card-mastercard-large.svg new file mode 100644 index 000000000000..efc27960ef73 --- /dev/null +++ b/assets/images/companyCards/large/card-mastercard-large.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/companyCards/large/card-stripe-large.svg b/assets/images/companyCards/large/card-stripe-large.svg new file mode 100644 index 000000000000..cd084457f5b7 --- /dev/null +++ b/assets/images/companyCards/large/card-stripe-large.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/companyCards/large/card-visa-large.svg b/assets/images/companyCards/large/card-visa-large.svg new file mode 100644 index 000000000000..0f000c5652df --- /dev/null +++ b/assets/images/companyCards/large/card-visa-large.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/companyCards/large/card-wellsfargo-large.svg b/assets/images/companyCards/large/card-wellsfargo-large.svg new file mode 100644 index 000000000000..ef9eb84a890d --- /dev/null +++ b/assets/images/companyCards/large/card-wellsfargo-large.svg @@ -0,0 +1,23 @@ + + + + + + + + + + \ No newline at end of file diff --git a/contributingGuides/BUGZERO_CHECKLIST.md b/contributingGuides/BUGZERO_CHECKLIST.md index 00075620641c..96fb1c29432e 100644 --- a/contributingGuides/BUGZERO_CHECKLIST.md +++ b/contributingGuides/BUGZERO_CHECKLIST.md @@ -15,7 +15,8 @@ Source of bug: Where bug was reported: - [ ] 2a. Reported on production - [ ] 2b. Reported on staging (deploy blocker) - - [ ] 2c. Reported on a PR + - [ ] 2c. Reported on both staging and production + - [ ] 2d. Reported on a PR - [ ] 2z. Other: Who reported the bug: @@ -39,7 +40,7 @@ Who reported the bug:
Regression Test Proposal Template - + - [ ] **[BugZero Assignee]** Create a GH issue for creating/updating the regression test once above steps have been agreed upon. diff --git a/docs/articles/expensify-classic/expenses/Navigate-the-Expenses-Page.md b/docs/articles/expensify-classic/expenses/Navigate-the-Expenses-Page.md new file mode 100644 index 000000000000..410c598b2ca5 --- /dev/null +++ b/docs/articles/expensify-classic/expenses/Navigate-the-Expenses-Page.md @@ -0,0 +1,64 @@ +--- +title: Navigate the Expenses Page +description: How to use the Expenses page to filter, report, code, and export expenses +--- + +The Expenses page allows you to see all of your personal expenses. If you are an admin, you can also view all expenses submitted by people in your Workspace. You can use this page to filter, report, code, and export expenses. + +## Filter Expenses + +Expenses can be filtered in several ways to give you spending visibility, help you find expenses to submit, and customize your .csv export. + +1. Click the **Expenses** tab. +2. Adjust any of the following filters at the top of the page to match your specific needs: + - **Date Range:** Find expenses within a specific time frame. + - **Merchant Name:** Search for expenses from a particular merchant. Partial search terms work as well. + - **Workspace:** Locate specific Group/Individual Workspace expenses. + - **Categories:** Group expenses by category or identify those without a category. + - **Tags:** Filter expenses with specific tags. + - **Submitters:** Filter expenses by submitter (employee or vendor). + - **Personal Expenses:** Find all expenses yet to be included in a report. A Workspace admin can see these expenses once they are on a Processing, Approved, or Reimbursed report. + - **Open:** Display expenses on reports that have not yet been submitted. + - **Processing, Approved, Reimbursed:** See expenses on reports that are in the processing, approved, or reimbursed stages. + - **Closed:** View expenses on closed reports (not submitted for approval). + +*Note: You might notice that not all expense filters are always visible. They adapt based on the data you're currently filtering and persist from the last time you logged in. For instance, you won't see the Deleted filter if there are no **Deleted** expenses to filter out. Additionally, if you are not seeing what you expected, you may have too many filters applied. Click **Reset** at the top to clear your filters.* + +# Add an expense to a report + +The submitter (and their copilot) can add expenses to a report from the Expenses page. *Note: When expenses aren’t on a report, they are **personal expenses**. You’ll want to make sure you haven’t filtered out **personal expenses**, or you won’t be able to see them.* + +1. Find and select the expense(s) you want to add to the report by selecting the checkbox to the left. Or you can click **Select All**. +2. Click **Add to Report** in the upper right corner. Then choose an existing report or create a new one. + +# Code expenses + +To code expenses from the Expenses page, + +1. Look for the **Tag**, **Category**, and **Description** columns on the **Expenses** page. +2. Click the relevant field for a specific expense and add or update the **Category**, **Tag**, or **Description**. + +*Note: You can also open up individual expenses by clicking on them to see a detailed look.* + +# Export expenses to a CSV file + +To export multiple expenses, + +1. Select the expenses you want to export by selecting the checkbox to the left of each expense. +2. Click **Export To** in the upper right corner of the page and choose the default CSV format or create your own custom CSV template. + +{% include faq-begin.md %} + +**As a Workspace admin, what submitter expenses can you see?** + +A Workspace admin can see Processing, Approved, and Reimbursed expenses as long as they were submitted on the workspace that you are an admin. + +If employees submit expense reports on a Workspace where you are not an admin, you will not have visibility into those expenses. Additionally, if an expense is left unreported, a Workspace admin will not be able to see that expense until it’s been added to a report. + +A Workspace admin can edit the tags and categories on an expense, but if they want to edit the amount, date, or merchant name, the expense will need to be in a Processing state or rejected back to the submitter for changes. For more information about company card expense reconciliation, check out [this article](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Reconciliation). + +**Can I edit multiple expenses at once?** + +Yes! Select the expenses you want to edit and click **Edit Multiple**. + +{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/expenses/The-Expenses-Page.md b/docs/articles/expensify-classic/expenses/The-Expenses-Page.md deleted file mode 100644 index 57a7f7de298c..000000000000 --- a/docs/articles/expensify-classic/expenses/The-Expenses-Page.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -title: The Expenses Page -description: Details on Expenses Page filters ---- -# Overview - -The Expenses page allows you to see all of your personal expenses. If you are an admin, you can view all submitter’s expenses on the Expensify page. The Expenses page can be filtered in several ways to give you spending visibility, find expenses to submit and export to a spreadsheet (CSV). - -## Expense filters -Here are the available filters you can use on the Expenses Page: - -- **Date Range:** Find expenses within a specific time frame. -- **Merchant Name:** Search for expenses from a particular merchant. (Partial search terms also work if you need clarification on the exact name match.) -- **Workspace:** Locate specific Group/Individual Workspace expenses. -- **Categories:** Group expenses by category or identify those without a category. -- **Tags:** Filter expenses with specific tags. -- **Submitters:** Narrow expenses by submitter (employee or vendor). -- **Personal Expenses:** Find all expenses yet to be included in a report. A Workspace admin can see these expenses once they are on a Processing, Approved, or Reimbursed report. -- **Open:** Display expenses on reports that still need to be submitted (not submitted). -- **Processing, Approved, Reimbursed:** See expenses on reports at various stages – processing, approved, or reimbursed. -- **Closed:** View expenses on closed reports (not submitted for approval). - -Here's how to make the most of these filters: - -1. Log into your web account -2. Go to the **Expenses** page -3. At the top of the page, click on **Show Filters** -4. Adjust the filters to match your specific needs - -Note, you might notice that not all expense filters are always visible. They adapt based on the data you're currently filtering and persist from the last time you logged in. For instance, you won't see the deleted filter if there are no **Deleted** expenses to filter out. - -If you are not seeing what you expected, you may have too many filters applied. Click **Reset** at the top to clear your filters. - - -# How to add an expense to a report from the Expenses Page -The submitter (and their copilot) can add expenses to a report from the Expenses page. - -Note, when expenses aren’t on a report, they are **personal expenses**. So you’ll want to make sure you haven’t filtered out **personal expenses** expenses, or you won’t be able to see them. - -1. Find the expense you want to add. (Hint: Use the filters to sort expenses by the desired date range if it is not a recent expense.) -2. Then, select the expense you want to add to a report. You can click Select All to select multiple expenses. -3. Click **Add to Report** in the upper right corner, and choose either an existing report or create a new one. - -# How to code expenses from the Expenses Page -To code expenses from the Expenses page, do the following: - -1. Look for the **Tag**, **Category**, and **Description** columns on the **Expenses** page. -2. Click on the relevant field for a specific expense and add or update the **Category**, **Tag**, or **Description**. - -Note, you can also open up individual expenses by clicking on them to see a detailed look, but coding the expenses from the Expense list is even faster and more convenient! - -# How to export expenses to a CSV file or spreadsheet -If you want to export multiple expenses, run through the below steps: -Select the expenses you want to export by checking the box to the left of each expense. -Then, click **Export To** in the upper right corner of the page, and choose our default CSV format or create your own custom CSV template. - - -{% include faq-begin.md %} - -## Can I use the filters and analytics features on the mobile app? -The various features on the Expenses Page are only available while logged into your web account. - -## As a Workspace admin, what submitter expenses can you see? -A Workspace admin can see Processing, Approved, and Reimbursed expenses as long as they were submitted on the workspace that you are an admin. - -If employees submit expense reports on a workspace where you are not an admin, you will not have visibility into those expenses. Additionally, if an expense is left unreported, a workspace admin will not be able to see that expense until it’s been added to a report. - -A Workspace admin can edit the tags and categories on an expense, but if they want to edit the amount, date, or merchant name, the expense will need to be in a Processing state or rejected back to the submitter for changes. -We have more about company card expense reconciliation in this [support article](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Reconciliation). - -## Can I edit multiple expenses at once? -Yes! Select the expenses you want to edit and click **Edit Multiple**. - -{% include faq-end.md %} diff --git a/docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md b/docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md index ca0dbfe9ae54..26db42df9e5b 100644 --- a/docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md +++ b/docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md @@ -1,252 +1,162 @@ --- -title: Configure Netsuite +title: Configure NetSuite description: Configure the Import, Export, and Advanced settings for Expensify's integration with NetSuite order: 2 --- -# Configure NetSuite integration -## Step 1: Configure import settings +# Best Practices Using NetSuite + +Using Expensify with NetSuite brings a seamless, efficient approach to managing expenses. With automatic syncing, expense reports flow directly into NetSuite, reducing manual entry and errors while giving real-time visibility into spending. This integration speeds up approvals, simplifies reimbursements, and provides clear insights for smarter budgeting and compliance. Together, Expensify and NetSuite make expense management faster, more accurate, and stress-free. + +# Accessing the NetSuite Configuration Settings + +NetSuite is connected at the workspace level, and each workspace can have a unique configuration that dictates how the connection functions. To access the connection settings: + +1. Click your profile image or icon in the bottom left menu. +2. Scroll down and click **Workspaces** in the left menu. +3. Select the workspace you want to access settings for. +4. Click **Accounting** in the left menu. + +# Step 1: Configure Import Settings + +The following steps help you determine how data will be imported from NetSuite to Expensify. + +1. From the Accounting tab of your workspace settings, click on **Import**. +2. In the right-hand menu, review each of the following import settings: + - _Categories_: Your NetSuite Expense Categories are automatically imported into Expensify as categories. This is enabled by default and cannot be disabled. + - _Department, Classes, and Locations_: The NetSuite connection allows you to import each independently and utilize tags, report fields, or employee defaults as the coding method. + - Tags are applied at the expense level and apply to single expense. + - Report Fields are applied at the report header level and apply to all expenses on the report. + - The employee default is applied when the expense is exported to NetSuite and comes from the default on the submitter’s employee record in NetSuite. + - _Customers and Projects_: The NetSuite connections allows you to import customers and projects into Expensify as Tags or Report Fields. + -_Cross-subsidiary customers/projects_: Enable to import Customers and Projects across all NetSuite subsidiaries to a single Expensify workspace. This setting requires you to enable “Intercompany Time and Expense” in NetSuite. To enable that feature in NetSuite, go to **Setup > Company > Setup Tasks: Enable Features > Advanced Features**. + -_Tax_: Enable to import NetSuite Tax Groups and configure further on the Taxes tab of your workspace settings menu. + -_Custom Segments and Records_: Enable to import segments and records are tags or report fields. + - If configuring Custom Records as Report Fields, use the Field ID on the Transactions tab (under **Custom Segments > Transactions**). + - If configuring Custom Records as Tags, use the Field ID on the Transaction Columns tab (under **Custom Segments > Transaction Columns**). + - Don’t use the “Filtered by” feature available for Custom Segments. Expensify can’t make these dependent on other fields. If you do have a filter selected, we suggest switching that filter in NetSuite to “Subsidiary” and enabling all subsidiaries to ensure you don’t receive any errors upon exporting reports. + -_Custom Lists_: Enable to import lists as tags or reports fields. +3. Sync the connection by closing the right-hand menu and clicking the three-dot icon > Sync Now option. Once the sync completes, you should see the values for any enabled tags or report fields in the corresponding Tag or Report Field tabs in the workspace settings menu. + +{% include info.html %} +When you’re done configuring the settings, or anytime you make changes in the future, sync the NetSuite connection. This will ensure changes are saved and updated across both systems. +{% include end-info.html %} + +# Step 2: Configure Export Settings + +The following steps help you determine how data will be exported from Expensify to NetSuite. + +1. From the Accounting tab of your workspace settings, click on **Export**. +2. In the right-hand menu, review each of the following export settings: + - _Preferred exporter_: Any workspace admin can export reports to NetSuite. For automatic export, Concierge will export on behalf of the preferred exporter. The preferred exporter will also be notified of any expense reports that fail to export to NetSuite due to an error. + - _Export date_: You can choose which date to use for the records created in NetSuite. There are three date options: + - _Date of last expense_: This will use the date of the most recent expense on the report. + - _Submitted date_: The date the employee submitted the report. + - _Exported date_: The date you export the report to NetSuite. + - _Export out-of-pocket expenses as_: + - _Expense Reports_: Out-of-pocket expenses will be exported as expense reports, which will be posted to the payables account designated in NetSuite. + - _Vendor Bills_: Out-of-pocket expenses will be exported to NetSuite as vendor bills. Each report will be posted as payable to the vendor associated with the employee who submitted the report. You can also set an approval level in NetSuite for vendor bills. + - _Journal Entries_: Out-of-pocket expenses will be exported to NetSuite as journal entries. All the transactions will be posted to the payable account specified in the workspace. You can also set an approval level in NetSuite for the journal entries. + - By default, journal entry forms do not contain a customer column, so it is not possible to export customers or projects with this export option. Also, the credit line and header-level classifications are pulled from the employee record. + - _Export company card expenses as_: + - _Expense Reports_:To export company card expenses as expense reports, you will need to configure your default corporate cards in NetSuite. + - _Vendor Bills_: Company card expenses will be posted as a vendor bill payable to the default vendor specified in your workspace Accounting settings. You can also set an approval level in NetSuite for the bills. + - _Journal Entries_: Company Card expenses will be posted to the Journal Entries posting account selected in your workspace Accounting settings. + - Important Notes: + - Expensify Card expenses will always export as Journal Entries, even if you have Expense Reports or Vendor Bills configured for non-reimbursable expenses on the Export tab + - Journal entry forms do not contain a customer column, so it is not possible to export customers or projects with this export option + - The credit line and header level classifications are pulled from the employee record + - _Export invoices to_: Select the Accounts Receivable account where you want your Invoice reports to export. In NetSuite, the invoices are linked to the customer, corresponding to the email address where the invoice was sent. + - _Invoice item_: Choose whether Expensify creates an "Expensify invoice line item" for you upon export (if one doesn’t exist already) or select an existing invoice item. + - _Export foreign currency amount_: Enabling this feature allows you to send the original amount of the expense rather than the converted total when exporting to NetSuite. This option is only available when exporting out-of-pocket expenses as Expense Reports. + - _Export to next open period_: When this feature is enabled and you try exporting an expense report to a closed NetSuite period, we will automatically export to the next open period instead of returning an error. +3. Sync the connection by closing the right-hand menu and clicking the three-dot icon > Sync Now option. + +# Step 3: Configure Advanced Settings + +The following steps help you determine the advanced settings for your NetSuite connection. + +1. From the Accounting tab of your workspace settings, click on **Advanced**. +2. In the right-hand menu, review each of the following advanced settings: + - _Auto-sync_: When enabled, the connection will sync daily to ensure that the data shared between the two systems is up-to-date. We strongly recommend keeping auto-sync enabled. The following will occur when auto-sync is enabled: + - When an expense report reaches its final state in Expensify, it will be automatically exported to NetSuite. The final state will either be reimbursement (if you reimburse members through Expensify) or final approval (if you reimburse members outside of Expensify). + - If Sync Reimbursed Reports is enabled, then we will sync the reimbursement status of reports between Expensify and NetSuite. + - _Sync reimbursed reports_: Any time a report is paid using Expensify ACH, the corresponding bill payment will be created in the NetSuite. + - _Reimbursments account_: Select the account that matches the default account for Bill Payments in your NetSuite account. + - _Collections account_: When exporting invoices, once marked as Paid, the payment is marked against the account selected. + - _Invite employees and set approvals_: Enabling this feature will invite all employees from the connected NetSuite subsidiary to your Expensify workspace. Once imported, Expensify will send them an email letting them know they’ve been added to a workspace. + - In addition to inviting employees, this feature enables a custom set of approval workflow options, which you can manage in Expensify Classic. (Click Switch to Expensify Classic from the Settings menu.) + - _Auto create employees/vendors_: With this feature enabled, Expensify will automatically create a new employee or vendor in NetSuite (if one doesn’t already exist) using the name and email of the report submitter. + - _Enable newly imported categories_: Toggle to enable this feature and anytime a new Expense Category is created in NetSuite, it will be imported into Expensify as an enabled category. Otherwise, it will import disabled and employees will be unable to see it as an option to code to an expense. + - _Setting approval levels_: You can set the NetSuite approval level for each different export type; Expense report, Vendor bill, and Journal entry. + - Note: If you have Approval Routing selected in your accounting preference, this will override the selections in Expensify. If you do not wish to use Approval Routing in NetSuite, go to **Setup > Accounting > Accounting Preferences > Approval Routing** and ensure Vendor Bills and Journal Entries are not selected. + - _Custom form ID_: By default, Expensify creates entries using the preferred transaction form set in NetSuite. Enabling this setting allows you to designate a specific transaction form. + - _Out-of-pocket expense_: + - _Company card expense_: +3. Sync the connection by closing the right-hand menu and clicking the three-dot icon > Sync Now option. + +{% include faq-begin.md %} + +## I added tags in NetSuite (departments, classes, or locations) how do I get them into my workspace? + +New departments, classes, and locations must be added in NetSuite first before they can be added as options to code to expenses in Expensify. After adding them in NetSuite, sync your connection to import the new options. + +Once imported, you can turn specific tags on or off under **Settings > Workspaces > [Workspace Name] > Tags**. You can turn specific report fields on or off under **Settings > Workspaces > [Workspace Name] > Report Fields**. + +## Is it possible to automate inviting my employees and their approver from NetSuite into Expensify? + +Yes, you can automatically import your employees and set their approval workflow with your connection between NetSuite and Expensify. -The following section will help you determine how data will be imported from NetSuite into Expensify. To change your import settings, navigate to the Accounting settings for your workspace, then click **Import** under the NetSuite connection. - -### Expense Categories -Your NetSuite Expense Categories are automatically imported into Expensify as categories. This cannot be amended, and any new categories you'd like to add must be added as Expense Categories in NetSuite. - -Once imported, you can turn specific Categories on or off under **Settings > Workspaces > [Workspace Name] > Categories**. - -### Departments, Classes, and Locations -The NetSuite integration allows you to import departments, classes, and locations from NetSuite into Expensify as Tags, Report Fields, or using the NetSuite Employee Default. - -- **NetSuite Employee Default:** If default Department, Class, and Locations have been configured on NetSuite employee records, then you can choose to have the NetSuite employee default applied upon export from Expensify to NetSuite. With this selection, employees will not make a selection in Expensify. -- **Tags:** Employees can select the department, class, or location on each individual expense. If the employee's NetSuite employee record has a default value, then each expense will be defaulted to that tag upon creation, with the option for the employee to select a different value on each expense. -- **Report Fields:** Employees can select one department/class/location for each expense report. - - -New departments, classes, and locations must be added in NetSuite. Once imported, you can turn specific tags on or off under **Settings > Workspaces > [Workspace Name] > Tags**. You can turn specific report fields on or off under **Settings > Workspaces > [Workspace Name] > Report Fields**. - -### Customers and Projects -The NetSuite integration allows you to import customers and projects into Expensify as Tags or Report Fields. - -- **Tags:** Employees can select the customer or project on each individual expense. -- **Report Fields:** Employees can select one department/class/location for each expense report. - -New customers and projects must be added in NetSuite. Once imported, you can turn specific tags on or off under **Settings > Workspaces > [Workspace Name] > Tags**. You can turn specific report fields on or off under **Settings > Workspaces > [Workspace Name] > Report Fields**. - -When importing customers or projects, you can also choose to enable **Cross-subsidiary customers/projects**. This setting allows you to import Customers and Projects across all NetSuite subsidiaries to a single Expensify workspace. This setting requires you to enable “Intercompany Time and Expense” in NetSuite. To enable that feature in NetSuite, go to **Setup > Company > Setup Tasks: Enable Features > Advanced Features**. - -### Tax -The NetSuite integration allows users to apply a tax rate and amount to each expense for non-US NetSuite subsidiaries. To do this, import Tax Groups from NetSuite: - -1. In NetSuite, head to **Setup > Accounting > Tax Groups** -2. Once imported, go to the NetSuite connection configuration page in Expensify (under **Settings > Workspaces > [Workspace Name] > Accounting > NetSuite > Import**) -3. Enable Tax -4. Go back to the Accounting screen, click the three dots next to NetSuite, and click **Sync now** -5. All Tax Groups for the connected NetSuite subsidiary will be imported to Expensify as taxes. -6. After syncing, go to **Settings > Workspace > [Workspace Name] > Tax** to see the tax groups imported from NetSuite - -### Custom Segments -You can import one or more Custom Segments from NetSuite for selection in Expensify. To add a Custom Segment to your Expensify workspace: - -1. Go to **Settings > Workspaces > [Workspace Name] > Accounting** -2. Click **Import** under NetSuite -3. Click **Custom segments/records** -4. Click **Add custom segment/record** - -From there, you'll walk through a simple setup wizard. You can find detailed instructions below for each setup step. - -1. In Step 1, you'll select whether you'd like to import a custom segment or a custom record. For a Custom Segment, continue. We have separate instructions for [Custom Records](link) and [Custom Lists](link). -2. **Segment Name** - a. Log into NetSuite as an administrator - b. Go to **Customization > Lists, Records, & Fields > Custom Segments** - c. You’ll see the Segment Name on the Custom Segments page -3. Internal ID - a. Ensure you have internal IDs enabled in NetSuite under **Home > Set Preferences** - b. Navigate back to the **Custom Segments** page - c. Click the **Custom Record Type** link - d. You’ll see the Internal ID on the Custom Record Type page -4. **Script ID/Field ID** - a. If configuring Custom Segments as Report Fields, use the Field ID on the Transactions tab (under **Custom Segments > Transactions**). If no Field ID is shown, use the unified ID (just called “ID” right below the “Label”). - b. If configuring Custom Segments as Tags, use the Field ID on the Transaction Columns tab (under **Custom Segments > Transaction Columns**). If no Field ID is shown, use the unified ID (just called “ID” right below the “Label”). - c. Note that as of 2019.1, any new custom segments that you create automatically use the unified ID, and the "Use as Field ID" box is not visible. If you are editing a custom segment definition that was created before 2019.1, the "Use as Field ID" box is available. To use a unified ID for the entire custom segment definition, check the "Use as Field ID" box. When the box is checked, no field ID fields or columns are shown on the Application & Sourcing subtabs because one ID is used for all fields. -5. Select whether you'd like to import the custom segment as Tags or Report Fields -6. Finally, confirm that all the details look correct - -**Note:** Don’t use the “Filtered by” feature available for Custom Segments. Expensify can’t make these dependent on other fields. If you do have a filter selected, we suggest switching that filter in NetSuite to “Subsidiary” and enabling all subsidiaries to ensure you don’t receive any errors upon exporting reports. +Enabling this feature will invite all employees from the connected NetSuite subsidiary to your Expensify workspace. Once imported, Expensify will send them an email letting them know they’ve been added to a workspace. -### Custom Records -You can import one or more Custom Records from NetSuite for selection in Expensify. To add a Custom Record to your Expensify workspace: +In addition to inviting employees, this feature enables a custom set of approval workflow options, which you can manage in Expensify Classic. (Click Switch to Expensify Classic from the Settings menu.) Your options for approval include: -1. Go to **Settings > Workspaces > [Workspace Name] > Accounting** -2. Click **Import** under NetSuite -3. Click **Custom segments/records** -4. Click **Add custom segment/record** +- **Basic Approval:** A single level of approval, where all users submit directly to a Final Approver. The Final Approver defaults to the workspace owner but can be edited on the people page. +- **Manager Approval (default):** Two levels of approval route reports first to an employee’s NetSuite expense approver or supervisor, and second to a workspace-wide Final Approver. By NetSuite convention, Expensify will map to the supervisor if no expense approver exists. The Final Approver defaults to the workspace owner but can be edited on the people page. +- **Configure Manually:** Employees will be imported, but all levels of approval must be manually configured on the workspace’s People settings page. If you enable this setting, it’s recommended you review the newly imported employees and managers on the **Settings > Workspaces > Group > [Workspace Name] > People** page. -From there, you'll walk through a simple setup wizard. You can find detailed instructions below for each setup step. -1. In Step 1, you'll select whether you'd like to import a custom segment or a custom record. For a Custom Record, continue. We have separate instructions for [Custom Segments](link) and [Custom Lists](link). -2. **Segment Name** - a. Log into NetSuite as an administrator - b. Go to **Customization > Lists, Records, & Fields > Custom Segments** - c. You’ll see the Custom Record Name on the Custom Segments page -3. **Internal ID** - a. Make sure you have Internal IDs enabled in NetSuite under **Home > Set Preferences** - b. Navigate back to the **Custom Segment** page - c. Click the **Custom Record Type** hyperlink - d. You’ll see the Internal ID on the Custom Record Type page -4. **Transaction Column ID** - a. If configuring Custom Records as Report Fields, use the Field ID on the Transactions tab (under **Custom Segments > Transactions**). - b. If configuring Custom Records as Tags, use the Field ID on the Transaction Columns tab (under **Custom Segments > Transaction Columns**). -5. Select whether you'd like to import the custom record as Tags or Report Fields -6. Finally, confirm that all the details look correct +## I notice that company card expenses export to NetSuite right away when I approve a report, but reimbursable expenses don’t, why is that? -### Custom Lists -You can import one or more Custom Lists from NetSuite for selection in Expensify. To add a Custom List to your Expensify workspace: +When Auto Sync is enabled and you reimburse employees through Expensify, we help to automatically send finalized expenses to NetSuite. The timing of the export depends on the type of expense it is. + - **If you reimburse members through Expensify:** Reimbursing an expense report will trigger auto-export to NetSuite. When the expense report is exported to NetSuite, a corresponding bill payment will also be created in NetSuite. + - **If you reimburse members outside of Expensify:** Expense reports will be exported to NetSuite at the time of final approval. After you mark the report as paid in NetSuite, the reimbursed status will be synced back to Expensify the next time the integration syncs. -1. Go to **Settings > Workspaces > [Workspace Name] > Accounting** -2. Click **Import** under NetSuite -3. Click **Custom list** -4. Click **Add custom list** +## How do I configure my default corporate cards in NetSuite? -From there, you'll walk through a simple setup wizard. You can find detailed instructions below for each setup step. +To export company card expenses as expense reports, you must configure your default corporate cards in NetSuite. -1. In Step 1, you'll select which Custom List you'd like to import from a pre-populated list -2. **Transaction Line Field ID** - a. Log into NetSuite as an admin - b. Search **“Transaction Line Fields”** in the global search - c. Click into the desired Custom List - d. You'll find the transaction Line Field ID along the left-hand side of the page -3. Select whether you'd like to import the custom list as Tags or Report Fields -4. Finally, confirm that all the details look correct - -From there, you should see the values for the Custom Lists under the Tag or Report Field settings in Expensify. -## Step 2: Configure export settings -There are numerous options for exporting data from Expensify to NetSuite. To access these settings, head to **Settings > Workspaces > [Workspace name] > Accounting** and click **Export** under NetSuite. - -### Preferred Exporter -Any workspace admin can export reports to NetSuite. For auto-export, Concierge will export on behalf of the preferred exporter. The preferred exporter will also be notified of any expense reports that fail to export to NetSuite due to an error. - -### Date -You can choose which date to use for the records created in NetSuite. There are three date options: - -1. **Date of last expense:** This will use the date of the previous expense on the report -2. **Submitted date:** The date the employee submitted the report -3. **Exported date:** The date you export the report to NetSuite - -### Export out-of-pocket expenses as -**Expense Reports** -Out-of-pocket expenses will be exported to NetSuite as expense reports, which will be posted to the payables account designated in NetSuite. - -**Vendor Bills** -Out-of-pocket expenses will be exported to NetSuite as vendor bills. Each report will be posted as payable to the vendor associated with the employee who submitted the report. You can also set an approval level in NetSuite for vendor bills. - -**Journal Entries** -Out-of-pocket expenses will be exported to NetSuite as journal entries. All the transactions will be posted to the payable account specified in the workspace. You can also set an approval level in NetSuite for the journal entries. - -Note: By default, journal entry forms do not contain a customer column, so it is not possible to export customers or projects with this export option. Also, The credit line and header level classifications are pulled from the employee record. - -### Export company card expenses as -**Expense Reports** -To export company card expenses as expense reports, you will need to configure your default corporate cards in NetSuite. To do this, you must select the correct card on the NetSuite employee records (for individual accounts) or the subsidiary record (If you use a non-One World account, the default is found in your accounting preferences). +To do this, you must select the correct card on the NetSuite employee records (for individual accounts) or the subsidiary record (If you use a non-One World account, the default is found in your accounting preferences). To update your expense report transaction form in NetSuite: -1. Go to **Customization > Forms > Transaction Forms** -2. Click **Edit** next to the preferred expense report form -3. Go to the **Screen Fields > Main** tab -4. Check “Show” for "Account for Corporate Card Expenses" -5. Go to the **Screen Fields > Expenses** tab -6. Check “Show” for "Corporate Card" +1. Go to **Customization > Forms > Transaction Forms.** +2. Click **Edit** next to the preferred expense report form. +3. Go to the **Screen Fields > Main** tab. +4. Check “Show” for "Account for Corporate Card Expenses." +5. Go to the **Screen Fields > Expenses** tab. +6. Check “Show” for "Corporate Card." You can also select the default account on your employee record to use individual corporate cards for each employee. Make sure you add this field to your employee entity form in NetSuite. If you have multiple cards assigned to a single employee, you cannot export to each account. You can only have a single default per employee record. -**Vendor Bills** -Company card expenses will be posted as a vendor bill payable to the default vendor specified in your workspace Accounting settings. You can also set an approval level in NetSuite for the bills. - - -**Journal Entries** -Company Card expenses will be posted to the Journal Entries posting account selected in your workspace Accounting settings. - -Important Notes: - -- Expensify Card expenses will always export as Journal Entries, even if you have Expense Reports or Vendor Bills configured for non-reimbursable expenses on the Export tab -- Journal entry forms do not contain a customer column, so it is not possible to export customers or projects with this export option -- The credit line and header level classifications are pulled from the employee record - -### Export invoices to -Select the Accounts Receivable account where you want your Invoice reports to export. In NetSuite, the invoices are linked to the customer, corresponding to the email address where the invoice was sent. - -### Export foreign currency amount -Enabling this feature allows you to send the original amount of the expense rather than the converted total when exporting to NetSuite. This option is only available when exporting out-of-pocket expenses as Expense Reports. - -### Export to next open period -When this feature is enabled and you try exporting an expense report to a closed NetSuite period, we will automatically export to the next open period instead of returning an error. - - -## Step 3: Configure advanced settings -To access the advanced settings of the NetSuite integration, head to **Settings > Workspaces > [Workspace name] > Accounting** and click **Advanced** under NetSuite. +## My custom segments created before 2019.1 weren’t created with a unified ID, what change can I make to import them into Expensify?” + Note that as of 2019.1, any new custom segments that you create automatically use the unified ID, and the "Use as Field ID" box is not visible. If you are editing a custom segment definition that was created before 2019.1, the "Use as Field ID" box is available. To use a unified ID for the entire custom segment definition, check the "Use as Field ID" box. When the box is checked, no field ID fields or columns are shown on the Application & Sourcing subtabs because one ID is used for all fields. -Let’s review the different advanced settings and how they interact with the integration. +## How does Auto-sync work with reimbursed reports? -### Auto-sync -We strongly recommend enabling auto-sync to ensure that the information in NetSuite and Expensify is always in sync. The following will occur when auto-sync is enabled: - -**Daily sync from NetSuite to Expensify:** Once a day, Expensify will sync any changes from NetSuite into Expensify. This includes any new, updated, or removed departments/classes/locations/projects/etc. - -**Auto-export:** When an expense report reaches its final state in Expensify, it will be automatically exported to NetSuite. The final state will either be reimbursement (if you reimburse members through Expensify) or final approval (if you reimburse members outside of Expensify). - -**Reimbursement-sync:** If Sync Reimbursed Reports (more details below) is enabled, then we will sync the reimbursement status of reports between Expensify and NetSuite. - -### Sync reimbursed reports -When Sync reimbursed reports is enabled, the reimbursement status will be synced between Expensify and NetSuite. - -**If you reimburse members through Expensify:** Reimbursing an expense report will trigger auto-export to NetSuite. When the expense report is exported to NetSuite, a corresponding bill payment will also be created in NetSuite. - -**If you reimburse members outside of Expensify:** Expense reports will be exported to NetSuite at time of final approval. After you mark the report as paid in NetSuite, the reimbursed status will be synced back to Expensify the next time the integration syncs. - -To ensure this feature works properly for expense reports, make sure that the reimbursement account you choose within the settings matches the default account for Bill Payments in NetSuite. When exporting invoices, once marked as Paid, the payment is marked against the account selected after enabling the Collection Account setting. - -### Invite employees and set approvals -Enabling this feature will invite all employees from the connected NetSuite subsidiary to your Expensify workspace. Once imported, Expensify will send them an email letting them know they’ve been added to a workspace. - -In addition to inviting employees, this feature enables a custom set of approval workflow options, which you can manage in Expensify Classic: - -- **Basic Approval:** A single level of approval, where all users submit directly to a Final Approver. The Final Approver defaults to the workspace owner but can be edited on the people page. -- **Manager Approval (default):** Two levels of approval route reports first to an employee’s NetSuite expense approver or supervisor, and second to a workspace-wide Final Approver. By NetSuite convention, Expensify will map to the supervisor if no expense approver exists. The Final Approver defaults to the workspace owner but can be edited on the people page. -- **Configure Manually:** Employees will be imported, but all levels of approval must be manually configured on the workspace’s People settings page. If you enable this setting, it’s recommended you review the newly imported employees and managers on the **Settings > Workspaces > Group > [Workspace Name] > People** page. - -### Auto-create employees/vendors -With this feature enabled, Expensify will automatically create a new employee or vendor in NetSuite (if one doesn’t already exist) using the name and email of the report submitter. - -### Enable newly imported categories -With this feature enabled, anytime a new Expense Category is created in NetSuite, it will be imported into Expensify as an enabled category. If the feature is disabled, then new Expense Categories will be imported into Expensify as disabled. - -### Setting approval levels -You can set the NetSuite approval level for each different export type: - -- **Expense report approval level:** Choose from "NetSuite default preference," “Only supervisor approved,” “Only accounting approved,” or “Supervisor and accounting approved.” -- **Vendor bill approval level and Journal entry approval level:** Choose from "NetSuite default preference," “Pending approval,” or “Approved for posting.” - -If you have Approval Routing selected in your accounting preference, this will override the selections in Expensify. If you do not wish to use Approval Routing in NetSuite, go to **Setup > Accounting > Accounting Preferences > Approval Routing** and ensure Vendor Bills and Journal Entries are not selected. - -### Custom form ID -By default, Expensify will create entries using the preferred transaction form set in NetSuite. Alternatively, you have the option to designate a specific transaction form to be used. - - - -## FAQ - -### How does Auto-sync work with reimbursed reports? If a report is reimbursed via ACH or marked as reimbursed in Expensify and then exported to NetSuite, the report is automatically marked as paid in NetSuite. -If a report is exported to NetSuite, then marked as paid in NetSuite, the report will automatically be marked as reimbursed in Expensify during the next sync. +If a report is exported to NetSuite, and then marked as paid in NetSuite, the report will automatically be marked as reimbursed in Expensify during the next sync. + +## Will enabling auto-sync affect existing approved and reimbursed reports? -### Will enabling auto-sync affect existing approved and reimbursed reports? -Auto-sync will only export newly approved reports to NetSuite. Any reports that were approved or reimbursed before enabling auto-sync will need to be manually exported in order to sync them to NetSuite. +Auto-sync will only export newly approved reports to NetSuite. Reports that were approved or reimbursed before enabling auto-sync will need to be manually exported to sync them to NetSuite. +## When using multi-currency features in NetSuite, can expenses be exported with any currency? -### When using multi-currency features in NetSuite, can expenses be exported with any currency? When using multi-currency features with NetSuite, remember these points: **Employee/Vendor currency:** The currency set for a NetSuite vendor or employee record must match the subsidiary currency for whichever subsidiary you export that user's reports to. A currency mismatch will cause export errors. **Bank Account Currency:** When synchronizing bill payments, your bank account’s currency must match the subsidiary’s currency. Failure to do so will result in an “Invalid Account” error. +{% include faq-end.md %} diff --git a/docs/articles/new-expensify/connections/netsuite/Connect-to-NetSuite.md b/docs/articles/new-expensify/connections/netsuite/Connect-to-NetSuite.md index 19009c016862..990217523743 100644 --- a/docs/articles/new-expensify/connections/netsuite/Connect-to-NetSuite.md +++ b/docs/articles/new-expensify/connections/netsuite/Connect-to-NetSuite.md @@ -4,124 +4,161 @@ description: Integrate NetSuite with Expensify order: 1 --- -# Connect to NetSuite - -## Overview -Expensify’s integration with NetSuite allows you to sync data between the two systems. Before you start connecting Expensify with NetSuite, there are a few things to note: - -- You must use NetSuite administrator credentials to initiate the connection -- A Control Plan in Expensify is required to integrate with NetSuite -- Employees don’t need NetSuite access or a NetSuite license to submit expense reports and sync them to NetSuite -- Each NetSuite subsidiary must be connected to a separate Expensify workspace -- The workspace currency in Expensify must match the NetSuite subsidiary's default currency - -## Step 1: Install the Expensify Bundle in NetSuite -1. While logged into NetSuite as an administrator, go to **Customization > SuiteBundler > Search & Install Bundles**, then search for “Expensify” -2. Click on the Expensify Connect bundle (Bundle ID 283395) -3. Click **Install** -4. If you already have the Expensify Connect bundle installed, head to **Customization > SuiteBundler > Search & Install Bundles > List**, and update it to the latest version -5. Select "Show on Existing Custom Forms" for all available fields - -## Step 2: Enable Token-Based Authentication -1. In NetSuite, go to **Setup > Company > Enable Features > SuiteCloud > Manage Authentication** -2. Make sure “Token Based Authentication” is enabled -3. Click **Save** - - -## Step 3: Add Expensify Integration Role to a User -1. In NetSuite, head to **Lists > Employees**, and find the user who you would like to add the Expensify Integration role to. The user you select must at least have access to the permissions included in the Expensify Integration Role, and Admin access works too, but Admin access is not required. -2. Click **Edit > Access**, then find the Expensify Integration role in the dropdown and add it to the user -3. Click **Save** - -Remember that Tokens are linked to a User and a Role, not solely to a User. It’s important to note that you cannot establish a connection with tokens using one role and then switch to another role afterward. Once you’ve initiated a connection with tokens, you must continue using the same token/user/role combination for all subsequent sync or export actions. - -## Step 4: Create Access Tokens -1. In NetSuite, enter “page: tokens” in the Global Search -2. Click **New Access Token** -3. Select Expensify as the application (this must be the original Expensify integration from the bundle) -4. Select the role Expensify Integration -5. Click **Save** -6. Copy and paste the token and token ID to a saved location on your computer (this is the only time you will see these details) - -## Step 5: Confirm Expense Reports are enabled in NetSuite +{% include info.html %} +To use the NetSuite connection, you must have a NetSuite account and an Expensify Control plan. +{% include end-info.html %} + +Expensify’s integration with NetSuite supports syncing data between the two systems. Before you start connecting Expensify with NetSuite, there are a few things to note: + +- You must use NetSuite administrator credentials to initiate the connection. +- A Control Plan in Expensify is required to integrate with NetSuite. +- Employees don’t need NetSuite access or a NetSuite license to submit expense reports and sync them to NetSuite. +- Each NetSuite subsidiary must be connected to a separate Expensify workspace. +- The workspace currency in Expensify must match the NetSuite subsidiary's default currency. + +# Step 1: Install the Expensify Bundle in NetSuite + +While logged into NetSuite as an administrator, go to **Customization > SuiteBundler > Search & Install Bundles**, then search for “Expensify”. +Click on the Expensify Connect bundle (Bundle ID 283395). +Click **Install**. +If you already have the Expensify Connect bundle installed, head to **Customization > SuiteBundler > Search & Install Bundles > List**, and update it to the latest version. +Select "Show on Existing Custom Forms" for all available fields. + +# Step 2: Enable Token-Based Authentication + +In NetSuite, go to **Setup > Company > Enable Features > SuiteCloud > Manage Authentication**. +Make sure “Token Based Authentication” is enabled. +Click **Save**. + +# Step 3: Add Expensify Integration Role to a User + +In NetSuite, head to **Lists > Employees**, and find the user to who you would like to add the Expensify Integration role. The user you select must at least have access to the permissions included in the Expensify Integration Role, and Admin access works too, but Admin access is not required. +Click **Edit > Access**, then find the Expensify Integration role in the dropdown and add it to the user. +Click **Save**. + + +{% include info.html %} +Remember that Tokens are linked to a **User** and a **Role**, not solely to a User. It’s important to note that you cannot establish a connection with tokens using one role and then switch to another role afterward. Once you’ve initiated a connection with tokens, you must continue using the same token/user/role combination for all subsequent sync or export actions. +{% include end-info.html %} + +# Step 4: Create Access Tokens + + +In NetSuite, enter “page: tokens” in the Global Search. +Click **New Access Token**. +Select Expensify as the application (this must be the original Expensify integration from the bundle). +Select the role Expensify Integration. +Click **Save**. +Copy and paste the token and token ID to a saved location on your computer (this is the only time you will see these details.) + + +# Step 5: Confirm Expense Reports are enabled in NetSuite + +{% include info.html %} Expense Reports must be enabled in order to use Expensify’s integration with NetSuite. +{% include end-info.html %} + -1. In NetSuite, go to **Setup > Company > Enable Features > Employees** -2. Confirm the checkbox next to "Expense Reports" is checked -3. If not, click the checkbox and then click **Save** to enable Expense Reports +In NetSuite, go to **Setup > Company > Enable Features > Employees**. +Confirm the checkbox next to "Expense Reports" is checked. +If not, click the checkbox and then click **Save** to enable Expense Reports. -## Step 6: Confirm Expense Categories are set up in NetSuite + +# Step 6: Confirm Expense Categories are set up in NetSuite + +{% include info.html %} Once Expense Reports are enabled, Expense Categories can be set up in NetSuite. Expense Categories are synced to Expensify as Categories. Each Expense Category is an alias mapped to a General Ledger account so that employees can more easily categorize expenses. +{% include end-info.html %} + + +In NetSuite, go to **Setup > Accounting > Expense Categories** (a list of Expense Categories should show.) +If no Expense Categories are visible, click **New** to create new ones. + +# Step 7: Confirm Journal Entry Transaction Forms are Configured Properly -1. In NetSuite, go to **Setup > Accounting > Expense Categories** (a list of Expense Categories should show) -2. If no Expense Categories are visible, click **New** to create new ones - -## Step 7: Confirm Journal Entry Transaction Forms are Configured Properly -1. In NetSuite, go to **Customization > Forms > Transaction Forms** -2. Click **Customize** or **Edit** next to the Standard Journal Entry form -3. Click **Screen Fields > Main**. Please verify the “Created From” label has “Show” checked and the "Display Type" is set to "Normal" -4. Click the sub-header **Lines** and verify that the “Show” column for “Receipt URL” is checked -5. Go to **Customization > Forms > Transaction Forms** and ensure that all other transaction forms with the journal type have this same configuration - -## Step 8: Confirm Expense Report Transaction Forms are Configured Properly -1. In NetSuite, go to **Customization > Forms > Transaction Forms** -2. Click **Customize** or **Edit** next to the Standard Expense Report form, then click **Screen Fields > Main** -3. Verify the “Created From” label has “Show” checked and the "Display Type" is set to "Normal" -4. Click the second sub-header, **Expenses**, and verify that the "Show" column for "Receipt URL" is checked -5. Go to **Customization > Forms > Transaction Forms** and ensure that all other transaction forms with the expense report type have this same configuration - -## Step 9: Confirm Vendor Bill Transactions Forms are Configured Properly -1. In NetSuite, go to **Customization > Forms > Transaction Forms** -2. Click **Customize** or **Edit** next to your preferred Vendor Bill form -3. Click **Screen Fields > Main** and verify that the “Created From” label has “Show” checked and that Departments, Classes, and Locations have the “Show” label unchecked -4. Under the **Expenses** sub-header (make sure to click the “Expenses” sub-header at the very bottom and not “Expenses & Items”), ensure “Show” is checked for Receipt URL, Department, Location, and Class -5. Go to **Customization > Forms > Transaction Forms** and ensure that all other transaction forms with the vendor bill type have this same configuration - -## Step 10: Confirm Vendor Credit Transactions Forms are Configured Properly -1. In NetSuite, go to **Customization > Forms > Transaction Forms** -2. Click **Customize** or **Edit** next to your preferred Vendor Credit form, then click **Screen Fields > Main** and verify that the “Created From” label has “Show” checked and that Departments, Classes, and Locations have the “Show” label unchecked -3. Under the **Expenses** sub-header (make sure to click the “Expenses” sub-header at the very bottom and not “Expenses & Items”), ensure “Show” is checked for Receipt URL, Department, Location, and Class -4. Go to **Customization > Forms > Transaction Forms** and ensure that all other transaction forms with the vendor credit type have this same configuration - -## Step 11: Set up Tax Groups (only applicable if tracking taxes) -Expensify imports NetSuite Tax Groups (not Tax Codes), which you can find in NetSuite under **Setup > Accounting > Tax Groups**. +In NetSuite, go to **Customization > Forms > Transaction Forms.** +Click **Customize** or **Edit** next to the Standard Journal Entry form. +Click **Screen Fields > Main**. Please verify the “Created From” label has “Show” checked and the "Display Type" is set to "Normal." +Click the sub-header **Lines** and verify that the “Show” column for “Receipt URL” is checked. +Go to **Customization > Forms > Transaction Forms** and ensure that all other transaction forms with the journal type have this same configuration. + +# Step 8: Confirm Expense Report Transaction Forms are Configured Properly + + +In NetSuite, go to **Customization > Forms > Transaction Forms.** +Click **Customize** or **Edit** next to the Standard Expense Report form, then click **Screen Fields > Main.** +Verify the “Created From” label has “Show” checked and the "Display Type" is set to "Normal." +Click the second sub-header, **Expenses**, and verify that the "Show" column for "Receipt URL" is checked. +Go to **Customization > Forms > Transaction Forms** and ensure that all other transaction forms with the expense report type have this same configuration. + + +# Step 9: Confirm Vendor Bill Transactions Forms are Configured Properly + + +In NetSuite, go to **Customization > Forms > Transaction Forms.** +Click **Customize** or **Edit** next to your preferred Vendor Bill form. +Click **Screen Fields > Main** and verify that the “Created From” label has “Show” checked and that Departments, Classes, and Locations have the “Show” label unchecked. +Under the **Expenses** sub-header (make sure to click the “Expenses” sub-header at the very bottom and not “Expenses & Items”), ensure “Show” is checked for Receipt URL, Department, Location, and Class. +Go to **Customization > Forms > Transaction Forms** and ensure that all other transaction forms with the vendor bill type have this same configuration. + + +# Step 10: Confirm Vendor Credit Transactions Forms are Configured Properly + + +In NetSuite, go to **Customization > Forms > Transaction Forms**. +Click **Customize** or **Edit** next to your preferred Vendor Credit form, then click **Screen Fields > Main** and verify that the “Created From” label has “Show” checked and that Departments, Classes, and Locations have the “Show” label unchecked. +Under the **Expenses** sub-header (make sure to click the “Expenses” sub-header at the very bottom and not “Expenses & Items”), ensure “Show” is checked for Receipt URL, Department, Location, and Class. +Go to **Customization > Forms > Transaction Forms** and ensure that all other transaction forms with the vendor credit type have this same configuration. + + +# Step 11: Set up Tax Groups (only applicable if tracking taxes) + +{% include info.html %} +**Things to note about tax.** +Expensify imports NetSuite Tax Groups (not Tax Codes). To ensure Tax Groups can be applied to expenses go to **Setup > Accounting > Set Up Taxes** and set the _Tax Code Lists Include_ preference to “Tax Groups And Tax Codes” or “Tax Groups Only.” If this field does not display, it’s not needed for that specific country. Tax Groups are an alias for Tax Codes in NetSuite and can contain one or more Tax Codes (Please note: for UK and Ireland subsidiaries, please ensure your Tax Groups do not have more than one Tax Code). We recommend naming Tax Groups so your employees can easily understand them, as the name and rate will be displayed in Expensify. +{% include end-info.html %} + +Go to **Setup > Accounting > Tax Groups**. +Click **New**. +Select the country for your Tax Group. +Enter the Tax Name (this is what employees will see in Expensify.) +Select the subsidiary for this Tax Group. +Select the Tax Code from the table you wish to include in this Tax Group. +Click **Add**. +Click **Save**. +Create one NetSuite Tax Group for each tax rate you want to show in Expensify. + +# Step 12: Connect Expensify to NetSuite + +Click your profile image or icon in the bottom left menu. +Scroll down and click **Workspaces** in the left menu. +Select the workspace you want to connect to NetSuite. +Click **More features** in the left menu. +Click **More features** in the left menu. +Scroll down to the Integrate section and enable the Accounting toggle. +Click **Accounting** in the left menu. +Click **Connect** next to NetSuite. +Click **Next** until you reach setup step 5 (If you followed the instructions above, then the first four setup steps will already be complete.) +On setup step 5, enter your NetSuite Account ID, Token ID, and Token Secret (the NetSuite Account ID can be found in NetSuite by going to **Setup > Integration > Web Services Preferences**.) +Click **Confirm** to complete the setup. -To set up Tax Groups in NetSuite: - -1. Go to **Setup > Accounting > Tax Groups** -2. Click **New** -3. Select the country for your Tax Group -4. Enter the Tax Name (this is what employees will see in Expensify) -5. Select the subsidiary for this Tax Group -6. Select the Tax Code from the table you wish to include in this Tax Group -7. Click **Add** -8. Click **Save** -9. Create one NetSuite Tax Group for each tax rate you want to show in Expensify - -Ensure Tax Groups can be applied to expenses by going to **Setup > Accounting > Set Up Taxes** and setting the Tax Code Lists Include preference to “Tax Groups And Tax Codes” or “Tax Groups Only.” If this field does not display, it’s not needed for that specific country. - -## Step 12: Connect Expensify to NetSuite -1. Log into Expensify as a workspace admin -2. Click your profile image or icon in the bottom left menu -3. Scroll down and click **Workspaces** in the left menu -4. Select the workspace you want to connect to NetSuite -5. Click **More features** in the left menu -6. Scroll down to the Integrate section and enable Accounting -7. Click **Accounting** in the left menu -8. Click **Set up** next to NetSuite -9. Click **Next** until you reach setup step 5 (If you followed the instructions above, then the first four setup steps will be complete) -10. On setup step 5, enter your NetSuite Account ID, Token ID, and Token Secret (the NetSuite Account ID can be found in NetSuite by going to **Setup > Integration > Web Services Preferences**) -11. Click **Confirm** to complete the setup + +![The New Expensify workspace setting is open and the More Features tab is selected and visible. The toggle to enable Accounting is highlighted with an orange call out and is currently in the grey disabled position.]({{site.url}}/assets/images/ExpensifyHelp-Xero-1.png) + +![The New Expensify workspace settings > More features tab is open with the toggle to enable Accounting enabled and green. The Accounting tab is now visible in the left-hand menu and is highlighted with an orange call out.]({{site.url}}/assets/images/ExpensifyHelp-Xero-2.png){:width="100%"} After completing the setup, the NetSuite connection will sync. It can take 1-2 minutes to sync with NetSuite. -Once connected, all reports exported from Expensify will be generated in NetSuite using SOAP Web Services (the term NetSuite employs when records are created through the integration). +Once connected, all newly approved and paid reports exported from Expensify will be generated in NetSuite using SOAP Web Services (the term NetSuite employs when records are created through the integration). + +{% include faq-begin.md %} + +## If I have a lot of customer and vendor data in NetSuite, how can I help ensure that importing them all is seamless? + +For importing your customers and vendors, make sure your page size is set to 1000 in NetSuite. -## FAQ -### What type of Expensify plan is required to connect to NetSuite? -You need a Control workspace to integrate with NetSuite. If you have a Collect workspace, you will need to upgrade to Control. +Go to **Setup > Integration > Web Services Preferences** and search **Page Size** to determine your page size. -### Page size -Make sure your page size is set to 1000 in NetSuite for importing your customers and vendors. Go to **Setup > Integration > Web Services Preferences** and search **Page Size** to determine your page size. +{% include faq-end.md %} diff --git a/docs/redirects.csv b/docs/redirects.csv index d9f18ebb0227..4a08a683d08e 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -591,10 +591,11 @@ https://help.expensify.com/articles/expensify-classic/articles/expensify-classic https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/add-a-payment-card-and-view-your-subscription,https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Billing-page-coming-soon,https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Billing-page +https://help.expensify.com/articles/expensify-classic/expenses/The-Expenses-Page,https://help.expensify.com/articles/expensify-classic/expenses/Navigate-the-Expenses-Page https://help.expensify.com/articles/expensify-classic/expenses/Add-expenses-in-bulk,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense https://help.expensify.com/articles/expensify-classic/expenses/Track-group-expenses,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense https://help.expensify.com/articles/expensify-classic/expenses/Track-mileage-expenses,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense https://help.expensify.com/articles/expensify-classic/expenses/Track-per-diem-expenses,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense https://community.expensify.com/discussion/5116/faq-where-can-i-use-the-expensify-card,https://help.expensify.com/articles/new-expensify/expensify-card/Use-your-Expensify-Card#where-can-i-use-my-expensify-card https://help.expensify.com/articles/other/Expensify-Lounge,https://help.expensify.com/Hidden/Expensify-Lounge -https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-expenses +https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-expenses \ No newline at end of file diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 537de56b131c..8c949c8ffba3 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.61 + 9.0.62 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.61.0 + 9.0.62.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 328e27f2578f..73eff6f03bc4 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.61 + 9.0.62 CFBundleSignature ???? CFBundleVersion - 9.0.61.0 + 9.0.62.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 7efe1888d4ae..57ea8674815d 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.61 + 9.0.62 CFBundleVersion - 9.0.61.0 + 9.0.62.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 18d030a38ce9..6efc4d1a5eff 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1766,7 +1766,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-pager-view (6.4.1): + - react-native-pager-view (6.5.0): - DoubleConversion - glog - hermes-engine @@ -1779,7 +1779,7 @@ PODS: - React-featureflags - React-graphics - React-ImageManager - - react-native-pager-view/common (= 6.4.1) + - react-native-pager-view/common (= 6.5.0) - React-NativeModulesApple - React-RCTFabric - React-rendererdebug @@ -1788,7 +1788,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-pager-view/common (6.4.1): + - react-native-pager-view/common (6.5.0): - DoubleConversion - glog - hermes-engine @@ -3217,7 +3217,7 @@ SPEC CHECKSUMS: react-native-keyboard-controller: 902c07f41a415b632583b384427a71770a8b02a3 react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d react-native-netinfo: fb5112b1fa754975485884ae85a3fb6a684f49d5 - react-native-pager-view: 94195f1bf32e7f78359fa20057c97e632364a08b + react-native-pager-view: c64a744211a46202619a77509f802765d1659dba react-native-pdf: dd6ae39a93607a80919bef9f3499e840c693989d react-native-performance: 3c608307be10964f8a97d3af462f37125b6d8fa5 react-native-plaid-link-sdk: f91a22b45b7c3d4cd6c47273200dc57df35068b0 diff --git a/package-lock.json b/package-lock.json index b318c1a7f31c..fffe0be3b477 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.61-0", + "version": "9.0.62-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.61-0", + "version": "9.0.62-0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -95,8 +95,8 @@ "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.78", - "react-native-pager-view": "6.4.1", + "react-native-onyx": "2.0.79", + "react-native-pager-view": "6.5.0", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.10.0", @@ -104,7 +104,7 @@ "react-native-plaid-link-sdk": "11.11.0", "react-native-qrcode-svg": "6.3.11", "react-native-quick-sqlite": "git+https://github.com/margelo/react-native-quick-sqlite#99f34ebefa91698945f3ed26622e002bd79489e0", - "react-native-reanimated": "3.15.3", + "react-native-reanimated": "3.16.1", "react-native-release-profiler": "^0.2.1", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.10.9", @@ -35504,9 +35504,9 @@ } }, "node_modules/react-native-onyx": { - "version": "2.0.78", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.78.tgz", - "integrity": "sha512-YL6Zk470TOjhpccf2wqAi4bvvJyDrQccTAYsz+woZu+rKr74UX693U2EhP8ncZ7+dzgfS7zGKep2mwKVesfiWw==", + "version": "2.0.79", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.79.tgz", + "integrity": "sha512-1rbhDdufp2vXmw3ttCtEXPK3p6F94nqKgqqvcRIqo6xLzgTI74rdm3Kqiyx4r6tYCTjN/TfmI/KLV+2EUShJZQ==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", @@ -35541,9 +35541,10 @@ } }, "node_modules/react-native-pager-view": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-6.4.1.tgz", - "integrity": "sha512-HnDxXTRHnR6WJ/vnOitv0C32KG9MJjxLnxswuQlBJmQ7RxF2GWOHSPIRAdZ9fLxdLstV38z9Oz1C95+t+yXkcg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-6.5.0.tgz", + "integrity": "sha512-Buqc5mjCgIem7aIQU/seMKqhQr98YvBqRNilnoBb8hNGhCaQTE2yvYDwUhOytowyOkjCstLv7Fap2jcLm/k3Bw==", + "license": "MIT", "peerDependencies": { "react": "*", "react-native": "*" @@ -35627,9 +35628,9 @@ } }, "node_modules/react-native-reanimated": { - "version": "3.15.3", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.15.3.tgz", - "integrity": "sha512-5QBk/7PZvZ98Adxm4MRyglwzsRzReTQIe4Hd2wbBBAZ68IC4OYKvsc8cPEjgx3/1mG8HgHFYhbcDe5U2RjeFqw==", + "version": "3.16.1", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.16.1.tgz", + "integrity": "sha512-Wnbo7toHZ6kPLAD8JWKoKCTfNoqYOMW5vUEP76Rr4RBmJCrdXj6oauYP0aZnZq8NCbiP5bwwu7+RECcWtoetnQ==", "license": "MIT", "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", diff --git a/package.json b/package.json index 8d6612308505..f9fe15eadf93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.61-0", + "version": "9.0.62-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.", @@ -152,8 +152,8 @@ "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.78", - "react-native-pager-view": "6.4.1", + "react-native-onyx": "2.0.79", + "react-native-pager-view": "6.5.0", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.10.0", @@ -161,7 +161,7 @@ "react-native-plaid-link-sdk": "11.11.0", "react-native-qrcode-svg": "6.3.11", "react-native-quick-sqlite": "git+https://github.com/margelo/react-native-quick-sqlite#99f34ebefa91698945f3ed26622e002bd79489e0", - "react-native-reanimated": "3.15.3", + "react-native-reanimated": "3.16.1", "react-native-release-profiler": "^0.2.1", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.10.9", diff --git a/patches/react-native-pager-view+6.4.1.patch b/patches/react-native-pager-view+6.4.1.patch deleted file mode 100644 index 64b2b580ecd3..000000000000 --- a/patches/react-native-pager-view+6.4.1.patch +++ /dev/null @@ -1,73 +0,0 @@ ---- a/node_modules/react-native-pager-view/ios/Fabric/RNCPagerViewComponentView.mm -+++ b/node_modules/react-native-pager-view/ios/Fabric/RNCPagerViewComponentView.mm -@@ -195,13 +195,10 @@ -(void)scrollViewDidScroll:(UIScrollView *)scrollView { - - strongEventEmitter.onPageScroll(RNCViewPagerEventEmitter::OnPageScroll{.position = static_cast(position), .offset = offset}); - -- //This is temporary workaround to allow animations based on onPageScroll event -- //until Fabric implements proper NativeAnimationDriver -- RCTBridge *bridge = [RCTBridge currentBridge]; -- -- if (bridge) { -- [bridge.eventDispatcher sendEvent:[[RCTOnPageScrollEvent alloc] initWithReactTag:[NSNumber numberWithInt:self.tag] position:@(position) offset:@(offset)]]; -- } -+ NSDictionary *userInfo = [NSDictionary dictionaryWithObjectsAndKeys:[[RCTOnPageScrollEvent alloc] initWithReactTag:[NSNumber numberWithInt:self.tag] position:@(position) offset:@(offset)], @"event", nil]; -+ [[NSNotificationCenter defaultCenter] postNotificationName:@"RCTNotifyEventDispatcherObserversOfEvent_DEPRECATED" -+ object:nil -+ userInfo:userInfo]; - } - - #pragma mark - Internal methods -diff --git a/node_modules/react-native-pager-view/ios/LEGACY/Fabric/LEGACY_RNCPagerViewComponentView.mm b/node_modules/react-native-pager-view/ios/LEGACY/Fabric/LEGACY_RNCPagerViewComponentView.mm -index 7608645..84f6f60 100644 ---- a/node_modules/react-native-pager-view/ios/LEGACY/Fabric/LEGACY_RNCPagerViewComponentView.mm -+++ b/node_modules/react-native-pager-view/ios/LEGACY/Fabric/LEGACY_RNCPagerViewComponentView.mm -@@ -363,14 +363,10 @@ - (void)scrollViewDidScroll:(UIScrollView *)scrollView { - int eventPosition = (int) position; - strongEventEmitter.onPageScroll(LEGACY_RNCViewPagerEventEmitter::OnPageScroll{.position = static_cast(eventPosition), .offset = interpolatedOffset}); - -- //This is temporary workaround to allow animations based on onPageScroll event -- //until Fabric implements proper NativeAnimationDriver -- RCTBridge *bridge = [RCTBridge currentBridge]; -- -- if (bridge) { -- [bridge.eventDispatcher sendEvent:[[RCTOnPageScrollEvent alloc] initWithReactTag:[NSNumber numberWithInt:self.tag] position:@(position) offset:@(interpolatedOffset)]]; -- } -- -+ NSDictionary *userInfo = [NSDictionary dictionaryWithObjectsAndKeys:[[RCTOnPageScrollEvent alloc] initWithReactTag:[NSNumber numberWithInt:self.tag] position:@(position) offset:@(interpolatedOffset)], @"event", nil]; -+ [[NSNotificationCenter defaultCenter] postNotificationName:@"RCTNotifyEventDispatcherObserversOfEvent_DEPRECATED" -+ object:nil -+ userInfo:userInfo]; - } - - -diff --git a/node_modules/react-native-pager-view/ios/LEGACY/LEGACY_RNCPagerView.m b/node_modules/react-native-pager-view/ios/LEGACY/LEGACY_RNCPagerView.m -index 5f6c535..fd6c2a1 100644 ---- a/node_modules/react-native-pager-view/ios/LEGACY/LEGACY_RNCPagerView.m -+++ b/node_modules/react-native-pager-view/ios/LEGACY/LEGACY_RNCPagerView.m -@@ -1,5 +1,5 @@ - #import "LEGACY_RNCPagerView.h" --#import "React/RCTLog.h" -+#import - #import - - #import "UIViewController+CreateExtension.h" -diff --git a/node_modules/react-native-pager-view/ios/RNCPagerView.m b/node_modules/react-native-pager-view/ios/RNCPagerView.m -index 584aada..978496f 100644 ---- a/node_modules/react-native-pager-view/ios/RNCPagerView.m -+++ b/node_modules/react-native-pager-view/ios/RNCPagerView.m -@@ -1,12 +1,12 @@ - - #import "RNCPagerView.h" --#import "React/RCTLog.h" -+#import - #import - - #import "UIViewController+CreateExtension.h" - #import "RCTOnPageScrollEvent.h" - #import "RCTOnPageScrollStateChanged.h" --#import "React/RCTUIManagerObserverCoordinator.h" -+#import - #import "RCTOnPageSelected.h" - #import - diff --git a/patches/react-native-pager-view+6.5.0.patch b/patches/react-native-pager-view+6.5.0.patch new file mode 100644 index 000000000000..8488bf9c586e --- /dev/null +++ b/patches/react-native-pager-view+6.5.0.patch @@ -0,0 +1,30 @@ +diff --git a/node_modules/react-native-pager-view/ios/LEGACY/LEGACY_RNCPagerView.m b/node_modules/react-native-pager-view/ios/LEGACY/LEGACY_RNCPagerView.m +index 5f6c535..fd6c2a1 100644 +--- a/node_modules/react-native-pager-view/ios/LEGACY/LEGACY_RNCPagerView.m ++++ b/node_modules/react-native-pager-view/ios/LEGACY/LEGACY_RNCPagerView.m +@@ -1,5 +1,5 @@ + #import "LEGACY_RNCPagerView.h" +-#import "React/RCTLog.h" ++#import + #import + + #import "UIViewController+CreateExtension.h" +diff --git a/node_modules/react-native-pager-view/ios/RNCPagerView.m b/node_modules/react-native-pager-view/ios/RNCPagerView.m +index 584aada..978496f 100644 +--- a/node_modules/react-native-pager-view/ios/RNCPagerView.m ++++ b/node_modules/react-native-pager-view/ios/RNCPagerView.m +@@ -1,12 +1,12 @@ + + #import "RNCPagerView.h" +-#import "React/RCTLog.h" ++#import + #import + + #import "UIViewController+CreateExtension.h" + #import "RCTOnPageScrollEvent.h" + #import "RCTOnPageScrollStateChanged.h" +-#import "React/RCTUIManagerObserverCoordinator.h" ++#import + #import "RCTOnPageSelected.h" + #import + diff --git a/patches/react-native-reanimated+3.15.3+003+fixNullViewTag.patch b/patches/react-native-reanimated+3.15.3+003+fixNullViewTag.patch deleted file mode 100644 index ca982c6f8036..000000000000 --- a/patches/react-native-reanimated+3.15.3+003+fixNullViewTag.patch +++ /dev/null @@ -1,15 +0,0 @@ -diff --git a/node_modules/react-native-reanimated/src/createAnimatedComponent/createAnimatedComponent.tsx b/node_modules/react-native-reanimated/src/createAnimatedComponent/createAnimatedComponent.tsx -index 577b4a7..c60f0f8 100644 ---- a/node_modules/react-native-reanimated/src/createAnimatedComponent/createAnimatedComponent.tsx -+++ b/node_modules/react-native-reanimated/src/createAnimatedComponent/createAnimatedComponent.tsx -@@ -481,7 +481,9 @@ export function createAnimatedComponent( - ? (ref as HTMLElement) - : findNodeHandle(ref as Component); - -- this._componentViewTag = tag as number; -+ if (tag !== null) { -+ this._componentViewTag = tag as number; -+ } - - const { layout, entering, exiting, sharedTransitionTag } = this.props; - if ( diff --git a/patches/react-native-reanimated+3.15.3+001+hybrid-app.patch b/patches/react-native-reanimated+3.16.1+001+hybrid-app.patch similarity index 91% rename from patches/react-native-reanimated+3.15.3+001+hybrid-app.patch rename to patches/react-native-reanimated+3.16.1+001+hybrid-app.patch index 3b40360d5860..835df1f034a9 100644 --- a/patches/react-native-reanimated+3.15.3+001+hybrid-app.patch +++ b/patches/react-native-reanimated+3.16.1+001+hybrid-app.patch @@ -1,9 +1,9 @@ diff --git a/node_modules/react-native-reanimated/scripts/reanimated_utils.rb b/node_modules/react-native-reanimated/scripts/reanimated_utils.rb -index af0935f..ccd2a9e 100644 +index 9fc7b15..e453d84 100644 --- a/node_modules/react-native-reanimated/scripts/reanimated_utils.rb +++ b/node_modules/react-native-reanimated/scripts/reanimated_utils.rb @@ -17,7 +17,11 @@ def find_config() - :react_native_common_dir => nil, + :react_native_reanimated_dir_from_pods_root => nil, } - react_native_node_modules_dir = File.join(File.dirname(`cd "#{Pod::Config.instance.installation_root.to_s}" && node --print "require.resolve('react-native/package.json')"`), '..') diff --git a/patches/react-native-reanimated+3.15.3+002+dontWhitelistTextProp.patch b/patches/react-native-reanimated+3.16.1+002+dontWhitelistTextProp.patch similarity index 100% rename from patches/react-native-reanimated+3.15.3+002+dontWhitelistTextProp.patch rename to patches/react-native-reanimated+3.16.1+002+dontWhitelistTextProp.patch diff --git a/src/CONST.ts b/src/CONST.ts index 4e873163cc95..4b2b66ab5a2d 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -70,6 +70,7 @@ const selectableOnboardingChoices = { } as const; const backendOnboardingChoices = { + ADMIN: 'newDotAdmin', SUBMIT: 'newDotSubmit', } as const; @@ -90,14 +91,14 @@ const signupQualifiers = { SMB: 'smb', } as const; -const selfGuidedTourTask: OnboardingTaskType = { +const selfGuidedTourTask: OnboardingTask = { type: 'viewTour', autoCompleted: false, title: 'Take a 2-minute tour', description: ({navatticURL}) => `[Take a self-guided product tour](${navatticURL}) and learn about everything Expensify has to offer.`, }; -const onboardingEmployerOrSubmitMessage: OnboardingMessageType = { +const onboardingEmployerOrSubmitMessage: OnboardingMessage = { message: 'Getting paid back is as easy as sending a message. Let’s go over the basics.', video: { url: `${CLOUDFRONT_URL}/videos/guided-setup-get-paid-back-v2.mp4`, @@ -142,7 +143,7 @@ const onboardingEmployerOrSubmitMessage: OnboardingMessageType = { ], }; -const combinedTrackSubmitOnboardingEmployerOrSubmitMessage: OnboardingMessageType = { +const combinedTrackSubmitOnboardingEmployerOrSubmitMessage: OnboardingMessage = { ...onboardingEmployerOrSubmitMessage, tasks: [ { @@ -180,7 +181,7 @@ const combinedTrackSubmitOnboardingEmployerOrSubmitMessage: OnboardingMessageTyp ], }; -const onboardingPersonalSpendMessage: OnboardingMessageType = { +const onboardingPersonalSpendMessage: OnboardingMessage = { message: 'Here’s how to track your spend in a few clicks.', video: { url: `${CLOUDFRONT_URL}/videos/guided-setup-track-personal-v2.mp4`, @@ -208,7 +209,7 @@ const onboardingPersonalSpendMessage: OnboardingMessageType = { }, ], }; -const combinedTrackSubmitOnboardingPersonalSpendMessage: OnboardingMessageType = { +const combinedTrackSubmitOnboardingPersonalSpendMessage: OnboardingMessage = { ...onboardingPersonalSpendMessage, tasks: [ { @@ -231,11 +232,11 @@ const combinedTrackSubmitOnboardingPersonalSpendMessage: OnboardingMessageType = ], }; -type OnboardingPurposeType = ValueOf; +type OnboardingPurpose = ValueOf; -type OnboardingCompanySizeType = ValueOf; +type OnboardingCompanySize = ValueOf; -type OnboardingAccountingType = ValueOf | null; +type OnboardingAccounting = ValueOf | null; const onboardingInviteTypes = { IOU: 'iou', @@ -251,9 +252,9 @@ const onboardingCompanySize = { LARGE: '1001+', } as const; -type OnboardingInviteType = ValueOf; +type OnboardingInvite = ValueOf; -type OnboardingTaskType = { +type OnboardingTask = { type: string; autoCompleted: boolean; title: @@ -278,10 +279,17 @@ type OnboardingTaskType = { ) => string); }; -type OnboardingMessageType = { +type OnboardingMessage = { + /** Text message that will be displayed first */ message: string; + + /** Video object to be displayed after initial description message */ video?: Video; - tasks: OnboardingTaskType[]; + + /** List of tasks connected with the message, they will have a checkbox and a separate report for more information */ + tasks: OnboardingTask[]; + + /** Type of task described in a string format */ type?: string; }; @@ -5055,18 +5063,67 @@ const CONST = { }, ], }, + [onboardingChoices.ADMIN]: { + message: "As an admin, learn how to manage your team's workspace and submit expenses yourself.", + video: { + url: `${CLOUDFRONT_URL}/videos/guided-setup-manage-team-v2.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/guided-setup-manage-team.jpg`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'meetSetupSpecialist', + autoCompleted: false, + title: 'Meet your setup specialist', + description: + '*Meet your setup specialist* who can answer any questions as you get started with Expensify. Yes, a real human!' + + '\n' + + 'Chat with them in your #admins room or schedule a call today.', + }, + { + type: 'reviewWorkspaceSettings', + autoCompleted: false, + title: 'Review your workspace settings', + description: + "Here's how to review and update your workspace settings:" + + '\n' + + '1. Click your profile picture.' + + '2. Click *Workspaces* > [Your workspace].' + + '\n' + + "Make any changes there and we'll track them in the #admins room.", + }, + { + type: 'submitExpense', + autoCompleted: false, + title: 'Submit an expense', + description: + '*Submit an expense* by entering an amount or scanning a receipt.\n' + + '\n' + + 'Here’s how to submit an expense:\n' + + '\n' + + '1. Click the green *+* button.\n' + + '2. Choose *Submit expense*.\n' + + '3. Enter an amount or scan a receipt.\n' + + '4. Add your reimburser to the request.\n' + + '\n' + + 'Then, send your request and wait for that sweet “Cha-ching!” when it’s complete.', + }, + ], + }, [onboardingChoices.LOOKING_AROUND]: { message: "Expensify is best known for expense and corporate card management, but we do a lot more than that. Let me know what you're interested in and I'll help get you started.", tasks: [], }, - } satisfies Record, + } satisfies Record, COMBINED_TRACK_SUBMIT_ONBOARDING_MESSAGES: { [combinedTrackSubmitOnboardingChoices.PERSONAL_SPEND]: combinedTrackSubmitOnboardingPersonalSpendMessage, [combinedTrackSubmitOnboardingChoices.EMPLOYER]: combinedTrackSubmitOnboardingEmployerOrSubmitMessage, [combinedTrackSubmitOnboardingChoices.SUBMIT]: combinedTrackSubmitOnboardingEmployerOrSubmitMessage, - } satisfies Record, OnboardingMessageType>, + } satisfies Record, OnboardingMessage>, REPORT_FIELD_TITLE_FIELD_ID: 'text_title', @@ -6229,14 +6286,14 @@ export type { Country, IOUAction, IOUType, - OnboardingPurposeType, - OnboardingCompanySizeType, + OnboardingPurpose, + OnboardingCompanySize, IOURequestType, SubscriptionType, FeedbackSurveyOptionID, CancellationType, - OnboardingInviteType, - OnboardingAccountingType, + OnboardingInvite, + OnboardingAccounting, }; export default CONST; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index c5ec21b8b1c2..b4510a2faeed 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1,6 +1,6 @@ import type {ValueOf} from 'type-fest'; import type CONST from './CONST'; -import type {OnboardingCompanySizeType, OnboardingPurposeType} from './CONST'; +import type {OnboardingCompanySize} from './CONST'; import type Platform from './libs/getPlatform/types'; import type * as FormTypes from './types/form'; import type * as OnyxTypes from './types/onyx'; @@ -982,9 +982,9 @@ type OnyxValuesMapping = { [ONYXKEYS.MAX_CANVAS_AREA]: number; [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; - [ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: OnboardingPurposeType; - [ONYXKEYS.ONBOARDING_COMPANY_SIZE]: OnboardingCompanySizeType; - [ONYXKEYS.ONBOARDING_CUSTOM_CHOICES]: OnboardingPurposeType[] | []; + [ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: OnyxTypes.OnboardingPurpose; + [ONYXKEYS.ONBOARDING_COMPANY_SIZE]: OnboardingCompanySize; + [ONYXKEYS.ONBOARDING_CUSTOM_CHOICES]: OnyxTypes.OnboardingPurpose[] | []; [ONYXKEYS.ONBOARDING_ERROR_MESSAGE]: string; [ONYXKEYS.ONBOARDING_POLICY_ID]: string; [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index d9c4f7e93fbe..b578da242d88 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -46,7 +46,7 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi const styles = useThemeStyles(); const {isFullScreenRef} = useFullScreenContext(); const scrollRef = useAnimatedRef>>(); - const nope = useSharedValue(false); + const isPagerScrolling = useSharedValue(false); const pagerRef = useRef(null); const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, {canEvict: false}); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {canEvict: false}); @@ -61,7 +61,7 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi const [attachments, setAttachments] = useState([]); const [activeSource, setActiveSource] = useState(source); const {shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows} = useCarouselArrows(); - const {handleTap, handleScaleChange, scale} = useCarouselContextEvents(setShouldShowArrows); + const {handleTap, handleScaleChange, isScrollEnabled} = useCarouselContextEvents(setShouldShowArrows); useEffect(() => { if (!canUseTouchScreen) { @@ -200,13 +200,13 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi pagerItems: [{source, index: 0, isActive: true}], activePage: 0, pagerRef, - isPagerScrolling: nope, - isScrollEnabled: nope, + isPagerScrolling, + isScrollEnabled, onTap: handleTap, onScaleChanged: handleScaleChange, onSwipeDown: onClose, }), - [source, nope, handleTap, handleScaleChange, onClose], + [source, isPagerScrolling, isScrollEnabled, handleTap, handleScaleChange, onClose], ); /** Defines how a single attachment should be rendered */ @@ -229,14 +229,18 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi Gesture.Pan() .enabled(canUseTouchScreen) .onUpdate(({translationX}) => { - if (scale.current !== 1) { + if (!isScrollEnabled.value) { return; } + if (translationX !== 0) { + isPagerScrolling.value = true; + } + scrollTo(scrollRef, page * cellWidth - translationX, 0, false); }) .onEnd(({translationX, velocityX}) => { - if (scale.current !== 1) { + if (!isScrollEnabled.value) { return; } @@ -253,11 +257,12 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi newIndex = Math.min(attachments.length - 1, Math.max(0, page + delta)); } + isPagerScrolling.value = false; scrollTo(scrollRef, newIndex * cellWidth, 0, true); }) // eslint-disable-next-line react-compiler/react-compiler .withRef(pagerRef as MutableRefObject), - [attachments.length, canUseTouchScreen, cellWidth, page, scale, scrollRef], + [attachments.length, canUseTouchScreen, cellWidth, page, isScrollEnabled, scrollRef, isPagerScrolling], ); return ( diff --git a/src/components/AvatarCropModal/AvatarCropModal.tsx b/src/components/AvatarCropModal/AvatarCropModal.tsx index dca0d08d11d5..7911255ba49c 100644 --- a/src/components/AvatarCropModal/AvatarCropModal.tsx +++ b/src/components/AvatarCropModal/AvatarCropModal.tsx @@ -4,7 +4,7 @@ import type {LayoutChangeEvent} from 'react-native'; import {Gesture, GestureHandlerRootView} from 'react-native-gesture-handler'; import type {GestureUpdateEvent, PanGestureChangeEventPayload, PanGestureHandlerEventPayload} from 'react-native-gesture-handler'; import ImageSize from 'react-native-image-size'; -import {interpolate, runOnUI, useSharedValue, useWorkletCallback} from 'react-native-reanimated'; +import {interpolate, runOnUI, useSharedValue} from 'react-native-reanimated'; import Button from '@components/Button'; import HeaderGap from '@components/HeaderGap'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -144,12 +144,18 @@ function AvatarCropModal({imageUri = '', imageName = '', imageType = '', onClose /** * Validates that value is within the provided mix/max range. */ - const clamp = useWorkletCallback((value: number, [min, max]) => interpolate(value, [min, max], [min, max], 'clamp'), []); + const clamp = useCallback((value: number, [min, max]: [number, number]) => { + 'worklet'; + + return interpolate(value, [min, max], [min, max], 'clamp'); + }, []); /** * Returns current image size taking into account scale and rotation. */ - const getDisplayedImageSize = useWorkletCallback(() => { + const getDisplayedImageSize = useCallback(() => { + 'worklet'; + let height = imageContainerSize * scale.value; let width = imageContainerSize * scale.value; @@ -162,28 +168,33 @@ function AvatarCropModal({imageUri = '', imageName = '', imageType = '', onClose } return {height, width}; - }, [imageContainerSize, scale]); + }, [imageContainerSize, scale, originalImageWidth, originalImageHeight]); /** * Validates the offset to prevent overflow, and updates the image offset. */ - const updateImageOffset = useWorkletCallback( + const updateImageOffset = useCallback( (offsetX: number, offsetY: number) => { + 'worklet'; + const {height, width} = getDisplayedImageSize(); const maxOffsetX = (width - imageContainerSize) / 2; const maxOffsetY = (height - imageContainerSize) / 2; translateX.value = clamp(offsetX, [maxOffsetX * -1, maxOffsetX]); translateY.value = clamp(offsetY, [maxOffsetY * -1, maxOffsetY]); + // eslint-disable-next-line react-compiler/react-compiler prevMaxOffsetX.value = maxOffsetX; prevMaxOffsetY.value = maxOffsetY; }, - [imageContainerSize, scale, clamp], + [getDisplayedImageSize, imageContainerSize, translateX, translateY, prevMaxOffsetX, prevMaxOffsetY, clamp], ); - const newScaleValue = useWorkletCallback((newSliderValue: number, containerSize: number) => { + const newScaleValue = useCallback((newSliderValue: number, containerSize: number) => { + 'worklet'; + const {MAX_SCALE, MIN_SCALE} = CONST.AVATAR_CROP_MODAL; return (newSliderValue / containerSize) * (MAX_SCALE - MIN_SCALE) + MIN_SCALE; - }); + }, []); /** * Calculates new x & y image translate value on image panning diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 991aaea86513..6b625f312709 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -11,6 +11,17 @@ import VisaCompanyCardDetail from '@assets/images/companyCards/card-visa.svg'; import WellsFargoCompanyCardDetail from '@assets/images/companyCards/card-wellsfargo.svg'; import OtherCompanyCardDetail from '@assets/images/companyCards/card=-generic.svg'; import CompanyCardsEmptyState from '@assets/images/companyCards/emptystate__card-pos.svg'; +import AmexCardCompanyCardDetailLarge from '@assets/images/companyCards/large/card-amex-large.svg'; +import BankOfAmericaCompanyCardDetailLarge from '@assets/images/companyCards/large/card-bofa-large.svg'; +import BrexCompanyCardDetailLarge from '@assets/images/companyCards/large/card-brex-large.svg'; +import CapitalOneCompanyCardDetailLarge from '@assets/images/companyCards/large/card-capital_one-large.svg'; +import ChaseCompanyCardDetailLarge from '@assets/images/companyCards/large/card-chase-large.svg'; +import CitibankCompanyCardDetailLarge from '@assets/images/companyCards/large/card-citi-large.svg'; +import OtherCompanyCardDetailLarge from '@assets/images/companyCards/large/card-generic-large.svg'; +import MasterCardCompanyCardDetailLarge from '@assets/images/companyCards/large/card-mastercard-large.svg'; +import StripeCompanyCardDetailLarge from '@assets/images/companyCards/large/card-stripe-large.svg'; +import VisaCompanyCardDetailLarge from '@assets/images/companyCards/large/card-visa-large.svg'; +import WellsFargoCompanyCardDetailLarge from '@assets/images/companyCards/large/card-wellsfargo-large.svg'; import MasterCardCompanyCards from '@assets/images/companyCards/mastercard.svg'; import PendingBank from '@assets/images/companyCards/pending-bank.svg'; import CompanyCardsPendingState from '@assets/images/companyCards/pendingstate_laptop-with-hourglass-and-cards.svg'; @@ -266,4 +277,15 @@ export { StripeCompanyCardDetail, WellsFargoCompanyCardDetail, PerDiem, + AmexCardCompanyCardDetailLarge, + BankOfAmericaCompanyCardDetailLarge, + BrexCompanyCardDetailLarge, + CapitalOneCompanyCardDetailLarge, + ChaseCompanyCardDetailLarge, + CitibankCompanyCardDetailLarge, + OtherCompanyCardDetailLarge, + MasterCardCompanyCardDetailLarge, + StripeCompanyCardDetailLarge, + VisaCompanyCardDetailLarge, + WellsFargoCompanyCardDetailLarge, }; diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index 3c40210a5d99..0fe2a1542ca3 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -37,7 +37,7 @@ function OptionRowLHNData({ const optionItemRef = useRef(); - const shouldDisplayViolations = ReportUtils.shouldDisplayTransactionThreadViolations(fullReport, transactionViolations, parentReportAction); + const shouldDisplayViolations = ReportUtils.shouldDisplayViolationsRBRInLHN(fullReport, transactionViolations); const shouldDisplayReportViolations = ReportUtils.isReportOwner(fullReport) && ReportUtils.hasReportViolations(reportID); const optionItem = useMemo(() => { diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx index c5a77f9d5ec4..9e1b007321cc 100644 --- a/src/components/Lightbox/index.tsx +++ b/src/components/Lightbox/index.tsx @@ -50,6 +50,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan * we need to create a shared value that can be used in the render function. */ const isPagerScrollingFallback = useSharedValue(false); + const isScrollingEnabledFallback = useSharedValue(false); const {isOffline} = useNetwork(); const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); @@ -63,12 +64,14 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan onScaleChanged: onScaleChangedContext, onSwipeDown, pagerRef, + isScrollEnabled, } = useMemo(() => { if (attachmentCarouselPagerContext === null) { return { isUsedInCarousel: false, isSingleCarouselItem: true, isPagerScrolling: isPagerScrollingFallback, + isScrollEnabled: isScrollingEnabledFallback, page: 0, activePage: 0, onTap: () => {}, @@ -85,7 +88,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan isSingleCarouselItem: attachmentCarouselPagerContext.pagerItems.length === 1, page: foundPage, }; - }, [attachmentCarouselPagerContext, isPagerScrollingFallback, uri]); + }, [attachmentCarouselPagerContext, isPagerScrollingFallback, isScrollingEnabledFallback, uri]); /** Whether the Lightbox is used within an attachment carousel and there are more than one page in the carousel */ const hasSiblingCarouselItems = isUsedInCarousel && !isSingleCarouselItem; @@ -215,7 +218,9 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan contentSize={contentSize} zoomRange={zoomRange} pagerRef={pagerRef} + isUsedInCarousel={isUsedInCarousel} shouldDisableTransformationGestures={isPagerScrolling} + isPagerScrollEnabled={isScrollEnabled} onTap={onTap} onScaleChanged={scaleChange} onSwipeDown={onSwipeDown} diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 8bf07e2d3a02..cdcd09e6a152 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -169,9 +169,9 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea shouldShowExportIntegrationButton; const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport); const formattedAmount = CurrencyUtils.convertToDisplayString(reimbursableSpend, moneyRequestReport?.currency); - const [nonHeldAmount, fullAmount] = ReportUtils.getNonHeldAndFullAmount(moneyRequestReport, policy); + const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = ReportUtils.getNonHeldAndFullAmount(moneyRequestReport, policy); const isAnyTransactionOnHold = ReportUtils.hasHeldExpenses(moneyRequestReport?.reportID); - const displayedAmount = isAnyTransactionOnHold && canAllowSettlement ? nonHeldAmount : formattedAmount; + const displayedAmount = isAnyTransactionOnHold && canAllowSettlement && hasValidNonHeldAmount ? nonHeldAmount : formattedAmount; const isMoreContentShown = shouldShowNextStep || shouldShowStatusBar || (shouldShowAnyButton && shouldUseNarrowLayout); const {isDelegateAccessRestricted, delegatorEmail} = useDelegateUserDetails(); const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); @@ -479,7 +479,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea )} {isHoldMenuVisible && requestType !== undefined && ( setIsHoldMenuVisible(false)} diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index a0143f87e789..2f1d459e369a 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, useEffect, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useContext, 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,6 +46,7 @@ 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 */ @@ -194,7 +195,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; @@ -824,7 +825,7 @@ function MoneyRequestConfirmationList({ }, [routeError, isTypeSplit, shouldShowReadOnlySplits, debouncedFormError, formError, translate]); const footerContent = useMemo(() => { - if (isReadOnly) { + if (isReadOnly || isKeyboardShown || isWindowHeightReducedByKeyboard) { return; } @@ -876,7 +877,20 @@ function MoneyRequestConfirmationList({ {button} ); - }, [isReadOnly, iouType, confirm, bankAccountRoute, iouCurrencyCode, policyID, splitOrRequestOptions, styles.ph1, styles.mb2, errorMessage]); + }, [ + isReadOnly, + iouType, + confirm, + bankAccountRoute, + iouCurrencyCode, + policyID, + splitOrRequestOptions, + styles.ph1, + styles.mb2, + errorMessage, + isKeyboardShown, + isWindowHeightReducedByKeyboard, + ]); const listFooterContent = ( !state, false); - const shouldShowTags = useMemo(() => isPolicyExpenseChat && OptionsListUtils.hasEnabledTags(policyTagLists), [isPolicyExpenseChat, policyTagLists]); + const shouldShowTags = useMemo(() => isPolicyExpenseChat && TagsOptionsListUtils.hasEnabledTags(policyTagLists), [isPolicyExpenseChat, policyTagLists]); const isMultilevelTags = useMemo(() => PolicyUtils.isMultiLevelTags(policyTags), [policyTags]); const shouldShowAttendees = useMemo(() => TransactionUtils.shouldShowAttendees(iouType, policy), [iouType, policy]); diff --git a/src/components/MultiGestureCanvas/index.tsx b/src/components/MultiGestureCanvas/index.tsx index ff9566839d59..cfbd5215f5cc 100644 --- a/src/components/MultiGestureCanvas/index.tsx +++ b/src/components/MultiGestureCanvas/index.tsx @@ -1,12 +1,12 @@ import type {ForwardedRef} from 'react'; -import React, {useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; import type {GestureType} from 'react-native-gesture-handler'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import type {GestureRef} from 'react-native-gesture-handler/lib/typescript/handlers/gestures/gesture'; import type PagerView from 'react-native-pager-view'; import type {SharedValue} from 'react-native-reanimated'; -import Animated, {cancelAnimation, runOnUI, useAnimatedStyle, useDerivedValue, useSharedValue, useWorkletCallback, withSpring} from 'react-native-reanimated'; +import Animated, {cancelAnimation, runOnUI, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring} from 'react-native-reanimated'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; @@ -40,9 +40,15 @@ type MultiGestureCanvasProps = ChildrenProps & { /** A shared value of type boolean, that indicates disabled the transformation gestures (pinch, pan, double tap) */ shouldDisableTransformationGestures?: SharedValue; + /** A shared value to enable/disable the pager scroll */ + isPagerScrollEnabled: SharedValue; + /** If there is a pager wrapping the canvas, we need to disable the pan gesture in case the pager is swiping */ pagerRef?: ForwardedRef; // TODO: For TS migration: Exclude + /** Whether the component is being used inside a carousel */ + isUsedInCarousel: boolean; + /** Handles scale changed event */ onScaleChanged?: OnScaleChangedCallback; @@ -62,7 +68,9 @@ function MultiGestureCanvas({ isActive = true, children, pagerRef, + isUsedInCarousel, shouldDisableTransformationGestures: shouldDisableTransformationGesturesProp, + isPagerScrollEnabled, onTap, onScaleChanged, onSwipeDown, @@ -107,47 +115,65 @@ function MultiGestureCanvas({ const offsetX = useSharedValue(0); const offsetY = useSharedValue(0); + useAnimatedReaction( + () => isSwipingDownToClose.value, + (current) => { + if (!isUsedInCarousel) { + return; + } + // eslint-disable-next-line react-compiler/react-compiler, no-param-reassign + isPagerScrollEnabled.value = !current; + }, + ); + /** * Stops any currently running decay animation from panning */ - const stopAnimation = useWorkletCallback(() => { + const stopAnimation = useCallback(() => { + 'worklet'; + cancelAnimation(offsetX); cancelAnimation(offsetY); - }); + }, [offsetX, offsetY]); /** * Resets the canvas to the initial state and animates back smoothly */ - const reset = useWorkletCallback((animated: boolean, callback?: () => void) => { - stopAnimation(); - - // eslint-disable-next-line react-compiler/react-compiler - offsetX.value = 0; - offsetY.value = 0; - pinchScale.value = 1; - - if (animated) { - panTranslateX.value = withSpring(0, SPRING_CONFIG); - panTranslateY.value = withSpring(0, SPRING_CONFIG); - pinchTranslateX.value = withSpring(0, SPRING_CONFIG); - pinchTranslateY.value = withSpring(0, SPRING_CONFIG); - zoomScale.value = withSpring(1, SPRING_CONFIG, callback); - - return; - } - - panTranslateX.value = 0; - panTranslateY.value = 0; - pinchTranslateX.value = 0; - pinchTranslateY.value = 0; - zoomScale.value = 1; - - if (callback === undefined) { - return; - } - - callback(); - }); + const reset = useCallback( + (animated: boolean, callback?: () => void) => { + 'worklet'; + + stopAnimation(); + + // eslint-disable-next-line react-compiler/react-compiler + offsetX.value = 0; + offsetY.value = 0; + pinchScale.value = 1; + + if (animated) { + panTranslateX.value = withSpring(0, SPRING_CONFIG); + panTranslateY.value = withSpring(0, SPRING_CONFIG); + pinchTranslateX.value = withSpring(0, SPRING_CONFIG); + pinchTranslateY.value = withSpring(0, SPRING_CONFIG); + zoomScale.value = withSpring(1, SPRING_CONFIG, callback); + + return; + } + + panTranslateX.value = 0; + panTranslateY.value = 0; + pinchTranslateX.value = 0; + pinchTranslateY.value = 0; + zoomScale.value = 1; + + if (callback === undefined) { + return; + } + + callback(); + }, + [stopAnimation, offsetX, offsetY, pinchScale, panTranslateX, panTranslateY, pinchTranslateX, pinchTranslateY, zoomScale], + ); const {singleTapGesture: baseSingleTapGesture, doubleTapGesture} = useTapGestures({ canvasSize, @@ -164,6 +190,7 @@ function MultiGestureCanvas({ onTap, shouldDisableTransformationGestures, }); + // eslint-disable-next-line react-compiler/react-compiler const singleTapGesture = baseSingleTapGesture.requireExternalGestureToFail(doubleTapGesture, panGestureRef); const panGestureSimultaneousList = useMemo( @@ -186,6 +213,7 @@ function MultiGestureCanvas({ onSwipeDown, }) .simultaneousWithExternalGesture(...panGestureSimultaneousList) + // eslint-disable-next-line react-compiler/react-compiler .withRef(panGestureRef); const pinchGesture = usePinchGesture({ diff --git a/src/components/MultiGestureCanvas/usePanGesture.ts b/src/components/MultiGestureCanvas/usePanGesture.ts index b31e310055ae..b94ed77f150b 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.ts +++ b/src/components/MultiGestureCanvas/usePanGesture.ts @@ -1,8 +1,9 @@ /* eslint-disable no-param-reassign */ +import {useCallback} from 'react'; import {Dimensions} from 'react-native'; import type {PanGesture} from 'react-native-gesture-handler'; import {Gesture} from 'react-native-gesture-handler'; -import {runOnJS, useDerivedValue, useSharedValue, useWorkletCallback, withDecay, withSpring} from 'react-native-reanimated'; +import {runOnJS, useDerivedValue, useSharedValue, withDecay, withSpring} from 'react-native-reanimated'; import * as Browser from '@libs/Browser'; import {SPRING_CONFIG} from './constants'; import type {MultiGestureCanvasVariables} from './types'; @@ -66,7 +67,9 @@ const usePanGesture = ({ // Calculates bounds of the scaled content // Can we pan left/right/up/down // Can be used to limit gesture or implementing tension effect - const getBounds = useWorkletCallback(() => { + const getBounds = useCallback(() => { + 'worklet'; + let horizontalBoundary = 0; let verticalBoundary = 0; @@ -87,32 +90,34 @@ const usePanGesture = ({ }; // If the horizontal/vertical offset is the same after clamping to the min/max boundaries, the content is within the boundaries - const isInHoriztontalBoundary = clampedOffset.x === offsetX.value; + const isInHorizontalBoundary = clampedOffset.x === offsetX.value; const isInVerticalBoundary = clampedOffset.y === offsetY.value; return { horizontalBoundaries, verticalBoundaries, clampedOffset, - isInHoriztontalBoundary, + isInHorizontalBoundary, isInVerticalBoundary, }; - }, [canvasSize.width, canvasSize.height]); + }, [canvasSize.width, canvasSize.height, zoomedContentWidth, zoomedContentHeight, offsetX, offsetY]); // We want to smoothly decay/end the gesture by phasing out the pan animation // In case the content is outside of the boundaries of the canvas, // we need to move the content back into the boundaries - const finishPanGesture = useWorkletCallback(() => { + const finishPanGesture = useCallback(() => { + 'worklet'; + // If the content is centered within the canvas, we don't need to run any animations if (offsetX.value === 0 && offsetY.value === 0 && panTranslateX.value === 0 && panTranslateY.value === 0) { return; } - const {clampedOffset, isInHoriztontalBoundary, isInVerticalBoundary, horizontalBoundaries, verticalBoundaries} = getBounds(); + const {clampedOffset, isInHorizontalBoundary, isInVerticalBoundary, horizontalBoundaries, verticalBoundaries} = getBounds(); // If the content is within the horizontal/vertical boundaries of the canvas, we can smoothly phase out the animation // If not, we need to snap back to the boundaries - if (isInHoriztontalBoundary) { + if (isInHorizontalBoundary) { // If the (absolute) velocity is 0, we don't need to run an animation if (Math.abs(panVelocityX.value) !== 0) { // Phase out the pan animation @@ -161,7 +166,7 @@ const usePanGesture = ({ // Reset velocity variables after we finished the pan gesture panVelocityX.value = 0; panVelocityY.value = 0; - }); + }, [offsetX, offsetY, panTranslateX, panTranslateY, panVelocityX, panVelocityY, zoomScale, isSwipingDownToClose, getBounds, onSwipeDown]); const panGesture = Gesture.Pan() .manualActivation(true) @@ -183,6 +188,7 @@ const usePanGesture = ({ if (Math.abs(velocityY) > velocityX && velocityY > 20) { state.activate(); + // eslint-disable-next-line react-compiler/react-compiler isSwipingDownToClose.value = true; previousTouch.value = null; diff --git a/src/components/MultiGestureCanvas/usePinchGesture.ts b/src/components/MultiGestureCanvas/usePinchGesture.ts index 46a5e28e5732..01be2d00194a 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.ts +++ b/src/components/MultiGestureCanvas/usePinchGesture.ts @@ -1,8 +1,8 @@ /* eslint-disable no-param-reassign */ -import {useEffect, useState} from 'react'; +import {useCallback, useEffect, useState} from 'react'; import type {PinchGesture} from 'react-native-gesture-handler'; import {Gesture} from 'react-native-gesture-handler'; -import {runOnJS, useAnimatedReaction, useSharedValue, useWorkletCallback, withSpring} from 'react-native-reanimated'; +import {runOnJS, useAnimatedReaction, useSharedValue, withSpring} from 'react-native-reanimated'; import {SPRING_CONFIG, ZOOM_RANGE_BOUNCE_FACTORS} from './constants'; import type {MultiGestureCanvasVariables} from './types'; @@ -78,12 +78,16 @@ const usePinchGesture = ({ * Calculates the adjusted focal point of the pinch gesture, * based on the canvas size and the current offset */ - const getAdjustedFocal = useWorkletCallback( - (focalX: number, focalY: number) => ({ - x: focalX - (canvasSize.width / 2 + offsetX.value), - y: focalY - (canvasSize.height / 2 + offsetY.value), - }), - [canvasSize.width, canvasSize.height], + const getAdjustedFocal = useCallback( + (focalX: number, focalY: number) => { + 'worklet'; + + return { + x: focalX - (canvasSize.width / 2 + offsetX.value), + y: focalY - (canvasSize.height / 2 + offsetY.value), + }; + }, + [canvasSize.width, canvasSize.height, offsetX, offsetY], ); // The pinch gesture is disabled when we release one of the fingers diff --git a/src/components/MultiGestureCanvas/useTapGestures.ts b/src/components/MultiGestureCanvas/useTapGestures.ts index e4bb02bd5d34..4faacc8ac972 100644 --- a/src/components/MultiGestureCanvas/useTapGestures.ts +++ b/src/components/MultiGestureCanvas/useTapGestures.ts @@ -1,8 +1,8 @@ /* eslint-disable no-param-reassign */ -import {useMemo} from 'react'; +import {useCallback, useMemo} from 'react'; import type {TapGesture} from 'react-native-gesture-handler'; import {Gesture} from 'react-native-gesture-handler'; -import {runOnJS, useWorkletCallback, withSpring} from 'react-native-reanimated'; +import {runOnJS, withSpring} from 'react-native-reanimated'; import {DOUBLE_TAP_SCALE, SPRING_CONFIG} from './constants'; import type {MultiGestureCanvasVariables} from './types'; import * as MultiGestureCanvasUtils from './utils'; @@ -46,7 +46,7 @@ const useTapGestures = ({ // On double tap the content should be zoomed to fill, but at least zoomed by DOUBLE_TAP_SCALE const doubleTapScale = useMemo(() => Math.max(DOUBLE_TAP_SCALE, maxContentScale / minContentScale), [maxContentScale, minContentScale]); - const zoomToCoordinates = useWorkletCallback( + const zoomToCoordinates = useCallback( (focalX: number, focalY: number, callback: () => void) => { 'worklet'; @@ -117,7 +117,7 @@ const useTapGestures = ({ zoomScale.value = withSpring(doubleTapScale, SPRING_CONFIG, callback); pinchScale.value = doubleTapScale; }, - [scaledContentWidth, scaledContentHeight, canvasSize, doubleTapScale], + [stopAnimation, scaledContentWidth, scaledContentHeight, canvasSize, doubleTapScale, offsetX, offsetY, zoomScale, pinchScale], ); const doubleTapGesture = Gesture.Tap() diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 336b7dea9654..8d9def814549 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -115,8 +115,8 @@ function MoneyRequestPreviewContent({ const isOnHold = TransactionUtils.isOnHold(transaction); const isSettlementOrApprovalPartial = !!iouReport?.pendingFields?.partial; const isPartialHold = isSettlementOrApprovalPartial && isOnHold; - const hasViolations = TransactionUtils.hasViolation(transaction?.transactionID ?? '-1', transactionViolations); - const hasNoticeTypeViolations = TransactionUtils.hasNoticeTypeViolation(transaction?.transactionID ?? '-1', transactionViolations) && ReportUtils.isPaidGroupPolicy(iouReport); + const hasViolations = TransactionUtils.hasViolation(transaction?.transactionID ?? '-1', transactionViolations, true); + const hasNoticeTypeViolations = TransactionUtils.hasNoticeTypeViolation(transaction?.transactionID ?? '-1', transactionViolations, true) && ReportUtils.isPaidGroupPolicy(iouReport); const hasFieldErrors = TransactionUtils.hasMissingSmartscanFields(transaction); const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction); const isFetchingWaypointsFromServer = TransactionUtils.isFetchingWaypointsFromServer(transaction); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 274cb5a36856..381f01aadd89 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -28,6 +28,7 @@ import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import type {TransactionDetails} from '@libs/ReportUtils'; +import * as TagsOptionsListUtils from '@libs/TagsOptionsListUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import Navigation from '@navigation/Navigation'; @@ -182,7 +183,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals const shouldShowCategory = isPolicyExpenseChat && (transactionCategory || OptionsListUtils.hasEnabledOptions(policyCategories ?? {})); // transactionTag can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const shouldShowTag = isPolicyExpenseChat && (transactionTag || OptionsListUtils.hasEnabledTags(policyTagLists)); + const shouldShowTag = isPolicyExpenseChat && (transactionTag || TagsOptionsListUtils.hasEnabledTags(policyTagLists)); const shouldShowBillable = isPolicyExpenseChat && (!!transactionBillable || !(policy?.disabledFields?.defaultBillable ?? true) || !!updatedTransaction?.billable); const shouldShowAttendees = useMemo(() => TransactionUtils.shouldShowAttendees(iouType, policy), [iouType, policy]); diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index d476d1198808..5edeffd4dea4 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -123,7 +123,7 @@ function ReportPreview({ const [isPaidAnimationRunning, setIsPaidAnimationRunning] = useState(false); const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); const [requestType, setRequestType] = useState(); - const [nonHeldAmount, fullAmount] = ReportUtils.getNonHeldAndFullAmount(iouReport, policy); + const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = ReportUtils.getNonHeldAndFullAmount(iouReport, policy); const hasOnlyHeldExpenses = ReportUtils.hasOnlyHeldExpenses(iouReport?.reportID ?? ''); const [paymentType, setPaymentType] = useState(); const [invoiceReceiverPolicy] = useOnyx( @@ -157,8 +157,9 @@ function ReportPreview({ const hasErrors = (hasMissingSmartscanFields && !iouSettled) || // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - ReportUtils.hasViolations(iouReportID, transactionViolations) || - ReportUtils.hasWarningTypeViolations(iouReportID, transactionViolations) || + ReportUtils.hasViolations(iouReportID, transactionViolations, true) || + ReportUtils.hasNoticeTypeViolations(iouReportID, transactionViolations, true) || + ReportUtils.hasWarningTypeViolations(iouReportID, transactionViolations, true) || (ReportUtils.isReportOwner(iouReport) && ReportUtils.hasReportViolations(iouReportID)) || ReportUtils.hasActionsWithErrors(iouReportID); const lastThreeTransactionsWithReceipts = transactionsWithReceipts.slice(-3); @@ -240,7 +241,8 @@ function ReportPreview({ return ''; } - if (ReportUtils.hasHeldExpenses(iouReport?.reportID) && canAllowSettlement) { + // We shouldn't display the nonHeldAmount as the default option if it's not valid since we cannot pay partially in this case + if (ReportUtils.hasHeldExpenses(iouReport?.reportID) && canAllowSettlement && hasValidNonHeldAmount) { return nonHeldAmount; } @@ -595,7 +597,7 @@ function ReportPreview({ {isHoldMenuVisible && !!iouReport && requestType !== undefined && ( setIsHoldMenuVisible(false)} diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index a330be3d5ff6..1c3370cd72d5 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -366,7 +366,6 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) { null} shouldAlwaysShowDropdownMenu - pressOnEnter buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} customText={translate('workspace.common.selected', {count: selectedTransactionsKeys.length})} options={headerButtonsOptions} diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 45e30a6bad6d..da300f12eb9b 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -1,6 +1,7 @@ import React, {forwardRef, useCallback} from 'react'; import type {ForwardedRef} from 'react'; import {useOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import * as Expensicons from '@components/Icon/Expensicons'; import {usePersonalDetails} from '@components/OnyxProvider'; import type {SearchFilterKey, SearchQueryString} from '@components/Search/types'; @@ -13,16 +14,18 @@ import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; +import type {SearchOption} from '@libs/OptionsListUtils'; import Performance from '@libs/Performance'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; import {getQueryWithoutAutocompletedPart} from '@libs/SearchAutocompleteUtils'; import * as SearchQueryUtils from '@libs/SearchQueryUtils'; -import * as Report from '@userActions/Report'; +import * as ReportUserActions from '@userActions/Report'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type Report from '@src/types/onyx/Report'; import {getSubstitutionMapKey} from './getQueryWithSubstitutions'; type SearchQueryItemData = { @@ -73,8 +76,24 @@ const setPerformanceTimersEnd = () => { Performance.markEnd(CONST.TIMING.OPEN_SEARCH); }; -function getContextualSearchQuery(reportName: string) { - return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${CONST.SEARCH.DATA_TYPES.CHAT} ${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${SearchQueryUtils.sanitizeSearchValue(reportName)}`; +function getContextualSearchQuery(item: SearchQueryItem) { + const baseQuery = `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${item.roomType}`; + let additionalQuery = ''; + + switch (item.roomType) { + case CONST.SEARCH.DATA_TYPES.EXPENSE: + case CONST.SEARCH.DATA_TYPES.INVOICE: + additionalQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID}:${item.policyID}`; + if (item.roomType === CONST.SEARCH.DATA_TYPES.INVOICE && item.autocompleteID) { + additionalQuery += ` ${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${SearchQueryUtils.sanitizeSearchValue(item.searchQuery ?? '')}`; + } + break; + case CONST.SEARCH.DATA_TYPES.CHAT: + default: + additionalQuery = ` ${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${SearchQueryUtils.sanitizeSearchValue(item.searchQuery ?? '')}`; + break; + } + return baseQuery + additionalQuery; } function isSearchQueryItem(item: OptionData | SearchQueryItem): item is SearchQueryItem { @@ -154,16 +173,33 @@ function SearchRouterList( if (reportForContextualSearch && !textInputValue) { const reportQueryValue = reportForContextualSearch.text ?? reportForContextualSearch.alternateText ?? reportForContextualSearch.reportID; + let roomType: ValueOf = CONST.SEARCH.DATA_TYPES.CHAT; + let autocompleteID = reportForContextualSearch.reportID; + if (reportForContextualSearch.isInvoiceRoom) { + roomType = CONST.SEARCH.DATA_TYPES.INVOICE; + const report = reportForContextualSearch as SearchOption; + if (report.item && report.item?.invoiceReceiver && report.item.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) { + autocompleteID = report.item.invoiceReceiver.accountID.toString(); + } else { + autocompleteID = ''; + } + } + if (reportForContextualSearch.isPolicyExpenseChat) { + roomType = CONST.SEARCH.DATA_TYPES.EXPENSE; + autocompleteID = reportForContextualSearch.policyID ?? ''; + } sections.push({ data: [ { text: `${translate('search.searchIn')} ${reportForContextualSearch.text ?? reportForContextualSearch.alternateText}`, singleIcon: Expensicons.MagnifyingGlass, searchQuery: reportQueryValue, - autocompleteID: reportForContextualSearch.reportID, + autocompleteID, itemStyle: styles.activeComponentBG, keyForList: 'contextualSearch', searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION, + roomType, + policyID: reportForContextualSearch.policyID, }, ], }); @@ -209,10 +245,14 @@ function SearchRouterList( return; } if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { - const searchQuery = getContextualSearchQuery(item.searchQuery); + const searchQuery = getContextualSearchQuery(item); updateSearchValue(`${searchQuery} `); - if (item.autocompleteID) { + if (item.roomType === CONST.SEARCH.DATA_TYPES.INVOICE && item.autocompleteID) { + const autocompleteKey = `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${item.searchQuery}`; + onAutocompleteSuggestionClick(autocompleteKey, item.autocompleteID); + } + if (item.roomType === CONST.SEARCH.DATA_TYPES.CHAT && item.autocompleteID) { const autocompleteKey = `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${item.searchQuery}`; onAutocompleteSuggestionClick(autocompleteKey, item.autocompleteID); } @@ -236,7 +276,7 @@ function SearchRouterList( if ('reportID' in item && item?.reportID) { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item?.reportID)); } else if ('login' in item) { - Report.navigateToAndOpenReport(item.login ? [item.login] : [], false); + ReportUserActions.navigateToAndOpenReport(item.login ? [item.login] : [], false); } }, [closeRouter, textInputValue, onSearchSubmit, updateSearchValue, onAutocompleteSuggestionClick], diff --git a/src/components/SelectionList/Search/SearchQueryListItem.tsx b/src/components/SelectionList/Search/SearchQueryListItem.tsx index 77637eed39df..0dad7796556c 100644 --- a/src/components/SelectionList/Search/SearchQueryListItem.tsx +++ b/src/components/SelectionList/Search/SearchQueryListItem.tsx @@ -14,6 +14,7 @@ type SearchQueryItem = ListItem & { singleIcon?: IconAsset; searchQuery?: string; autocompleteID?: string; + roomType?: ValueOf; searchItemType?: ValueOf; }; diff --git a/src/components/TagPicker/index.tsx b/src/components/TagPicker/index.tsx index a64c4c276606..6f7612040ea4 100644 --- a/src/components/TagPicker/index.tsx +++ b/src/components/TagPicker/index.tsx @@ -7,18 +7,11 @@ import useThemeStyles from '@hooks/useThemeStyles'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import type * as ReportUtils from '@libs/ReportUtils'; +import type {SelectedTagOption} from '@libs/TagsOptionsListUtils'; +import * as TagOptionListUtils from '@libs/TagsOptionsListUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PolicyTag, PolicyTags} from '@src/types/onyx'; -import type {PendingAction} from '@src/types/onyx/OnyxCommon'; - -type SelectedTagOption = { - name: string; - enabled: boolean; - isSelected?: boolean; - accountID: number | undefined; - pendingAction?: PendingAction; -}; type TagPickerProps = { /** The policyID we are getting tags for */ @@ -81,15 +74,12 @@ function TagPicker({selectedTag, tagListName, policyID, tagListIndex, shouldShow const sections = useMemo( () => - OptionsListUtils.getFilteredOptions({ + TagOptionListUtils.getTagListSections({ searchValue, selectedOptions, - includeP2P: false, - includeTags: true, tags: enabledTags, recentlyUsedTags: policyRecentlyUsedTagsList, - canInviteUser: false, - }).tagOptions, + }), [searchValue, enabledTags, selectedOptions, policyRecentlyUsedTagsList], ); diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index 425960078b0a..cd80330b08ef 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -16,6 +16,7 @@ import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as Browser from '@libs/Browser'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as ValidationUtils from '@libs/ValidationUtils'; import * as User from '@userActions/User'; @@ -65,6 +66,9 @@ 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 */ + isLoading?: boolean; }; function BaseValidateCodeForm({ @@ -78,6 +82,7 @@ function BaseValidateCodeForm({ clearError, sendValidateCode, buttonStyles, + isLoading, }: ValidateCodeFormProps) { const {translate} = useLocalize(); const {isOffline} = useNetwork(); @@ -120,9 +125,16 @@ function BaseValidateCodeForm({ if (focusTimeoutRef.current) { clearTimeout(focusTimeoutRef.current); } - focusTimeoutRef.current = setTimeout(() => { + + // Keyboard won't show if we focus the input with a delay, so we need to focus immediately. + if (!Browser.isMobileSafari()) { + focusTimeoutRef.current = setTimeout(() => { + inputValidateCodeRef.current?.focusLastSelected(); + }, CONST.ANIMATED_TRANSITION); + } else { inputValidateCodeRef.current?.focusLastSelected(); - }, CONST.ANIMATED_TRANSITION); + } + return () => { if (!focusTimeoutRef.current) { return; @@ -205,7 +217,7 @@ function BaseValidateCodeForm({ errorText={formError?.validateCode ? translate(formError?.validateCode) : ErrorUtils.getLatestErrorMessage(account ?? {})} hasError={!isEmptyObject(validateError)} onFulfill={validateAndSubmitForm} - autoFocus + autoFocus={false} /> {shouldShowTimer && ( @@ -259,7 +271,8 @@ function BaseValidateCodeForm({ style={[styles.mt4]} success large - isLoading={account?.isLoading} + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + isLoading={account?.isLoading || isLoading} /> diff --git a/src/components/ValidateCodeActionModal/index.tsx b/src/components/ValidateCodeActionModal/index.tsx index 461c780a50d0..b848cb501b0f 100644 --- a/src/components/ValidateCodeActionModal/index.tsx +++ b/src/components/ValidateCodeActionModal/index.tsx @@ -16,7 +16,8 @@ import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeFo function ValidateCodeActionModal({ isVisible, title, - description, + descriptionPrimary, + descriptionSecondary, onClose, onModalHide, validatePendingAction, @@ -26,6 +27,7 @@ function ValidateCodeActionModal({ footer, sendValidateCode, hasMagicCodeBeenSent, + isLoading, }: ValidateCodeActionModalProps) { const themeStyles = useThemeStyles(); const safePaddingBottomStyle = useSafePaddingBottomStyle(); @@ -70,7 +72,8 @@ function ValidateCodeActionModal({ /> - {description} + {descriptionPrimary} + {!!descriptionSecondary && {descriptionSecondary}} {footer?.()} diff --git a/src/components/ValidateCodeActionModal/type.ts b/src/components/ValidateCodeActionModal/type.ts index 5556287b370e..2682ac3a6ea3 100644 --- a/src/components/ValidateCodeActionModal/type.ts +++ b/src/components/ValidateCodeActionModal/type.ts @@ -8,8 +8,11 @@ type ValidateCodeActionModalProps = { /** Title of the modal */ title: string; - /** Description of the modal */ - description: string; + /** Primary description of the modal */ + descriptionPrimary: string; + + /** Secondary description of the modal */ + descriptionSecondary?: string | null; /** Function to call when the user closes the modal */ onClose: () => void; @@ -37,6 +40,9 @@ type ValidateCodeActionModalProps = { /** If the magic code has been resent previously */ hasMagicCodeBeenSent?: boolean; + + /** Wheather the form is loading or not */ + isLoading?: boolean; }; // eslint-disable-next-line import/prefer-default-export diff --git a/src/components/withKeyboardState.tsx b/src/components/withKeyboardState.tsx index 72540ebceaa8..6184213027af 100755 --- a/src/components/withKeyboardState.tsx +++ b/src/components/withKeyboardState.tsx @@ -1,6 +1,7 @@ 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'; @@ -10,6 +11,9 @@ 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({ @@ -19,6 +23,7 @@ const KeyboardStateContext = createContext({ function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null { const [keyboardHeight, setKeyboardHeight] = useState(0); + const isWindowHeightReducedByKeyboard = useIsWindowHeightReducedByKeyboard(); useEffect(() => { const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', (e) => { @@ -38,8 +43,9 @@ function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null { () => ({ keyboardHeight, isKeyboardShown: keyboardHeight !== 0, + isWindowHeightReducedByKeyboard, }), - [keyboardHeight], + [keyboardHeight, isWindowHeightReducedByKeyboard], ); return {children}; } diff --git a/src/hooks/useIsWindowHeightReducedByKeyboard/index.ts b/src/hooks/useIsWindowHeightReducedByKeyboard/index.ts new file mode 100644 index 000000000000..7895c7209115 --- /dev/null +++ b/src/hooks/useIsWindowHeightReducedByKeyboard/index.ts @@ -0,0 +1,37 @@ +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 fa78b563c522..d13cf61957ea 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1116,7 +1116,7 @@ const translations = { helpTextAfterEmail: ' from multiple email addresses.', pleaseVerify: 'Please verify this contact method', getInTouch: "Whenever we need to get in touch with you, we'll use this contact method.", - enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Please enter the magic code sent to ${contactMethod}`, + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Please enter the magic code sent to ${contactMethod}. It should arrive within a minute or two.`, setAsDefault: 'Set as default', yourDefaultContactMethod: "This is your current default contact method. Before you can delete it, you'll need to choose another contact method and click “Set as default”.", removeContactMethod: 'Remove contact method', @@ -1455,7 +1455,7 @@ const translations = { }, cardDetailsLoadingFailure: 'An error occurred while loading the card details. Please check your internet connection and try again.', validateCardTitle: "Let's make sure it's you", - enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Please enter the magic code sent to ${contactMethod} to view your card details`, + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Please enter the magic code sent to ${contactMethod} to view your card details. It should arrive within a minute or two.`, }, workflowsPage: { workflowTitle: 'Spend', @@ -1903,7 +1903,7 @@ const translations = { chooseAnAccountBelow: 'Choose an account below', addBankAccount: 'Add bank account', chooseAnAccount: 'Choose an account', - connectOnlineWithPlaid: 'Connect online with Plaid', + connectOnlineWithPlaid: 'Connect via Plaid', connectManually: 'Connect manually', desktopConnection: 'Note: To connect with Chase, Wells Fargo, Capital One or Bank of America, please click here to complete this process in a browser.', yourDataIsSecure: 'Your data is secure', @@ -5180,7 +5180,7 @@ const translations = { removeCopilotConfirmation: 'Are you sure you want to remove this copilot?', changeAccessLevel: 'Change access level', makeSureItIsYou: "Let's make sure it's you", - enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Please enter the magic code sent to ${contactMethod} to add a copilot.`, + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Please enter the magic code sent to ${contactMethod} to add a copilot. It should arrive within a minute or two.`, enterMagicCodeUpdate: ({contactMethod}: EnterMagicCodeParams) => `Please enter the magic code sent to ${contactMethod} to update your copilot.`, notAllowed: 'Not so fast...', notAllowedMessageStart: ({accountOwnerEmail}: AccountOwnerParams) => `You don't have permission to take this action for ${accountOwnerEmail} as a`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 0c682569263f..d0ca8bc173bd 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1113,7 +1113,7 @@ const translations = { helpTextAfterEmail: ' desde varias direcciones de correo electrónico.', pleaseVerify: 'Por favor, verifica este método de contacto', getInTouch: 'Utilizaremos este método de contacto cuando necesitemos contactarte.', - enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Por favor, introduce el código mágico enviado a ${contactMethod}`, + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Por favor, introduce el código mágico enviado a ${contactMethod}. Debería llegar en un par de minutos.`, setAsDefault: 'Establecer como predeterminado', yourDefaultContactMethod: 'Este es tu método de contacto predeterminado. Antes de poder eliminarlo, tendrás que elegir otro método de contacto y haz clic en "Establecer como predeterminado".', @@ -1454,7 +1454,8 @@ const translations = { }, cardDetailsLoadingFailure: 'Se ha producido un error al cargar los datos de la tarjeta. Comprueba tu conexión a Internet e inténtalo de nuevo.', validateCardTitle: 'Asegurémonos de que eres tú', - enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Introduzca el código mágico enviado a ${contactMethod} para ver los datos de su tarjeta`, + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => + `Introduzca el código mágico enviado a ${contactMethod} para ver los datos de su tarjeta. Debería llegar en un par de minutos.`, }, workflowsPage: { workflowTitle: 'Gasto', @@ -5696,7 +5697,8 @@ const translations = { removeCopilotConfirmation: '¿Estás seguro de que quieres eliminar este copiloto?', changeAccessLevel: 'Cambiar nivel de acceso', makeSureItIsYou: 'Vamos a asegurarnos de que eres tú', - enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Por favor, introduce el código mágico enviado a ${contactMethod} para agregar un copiloto.`, + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => + `Por favor, introduce el código mágico enviado a ${contactMethod} para agregar un copiloto. Debería llegar en un par de minutos.`, enterMagicCodeUpdate: ({contactMethod}: EnterMagicCodeParams) => `Por favor, introduce el código mágico enviado a ${contactMethod} para actualizar el nivel de acceso de tu copiloto.`, notAllowed: 'No tan rápido...', diff --git a/src/libs/API/parameters/CompleteGuidedSetupParams.ts b/src/libs/API/parameters/CompleteGuidedSetupParams.ts index 6ff45ecc424a..febb1a47d8b6 100644 --- a/src/libs/API/parameters/CompleteGuidedSetupParams.ts +++ b/src/libs/API/parameters/CompleteGuidedSetupParams.ts @@ -1,14 +1,15 @@ -import type {OnboardingAccountingType, OnboardingCompanySizeType, OnboardingPurposeType} from '@src/CONST'; +import type {OnboardingAccounting, OnboardingCompanySize} from '@src/CONST'; +import type {OnboardingPurpose} from '@src/types/onyx'; type CompleteGuidedSetupParams = { firstName: string; lastName: string; actorAccountID: number; guidedSetupData: string; - engagementChoice: OnboardingPurposeType; + engagementChoice: OnboardingPurpose; paymentSelected?: string; - companySize?: OnboardingCompanySizeType; - userReportedIntegration?: OnboardingAccountingType; + companySize?: OnboardingCompanySize; + userReportedIntegration?: OnboardingAccounting; policyID?: string; }; diff --git a/src/libs/API/parameters/OpenReportParams.ts b/src/libs/API/parameters/OpenReportParams.ts index 0f9cb7075fce..a665313580e8 100644 --- a/src/libs/API/parameters/OpenReportParams.ts +++ b/src/libs/API/parameters/OpenReportParams.ts @@ -14,6 +14,7 @@ type OpenReportParams = { chatType?: string; optimisticAccountIDList?: string; file?: File | CustomRNImageManipulatorResult; + guidedSetupData?: string; }; export default OpenReportParams; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index e4baef57c001..2e31ffa808b9 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -141,8 +141,9 @@ function maskCardNumber(cardName: string): string { if (!cardName || cardName === '') { return ''; } + const hasSpace = /\s/.test(cardName); const maskedString = cardName.replace(/X/g, '•'); - return maskedString.replace(/(.{4})/g, '$1 ').trim(); + return hasSpace ? cardName : maskedString.replace(/(.{4})/g, '$1 ').trim(); } /** @@ -200,27 +201,27 @@ function sortCardsByCardholderName(cardsList: OnyxEntry, per }); } -function getCompanyCardNumber(cardList: Record, lastFourPAN?: string): string { +function getCompanyCardNumber(cardList: Record, lastFourPAN?: string, cardName = ''): string { if (!lastFourPAN) { return ''; } - return Object.keys(cardList).find((card) => card.endsWith(lastFourPAN)) ?? maskCard(lastFourPAN); + 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.VisaCompanyCardDetail, - [CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX]: Illustrations.AmexCardCompanyCardDetail, - [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD]: Illustrations.MasterCardCompanyCardDetail, - [CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX_DIRECT]: Illustrations.AmexCardCompanyCardDetail, - [CONST.COMPANY_CARD.FEED_BANK_NAME.BANK_OF_AMERICA]: Illustrations.BankOfAmericaCompanyCardDetail, - [CONST.COMPANY_CARD.FEED_BANK_NAME.CAPITAL_ONE]: Illustrations.CapitalOneCompanyCardDetail, - [CONST.COMPANY_CARD.FEED_BANK_NAME.CHASE]: Illustrations.ChaseCompanyCardDetail, - [CONST.COMPANY_CARD.FEED_BANK_NAME.CITIBANK]: Illustrations.CitibankCompanyCardDetail, - [CONST.COMPANY_CARD.FEED_BANK_NAME.WELLS_FARGO]: Illustrations.WellsFargoCompanyCardDetail, - [CONST.COMPANY_CARD.FEED_BANK_NAME.BREX]: Illustrations.BrexCompanyCardDetail, - [CONST.COMPANY_CARD.FEED_BANK_NAME.STRIPE]: Illustrations.StripeCompanyCardDetail, + [CONST.COMPANY_CARD.FEED_BANK_NAME.VISA]: Illustrations.VisaCompanyCardDetailLarge, + [CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX]: Illustrations.AmexCardCompanyCardDetailLarge, + [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD]: Illustrations.MasterCardCompanyCardDetailLarge, + [CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX_DIRECT]: Illustrations.AmexCardCompanyCardDetailLarge, + [CONST.COMPANY_CARD.FEED_BANK_NAME.BANK_OF_AMERICA]: Illustrations.BankOfAmericaCompanyCardDetailLarge, + [CONST.COMPANY_CARD.FEED_BANK_NAME.CAPITAL_ONE]: Illustrations.CapitalOneCompanyCardDetailLarge, + [CONST.COMPANY_CARD.FEED_BANK_NAME.CHASE]: Illustrations.ChaseCompanyCardDetailLarge, + [CONST.COMPANY_CARD.FEED_BANK_NAME.CITIBANK]: Illustrations.CitibankCompanyCardDetailLarge, + [CONST.COMPANY_CARD.FEED_BANK_NAME.WELLS_FARGO]: Illustrations.WellsFargoCompanyCardDetailLarge, + [CONST.COMPANY_CARD.FEED_BANK_NAME.BREX]: Illustrations.BrexCompanyCardDetailLarge, + [CONST.COMPANY_CARD.FEED_BANK_NAME.STRIPE]: Illustrations.StripeCompanyCardDetailLarge, [CONST.EXPENSIFY_CARD.BANK]: ExpensifyCardImage, }; diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 6b3b2a70ede0..2f0da08b194d 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -65,9 +65,7 @@ const REPORT_BOOLEAN_PROPERTIES: Array = [ 'isWaitingOnBankAccount', 'isCancelledIOU', 'isHidden', - 'isChatRoom', 'isLoadingPrivateNotes', - 'selected', ] satisfies Array; const REPORT_DATE_PROPERTIES: Array = ['lastVisibleActionCreated', 'lastReadTime', 'lastMentionedTime', 'lastVisibleActionLastModified'] satisfies Array; @@ -496,9 +494,6 @@ function validateReportDraftProperty(key: keyof Report, value: string) { if (key === 'pendingFields') { return validateObject(value, {}); } - if (key === 'participantAccountIDs') { - return validateArray(value, 'number'); - } validateString(value); } @@ -588,7 +583,7 @@ function getReasonForShowingRowInLHN(report: OnyxEntry, hasRBR = false): return null; } - const doesReportHaveViolations = ReportUtils.shouldShowViolations(report, transactionViolations); + const doesReportHaveViolations = ReportUtils.shouldDisplayViolationsRBRInLHN(report, transactionViolations); const reason = ReportUtils.reasonForReportToBeInOptionList({ report, diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index a061d5b52d22..7c39f36b932d 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -261,12 +261,6 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie isInitialRender.current = false; } - const isOnboardingCompletedRef = useRef(isOnboardingCompleted); - - useEffect(() => { - isOnboardingCompletedRef.current = isOnboardingCompleted; - }, [isOnboardingCompleted]); - useEffect(() => { const shortcutsOverviewShortcutConfig = CONST.KEYBOARD_SHORTCUTS.SHORTCUTS; const searchShortcutConfig = CONST.KEYBOARD_SHORTCUTS.SEARCH; @@ -363,9 +357,6 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie searchShortcutConfig.shortcutKey, () => { Session.checkIfActionIsAllowed(() => { - if (!isOnboardingCompletedRef.current) { - return; - } toggleSearchRouter(); })(); }, diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 6bcb353cf065..66e516593450 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -9,7 +9,6 @@ import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {SetNonNullable} from 'type-fest'; import {FallbackAvatar} from '@components/Icon/Expensicons'; -import type {SelectedTagOption} from '@components/TagPicker'; import type {IOUAction} from '@src/CONST'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -24,8 +23,6 @@ import type { PolicyCategories, PolicyCategory, PolicyTag, - PolicyTagLists, - PolicyTags, Report, ReportAction, ReportActions, @@ -42,7 +39,6 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import times from '@src/utils/times'; import Timing from './actions/Timing'; import filterArrayByMatch from './filterArrayByMatch'; -import localeCompare from './LocaleCompare'; import * as LocalePhoneNumber from './LocalePhoneNumber'; import * as Localize from './Localize'; import * as LoginUtils from './LoginUtils'; @@ -162,9 +158,6 @@ type GetOptionsConfig = { includeCategories?: boolean; categories?: PolicyCategories; recentlyUsedCategories?: string[]; - includeTags?: boolean; - tags?: PolicyTags | Array; - recentlyUsedTags?: string[]; canInviteUser?: boolean; includeSelectedOptions?: boolean; includeTaxRates?: boolean; @@ -212,7 +205,6 @@ type Options = { userToInvite: ReportUtils.OptionData | null; currentUserOption: ReportUtils.OptionData | null | undefined; categoryOptions: CategoryTreeSection[]; - tagOptions: CategorySection[]; taxRatesOptions: CategorySection[]; }; @@ -978,16 +970,6 @@ function sortCategories(categories: Record): Category[] { return flatHierarchy(hierarchy); } -/** - * Sorts tags alphabetically by name. - */ -function sortTags(tags: Record | Array) { - const sortedTags = Array.isArray(tags) ? tags : Object.values(tags); - - // Use lodash's sortBy to ensure consistency with oldDot. - return lodashSortBy(sortedTags, 'name', localeCompare); -} - /** * Builds the options for the category tree hierarchy via indents * @@ -1170,141 +1152,6 @@ function getCategoryListSections( return categorySections; } -/** - * Transforms the provided tags into option objects. - * - * @param tags - an initial tag array - */ -function getTagsOptions(tags: Array>, selectedOptions?: SelectedTagOption[]): Option[] { - return tags.map((tag) => { - // This is to remove unnecessary escaping backslash in tag name sent from backend. - const cleanedName = PolicyUtils.getCleanedTagName(tag.name); - return { - text: cleanedName, - keyForList: tag.name, - searchText: tag.name, - tooltipText: cleanedName, - isDisabled: !tag.enabled || tag.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - isSelected: selectedOptions?.some((selectedTag) => selectedTag.name === tag.name), - pendingAction: tag.pendingAction, - }; - }); -} - -/** - * Build the section list for tags - */ -function getTagListSections( - tags: Array, - recentlyUsedTags: string[], - selectedOptions: SelectedTagOption[], - searchInputValue: string, - maxRecentReportsToShow: number, -) { - const tagSections = []; - const sortedTags = sortTags(tags) as PolicyTag[]; - const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name); - const enabledTags = sortedTags.filter((tag) => tag.enabled); - const enabledTagsNames = enabledTags.map((tag) => tag.name); - const enabledTagsWithoutSelectedOptions = enabledTags.filter((tag) => !selectedOptionNames.includes(tag.name)); - const selectedTagsWithDisabledState: SelectedTagOption[] = []; - const numberOfTags = enabledTags.length; - - selectedOptions.forEach((tag) => { - if (enabledTagsNames.includes(tag.name)) { - selectedTagsWithDisabledState.push({...tag, enabled: true}); - return; - } - selectedTagsWithDisabledState.push({...tag, enabled: false}); - }); - - // If all tags are disabled but there's a previously selected tag, show only the selected tag - if (numberOfTags === 0 && selectedOptions.length > 0) { - tagSections.push({ - // "Selected" section - title: '', - shouldShow: false, - data: getTagsOptions(selectedTagsWithDisabledState, selectedOptions), - }); - - return tagSections; - } - - if (searchInputValue) { - const enabledSearchTags = enabledTagsWithoutSelectedOptions.filter((tag) => PolicyUtils.getCleanedTagName(tag.name.toLowerCase()).includes(searchInputValue.toLowerCase())); - const selectedSearchTags = selectedTagsWithDisabledState.filter((tag) => PolicyUtils.getCleanedTagName(tag.name.toLowerCase()).includes(searchInputValue.toLowerCase())); - const tagsForSearch = [...selectedSearchTags, ...enabledSearchTags]; - - tagSections.push({ - // "Search" section - title: '', - shouldShow: true, - data: getTagsOptions(tagsForSearch, selectedOptions), - }); - - return tagSections; - } - - if (numberOfTags < CONST.STANDARD_LIST_ITEM_LIMIT) { - tagSections.push({ - // "All" section when items amount less than the threshold - title: '', - shouldShow: false, - data: getTagsOptions([...selectedTagsWithDisabledState, ...enabledTagsWithoutSelectedOptions], selectedOptions), - }); - - return tagSections; - } - - const filteredRecentlyUsedTags = recentlyUsedTags - .filter((recentlyUsedTag) => { - const tagObject = tags.find((tag) => tag.name === recentlyUsedTag); - return !!tagObject?.enabled && !selectedOptionNames.includes(recentlyUsedTag) && tagObject?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - }) - .map((tag) => ({name: tag, enabled: true})); - - if (selectedOptions.length) { - tagSections.push({ - // "Selected" section - title: '', - shouldShow: true, - data: getTagsOptions(selectedTagsWithDisabledState, selectedOptions), - }); - } - - if (filteredRecentlyUsedTags.length > 0) { - const cutRecentlyUsedTags = filteredRecentlyUsedTags.slice(0, maxRecentReportsToShow); - - tagSections.push({ - // "Recent" section - title: Localize.translateLocal('common.recent'), - shouldShow: true, - data: getTagsOptions(cutRecentlyUsedTags, selectedOptions), - }); - } - - tagSections.push({ - // "All" section when items amount more than the threshold - title: Localize.translateLocal('common.all'), - shouldShow: true, - data: getTagsOptions(enabledTagsWithoutSelectedOptions, selectedOptions), - }); - - return tagSections; -} - -/** - * Verifies that there is at least one enabled tag - */ -function hasEnabledTags(policyTagList: Array) { - const policyTagValueList = policyTagList - .filter((tag) => tag && tag.tags) - .map(({tags}) => Object.values(tags)) - .flat(); - - return hasEnabledOptions(policyTagValueList); -} - /** * Sorts tax rates alphabetically by name. */ @@ -1638,9 +1485,6 @@ function getOptions( includeCategories = false, categories = {}, recentlyUsedCategories = [], - includeTags = false, - tags = {}, - recentlyUsedTags = [], canInviteUser = true, includeSelectedOptions = false, transactionViolations = {}, @@ -1664,21 +1508,6 @@ function getOptions( userToInvite: null, currentUserOption: null, categoryOptions, - tagOptions: [], - taxRatesOptions: [], - }; - } - - if (includeTags) { - const tagOptions = getTagListSections(Object.values(tags), recentlyUsedTags, selectedOptions as SelectedTagOption[], searchInputValue, maxRecentReportsToShow); - - return { - recentReports: [], - personalDetails: [], - userToInvite: null, - currentUserOption: null, - categoryOptions: [], - tagOptions, taxRatesOptions: [], }; } @@ -1692,7 +1521,6 @@ function getOptions( userToInvite: null, currentUserOption: null, categoryOptions: [], - tagOptions: [], taxRatesOptions, }; } @@ -1704,7 +1532,7 @@ function getOptions( // Filter out all the reports that shouldn't be displayed const filteredReportOptions = options.reports.filter((option) => { const report = option.item; - const doesReportHaveViolations = ReportUtils.shouldShowViolations(report, transactionViolations); + const doesReportHaveViolations = ReportUtils.shouldDisplayViolationsRBRInLHN(report, transactionViolations); return ReportUtils.shouldReportBeInOptionList({ report, @@ -1953,7 +1781,6 @@ function getOptions( userToInvite: canInviteUser ? userToInvite : null, currentUserOption, categoryOptions: [], - tagOptions: [], taxRatesOptions: [], }; } @@ -2040,9 +1867,6 @@ type FilteredOptionsParams = { includeCategories?: boolean; categories?: PolicyCategories; recentlyUsedCategories?: string[]; - includeTags?: boolean; - tags?: PolicyTags | Array; - recentlyUsedTags?: string[]; canInviteUser?: boolean; includeSelectedOptions?: boolean; includeTaxRates?: boolean; @@ -2078,9 +1902,6 @@ function getFilteredOptions(params: FilteredOptionsParamsWithDefaultSearchValue includeCategories = false, categories = {}, recentlyUsedCategories = [], - includeTags = false, - tags = {}, - recentlyUsedTags = [], canInviteUser = true, includeSelectedOptions = false, includeTaxRates = false, @@ -2105,9 +1926,6 @@ function getFilteredOptions(params: FilteredOptionsParamsWithDefaultSearchValue includeCategories, categories, recentlyUsedCategories, - includeTags, - tags, - recentlyUsedTags, canInviteUser, includeSelectedOptions, includeTaxRates, @@ -2146,9 +1964,6 @@ function getAttendeeOptions( includeCategories: false, categories: {}, recentlyUsedCategories: [], - includeTags: false, - tags: {}, - recentlyUsedTags: [], canInviteUser, includeSelectedOptions: false, includeTaxRates: false, @@ -2445,7 +2260,6 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt userToInvite: null, currentUserOption, categoryOptions: [], - tagOptions: [], taxRatesOptions: [], }; }, options); @@ -2481,7 +2295,6 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt userToInvite, currentUserOption: matchResults.currentUserOption, categoryOptions: [], - tagOptions: [], taxRatesOptions: [], }; } @@ -2497,7 +2310,6 @@ function getEmptyOptions(): Options { userToInvite: null, currentUserOption: null, categoryOptions: [], - tagOptions: [], taxRatesOptions: [], }; } @@ -2532,9 +2344,7 @@ export { hasEnabledOptions, sortCategories, sortAlphabetically, - sortTags, getCategoryOptionTree, - hasEnabledTags, formatMemberForList, formatSectionsFromSearchTerm, getShareLogOptions, diff --git a/src/libs/ReportConnection.ts b/src/libs/ReportConnection.ts index a390a5cea2a9..f9f913d59beb 100644 --- a/src/libs/ReportConnection.ts +++ b/src/libs/ReportConnection.ts @@ -48,4 +48,24 @@ function getAllReportsLength() { return Object.keys(allReports ?? {}).length; } -export {getAllReports, getAllReportsNameMap, getAllReportsLength}; +function getReport(reportID: string) { + if (!reportID || !allReports) { + return; + } + return allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; +} + +function updateReportData(reportID: string, reportData?: Partial) { + const report = getReport(reportID); + + if (!allReports || !report || !report.reportID) { + return; + } + + allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] = { + ...report, + ...reportData, + }; +} + +export {getAllReports, getAllReportsNameMap, getAllReportsLength, updateReportData, getReport}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 28e3a0144141..b220c2db20b6 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -520,6 +520,8 @@ type OptionData = { isConciergeChat?: boolean; isBold?: boolean; lastIOUCreationDate?: string; + isChatRoom?: boolean; + participantsList?: PersonalDetails[]; icons?: Icon[]; iouReportAmount?: number; } & Report; @@ -555,6 +557,16 @@ type ParsingDetails = { policyID?: string; }; +type NonHeldAndFullAmount = { + nonHeldAmount: string; + fullAmount: string; + /** + * nonHeldAmount is valid if not negative; + * It can be negative if the unheld transaction comes from the current user + */ + hasValidNonHeldAmount: boolean; +}; + type Thread = { parentReportID: string; parentReportActionID: string; @@ -6320,65 +6332,53 @@ function shouldHideReport(report: OnyxEntry, currentReportId: string): b } /** - * Checks to see if a report's parentAction is an expense that contains a violation type of either violation or warning + * Should we display a RBR on the LHN on this report due to violations? */ -function doesTransactionThreadHaveViolations( - report: OnyxInputOrEntry, - transactionViolations: OnyxCollection, - parentReportAction: OnyxInputOrEntry, -): boolean { - if (!ReportActionsUtils.isMoneyRequestAction(parentReportAction)) { - return false; - } - const {IOUTransactionID, IOUReportID} = ReportActionsUtils.getOriginalMessage(parentReportAction) ?? {}; - if (!IOUTransactionID || !IOUReportID) { +function shouldDisplayViolationsRBRInLHN(report: OnyxEntry, transactionViolations: OnyxCollection): boolean { + // We only show the RBR in the highest level, which is the workspace chat + if (!report || !isPolicyExpenseChat(report)) { return false; } - if (!isCurrentUserSubmitter(IOUReportID)) { - return false; - } - if (report?.stateNum !== CONST.REPORT.STATE_NUM.OPEN && report?.stateNum !== CONST.REPORT.STATE_NUM.SUBMITTED) { + + // We only show the RBR to the submitter + if (!isCurrentUserSubmitter(report.reportID ?? '')) { return false; } - return ( - TransactionUtils.hasViolation(IOUTransactionID, transactionViolations) || - TransactionUtils.hasWarningTypeViolation(IOUTransactionID, transactionViolations) || - (isPaidGroupPolicy(report) && TransactionUtils.hasModifiedAmountOrDateViolation(IOUTransactionID, transactionViolations)) + + // Get all potential reports, which are the ones that are: + // - Owned by the same user + // - Are either open or submitted + // - Belong to the same workspace + // And if any have a violation, then it should have a RBR + const allReports = Object.values(ReportConnection.getAllReports() ?? {}) as Report[]; + const potentialReports = allReports.filter((r) => r.ownerAccountID === currentUserAccountID && (r.stateNum ?? 0) <= 1 && r.policyID === report.policyID); + return potentialReports.some( + (potentialReport) => hasViolations(potentialReport.reportID, transactionViolations) || hasWarningTypeViolations(potentialReport.reportID, transactionViolations), ); } /** - * Checks if we should display violation - we display violations when the expense has violation and it is not settled + * Checks to see if a report contains a violation */ -function shouldDisplayTransactionThreadViolations( - report: OnyxEntry, - transactionViolations: OnyxCollection, - parentReportAction: OnyxEntry, -): boolean { - if (!ReportActionsUtils.isMoneyRequestAction(parentReportAction)) { - return false; - } - const {IOUReportID} = ReportActionsUtils.getOriginalMessage(parentReportAction) ?? {}; - if (isSettled(IOUReportID) || isReportApproved(IOUReportID?.toString())) { - return false; - } - return doesTransactionThreadHaveViolations(report, transactionViolations, parentReportAction); +function hasViolations(reportID: string, transactionViolations: OnyxCollection, shouldShowInReview?: boolean): boolean { + const transactions = reportsTransactions[reportID] ?? []; + return transactions.some((transaction) => TransactionUtils.hasViolation(transaction.transactionID, transactionViolations, shouldShowInReview)); } /** - * Checks to see if a report contains a violation + * Checks to see if a report contains a violation of type `warning` */ -function hasViolations(reportID: string, transactionViolations: OnyxCollection): boolean { +function hasWarningTypeViolations(reportID: string, transactionViolations: OnyxCollection, shouldShowInReview?: boolean): boolean { const transactions = reportsTransactions[reportID] ?? []; - return transactions.some((transaction) => TransactionUtils.hasViolation(transaction.transactionID, transactionViolations)); + return transactions.some((transaction) => TransactionUtils.hasWarningTypeViolation(transaction.transactionID, transactionViolations, shouldShowInReview)); } /** - * Checks to see if a report contains a violation of type `warning` + * Checks to see if a report contains a violation of type `notice` */ -function hasWarningTypeViolations(reportID: string, transactionViolations: OnyxCollection): boolean { +function hasNoticeTypeViolations(reportID: string, transactionViolations: OnyxCollection, shouldShowInReview?: boolean): boolean { const transactions = reportsTransactions[reportID] ?? []; - return transactions.some((transaction) => TransactionUtils.hasWarningTypeViolation(transaction.transactionID, transactionViolations)); + return transactions.some((transaction) => TransactionUtils.hasNoticeTypeViolation(transaction.transactionID, transactionViolations, shouldShowInReview)); } function hasReportViolations(reportID: string) { @@ -6400,23 +6400,6 @@ function shouldAdminsRoomBeVisible(report: OnyxEntry): boolean { return true; } -/** - * Check whether report has violations - */ -function shouldShowViolations(report: Report, transactionViolations: OnyxCollection) { - const {parentReportID, parentReportActionID} = report ?? {}; - const canGetParentReport = parentReportID && parentReportActionID && allReportActions; - if (!canGetParentReport) { - return false; - } - const parentReportActions = allReportActions ? allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`] ?? {} : {}; - const parentReportAction = parentReportActions[parentReportActionID] ?? null; - if (!parentReportAction) { - return false; - } - return shouldDisplayTransactionThreadViolations(report, transactionViolations, parentReportAction); -} - type ReportErrorsAndReportActionThatRequiresAttention = { errors: ErrorFields; reportAction?: OnyxEntry; @@ -6499,7 +6482,7 @@ function hasReportErrorsOtherThanFailedReceipt(report: Report, doesReportHaveVio let doesTransactionThreadReportHasViolations = false; if (oneTransactionThreadReportID) { const transactionReport = getReport(oneTransactionThreadReportID); - doesTransactionThreadReportHasViolations = !!transactionReport && shouldShowViolations(transactionReport, transactionViolations); + doesTransactionThreadReportHasViolations = !!transactionReport && shouldDisplayViolationsRBRInLHN(transactionReport, transactionViolations); } return ( doesTransactionThreadReportHasViolations || @@ -7791,7 +7774,7 @@ function hasUpdatedTotal(report: OnyxInputOrEntry, policy: OnyxInputOrEn /** * Return held and full amount formatted with used currency */ -function getNonHeldAndFullAmount(iouReport: OnyxEntry, policy: OnyxEntry): string[] { +function getNonHeldAndFullAmount(iouReport: OnyxEntry, policy: OnyxEntry): NonHeldAndFullAmount { const reportTransactions = reportsTransactions[iouReport?.reportID ?? ''] ?? []; const hasPendingTransaction = reportTransactions.some((transaction) => !!transaction.pendingAction); @@ -7801,16 +7784,18 @@ function getNonHeldAndFullAmount(iouReport: OnyxEntry, policy: OnyxEntry if (hasUpdatedTotal(iouReport, policy) && hasPendingTransaction) { const unheldTotal = reportTransactions.reduce((currentVal, transaction) => currentVal + (!TransactionUtils.isOnHold(transaction) ? transaction.amount : 0), 0); - return [ - CurrencyUtils.convertToDisplayString(unheldTotal * coefficient, iouReport?.currency), - CurrencyUtils.convertToDisplayString((iouReport?.total ?? 0) * coefficient, iouReport?.currency), - ]; + return { + nonHeldAmount: CurrencyUtils.convertToDisplayString(unheldTotal * coefficient, iouReport?.currency), + fullAmount: CurrencyUtils.convertToDisplayString((iouReport?.total ?? 0) * coefficient, iouReport?.currency), + hasValidNonHeldAmount: unheldTotal * coefficient >= 0, + }; } - return [ - CurrencyUtils.convertToDisplayString((iouReport?.unheldTotal ?? 0) * coefficient, iouReport?.currency), - CurrencyUtils.convertToDisplayString((iouReport?.total ?? 0) * coefficient, iouReport?.currency), - ]; + return { + nonHeldAmount: CurrencyUtils.convertToDisplayString((iouReport?.unheldTotal ?? 0) * coefficient, iouReport?.currency), + fullAmount: CurrencyUtils.convertToDisplayString((iouReport?.total ?? 0) * coefficient, iouReport?.currency), + hasValidNonHeldAmount: (iouReport?.unheldTotal ?? 0) * coefficient >= 0, + }; } /** @@ -8493,7 +8478,6 @@ export { chatIncludesConcierge, createDraftTransactionAndNavigateToParticipantSelector, doesReportBelongToWorkspace, - doesTransactionThreadHaveViolations, findLastAccessedReport, findSelfDMReportID, formatReportLastMessageText, @@ -8597,6 +8581,7 @@ export { hasUpdatedTotal, hasViolations, hasWarningTypeViolations, + hasNoticeTypeViolations, isActionCreator, isAdminRoom, isAdminsOnlyPostingRoom, @@ -8698,7 +8683,7 @@ export { shouldDisableRename, shouldDisableThread, shouldDisplayThreadReplies, - shouldDisplayTransactionThreadViolations, + shouldDisplayViolationsRBRInLHN, shouldReportBeInOptionList, shouldReportShowSubscript, shouldShowFlagComment, @@ -8746,7 +8731,6 @@ export { buildOptimisticChangeFieldAction, isPolicyRelatedReport, hasReportErrorsOtherThanFailedReceipt, - shouldShowViolations, getAllReportErrors, getAllReportActionsErrorsAndReportActionThatRequiresAttention, hasInvoiceReports, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index d47cee3745a0..b8acec00af05 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -110,7 +110,7 @@ function getOrderedReportIDs( return; } const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '-1', report?.parentReportActionID ?? '-1'); - const doesReportHaveViolations = ReportUtils.shouldShowViolations(report, transactionViolations); + const doesReportHaveViolations = ReportUtils.shouldDisplayViolationsRBRInLHN(report, transactionViolations); const isHidden = ReportUtils.getReportNotificationPreference(report) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; const isFocused = report.reportID === currentReportId; const hasErrorsOtherThanFailedReceipt = ReportUtils.hasReportErrorsOtherThanFailedReceipt(report, doesReportHaveViolations, transactionViolations); @@ -239,22 +239,11 @@ function getReasonAndReportActionThatHasRedBrickRoad( ): ReasonAndReportActionThatHasRedBrickRoad | null { const {errors, reportAction} = ReportUtils.getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions); const hasErrors = Object.keys(errors).length !== 0; - const oneTransactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, ReportActionsUtils.getAllReportActions(report.reportID)); - if (oneTransactionThreadReportID) { - const oneTransactionThreadReport = ReportUtils.getReport(oneTransactionThreadReportID); - - if ( - ReportUtils.shouldDisplayTransactionThreadViolations( - oneTransactionThreadReport, - transactionViolations, - ReportActionsUtils.getAllReportActions(report.reportID)[oneTransactionThreadReport?.parentReportActionID ?? '-1'], - ) - ) { - return { - reason: CONST.RBR_REASONS.HAS_TRANSACTION_THREAD_VIOLATIONS, - }; - } + if (ReportUtils.shouldDisplayViolationsRBRInLHN(report, transactionViolations)) { + return { + reason: CONST.RBR_REASONS.HAS_TRANSACTION_THREAD_VIOLATIONS, + }; } if (hasErrors) { diff --git a/src/libs/TagsOptionsListUtils.ts b/src/libs/TagsOptionsListUtils.ts new file mode 100644 index 000000000000..e67c4378b245 --- /dev/null +++ b/src/libs/TagsOptionsListUtils.ts @@ -0,0 +1,170 @@ +import lodashSortBy from 'lodash/sortBy'; +import CONST from '@src/CONST'; +import type {PolicyTag, PolicyTagLists, PolicyTags} from '@src/types/onyx'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import localeCompare from './LocaleCompare'; +import * as Localize from './Localize'; +import type {Option} from './OptionsListUtils'; +import * as OptionsListUtils from './OptionsListUtils'; +import * as PolicyUtils from './PolicyUtils'; + +type SelectedTagOption = { + name: string; + enabled: boolean; + isSelected?: boolean; + accountID: number | undefined; + pendingAction?: PendingAction; +}; + +/** + * Transforms the provided tags into option objects. + * + * @param tags - an initial tag array + */ +function getTagsOptions(tags: Array>, selectedOptions?: SelectedTagOption[]): Option[] { + return tags.map((tag) => { + // This is to remove unnecessary escaping backslash in tag name sent from backend. + const cleanedName = PolicyUtils.getCleanedTagName(tag.name); + return { + text: cleanedName, + keyForList: tag.name, + searchText: tag.name, + tooltipText: cleanedName, + isDisabled: !tag.enabled || tag.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + isSelected: selectedOptions?.some((selectedTag) => selectedTag.name === tag.name), + pendingAction: tag.pendingAction, + }; + }); +} + +/** + * Build the section list for tags + */ +function getTagListSections({ + tags, + recentlyUsedTags = [], + selectedOptions = [], + searchValue = '', + maxRecentReportsToShow = CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, +}: { + tags: PolicyTags | Array; + recentlyUsedTags?: string[]; + selectedOptions?: SelectedTagOption[]; + searchValue?: string; + maxRecentReportsToShow?: number; +}) { + const tagSections = []; + const sortedTags = sortTags(tags); + + const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name); + const enabledTags = sortedTags.filter((tag) => tag.enabled); + const enabledTagsNames = enabledTags.map((tag) => tag.name); + const enabledTagsWithoutSelectedOptions = enabledTags.filter((tag) => !selectedOptionNames.includes(tag.name)); + const selectedTagsWithDisabledState: SelectedTagOption[] = []; + const numberOfTags = enabledTags.length; + + selectedOptions.forEach((tag) => { + if (enabledTagsNames.includes(tag.name)) { + selectedTagsWithDisabledState.push({...tag, enabled: true}); + return; + } + selectedTagsWithDisabledState.push({...tag, enabled: false}); + }); + + // If all tags are disabled but there's a previously selected tag, show only the selected tag + if (numberOfTags === 0 && selectedOptions.length > 0) { + tagSections.push({ + // "Selected" section + title: '', + shouldShow: false, + data: getTagsOptions(selectedTagsWithDisabledState, selectedOptions), + }); + + return tagSections; + } + + if (searchValue) { + const enabledSearchTags = enabledTagsWithoutSelectedOptions.filter((tag) => PolicyUtils.getCleanedTagName(tag.name.toLowerCase()).includes(searchValue.toLowerCase())); + const selectedSearchTags = selectedTagsWithDisabledState.filter((tag) => PolicyUtils.getCleanedTagName(tag.name.toLowerCase()).includes(searchValue.toLowerCase())); + const tagsForSearch = [...selectedSearchTags, ...enabledSearchTags]; + + tagSections.push({ + // "Search" section + title: '', + shouldShow: true, + data: getTagsOptions(tagsForSearch, selectedOptions), + }); + + return tagSections; + } + + if (numberOfTags < CONST.STANDARD_LIST_ITEM_LIMIT) { + tagSections.push({ + // "All" section when items amount less than the threshold + title: '', + shouldShow: false, + data: getTagsOptions([...selectedTagsWithDisabledState, ...enabledTagsWithoutSelectedOptions], selectedOptions), + }); + + return tagSections; + } + + const filteredRecentlyUsedTags = recentlyUsedTags + .filter((recentlyUsedTag) => { + const tagObject = sortedTags.find((tag) => tag.name === recentlyUsedTag); + return !!tagObject?.enabled && !selectedOptionNames.includes(recentlyUsedTag) && tagObject?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + }) + .map((tag) => ({name: tag, enabled: true})); + + if (selectedOptions.length) { + tagSections.push({ + // "Selected" section + title: '', + shouldShow: true, + data: getTagsOptions(selectedTagsWithDisabledState, selectedOptions), + }); + } + + if (filteredRecentlyUsedTags.length > 0) { + const cutRecentlyUsedTags = filteredRecentlyUsedTags.slice(0, maxRecentReportsToShow); + + tagSections.push({ + // "Recent" section + title: Localize.translateLocal('common.recent'), + shouldShow: true, + data: getTagsOptions(cutRecentlyUsedTags, selectedOptions), + }); + } + + tagSections.push({ + // "All" section when items amount more than the threshold + title: Localize.translateLocal('common.all'), + shouldShow: true, + data: getTagsOptions(enabledTagsWithoutSelectedOptions, selectedOptions), + }); + + return tagSections; +} + +/** + * Verifies that there is at least one enabled tag + */ +function hasEnabledTags(policyTagList: Array) { + const policyTagValueList = policyTagList + .filter((tag) => tag && tag.tags) + .map(({tags}) => Object.values(tags)) + .flat(); + + return OptionsListUtils.hasEnabledOptions(policyTagValueList); +} + +/** + * Sorts tags alphabetically by name. + */ +function sortTags(tags: Record | Array) { + // Use lodash's sortBy to ensure consistency with oldDot. + return lodashSortBy(tags, 'name', localeCompare) as PolicyTag[]; +} + +export {getTagsOptions, getTagListSections, hasEnabledTags, sortTags}; +export type {SelectedTagOption}; diff --git a/src/libs/TourUtils.ts b/src/libs/TourUtils.ts index a88ee47cc563..141ed6b23a2b 100644 --- a/src/libs/TourUtils.ts +++ b/src/libs/TourUtils.ts @@ -1,8 +1,8 @@ import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; -import type {OnboardingPurposeType} from '@src/CONST'; +import type {OnboardingPurpose} from '@src/CONST'; -function getNavatticURL(environment: ValueOf, introSelected?: OnboardingPurposeType) { +function getNavatticURL(environment: ValueOf, introSelected?: OnboardingPurpose) { const adminTourURL = environment === CONST.ENVIRONMENT.PRODUCTION ? CONST.NAVATTIC.ADMIN_TOUR_PRODUCTION : CONST.NAVATTIC.ADMIN_TOUR_STAGING; const employeeTourURL = environment === CONST.ENVIRONMENT.PRODUCTION ? CONST.NAVATTIC.EMPLOYEE_TOUR_PRODUCTION : CONST.NAVATTIC.EMPLOYEE_TOUR_STAGING; return introSelected === CONST.SELECTABLE_ONBOARDING_CHOICES.MANAGE_TEAM ? adminTourURL : employeeTourURL; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index aa46d28e6899..cb2c1a52e2d2 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -865,41 +865,37 @@ function isOnHoldByTransactionID(transactionID: string): boolean { /** * Checks if any violations for the provided transaction are of type 'violation' */ -function hasViolation(transactionID: string, transactionViolations: OnyxCollection): boolean { +function hasViolation(transactionID: string, transactionViolations: OnyxCollection, showInReview?: boolean): boolean { return !!transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some( - (violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.VIOLATION, + (violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.VIOLATION && (showInReview === undefined || showInReview === (violation.showInReview ?? false)), ); } /** * Checks if any violations for the provided transaction are of type 'notice' */ -function hasNoticeTypeViolation(transactionID: string, transactionViolations: OnyxCollection): boolean { - return !!transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some((violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.NOTICE); +function hasNoticeTypeViolation(transactionID: string, transactionViolations: OnyxCollection, showInReview?: boolean): boolean { + return !!transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some( + (violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.NOTICE && (showInReview === undefined || showInReview === (violation.showInReview ?? false)), + ); } /** * Checks if any violations for the provided transaction are of type 'warning' */ -function hasWarningTypeViolation(transactionID: string, transactionViolations: OnyxCollection): boolean { - const warningTypeViolations = transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.filter( - (violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.WARNING, - ); +function hasWarningTypeViolation(transactionID: string, transactionViolations: OnyxCollection, showInReview?: boolean | null): boolean { + const violations = transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]; + const warningTypeViolations = + violations?.filter( + (violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.WARNING && (showInReview === null || showInReview === (violation.showInReview ?? false)), + ) ?? []; + const hasOnlyDupeDetectionViolation = warningTypeViolations?.every((violation: TransactionViolation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION); if (!Permissions.canUseDupeDetection(allBetas ?? []) && hasOnlyDupeDetectionViolation) { return false; } - return !!warningTypeViolations && warningTypeViolations.length > 0; -} - -/** - * Checks if any violations for the provided transaction are of modifiedAmount or modifiedDate - */ -function hasModifiedAmountOrDateViolation(transactionID: string, transactionViolations: OnyxCollection): boolean { - return !!transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some( - (violation: TransactionViolation) => violation.name === CONST.VIOLATIONS.MODIFIED_AMOUNT || violation.name === CONST.VIOLATIONS.MODIFIED_DATE, - ); + return warningTypeViolations.length > 0; } /** @@ -1291,7 +1287,6 @@ export { shouldShowBrokenConnectionViolation, hasNoticeTypeViolation, hasWarningTypeViolation, - hasModifiedAmountOrDateViolation, isCustomUnitRateIDForP2P, getRateID, getTransaction, diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts index eb03d8b6def9..f1b79402f86f 100644 --- a/src/libs/WorkspacesSettingsUtils.ts +++ b/src/libs/WorkspacesSettingsUtils.ts @@ -65,9 +65,7 @@ const getBrickRoadForPolicy = (report: Report, altReportActions?: OnyxCollection let doesReportContainErrors = Object.keys(reportErrors ?? {}).length !== 0 ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined; if (!doesReportContainErrors) { - const parentReportActions = (altReportActions ?? allReportActions)?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.parentReportID}`]; - const parentReportAction = parentReportActions?.[report?.parentReportActionID ?? '-1']; - const shouldDisplayViolations = ReportUtils.shouldDisplayTransactionThreadViolations(report, allTransactionViolations, parentReportAction); + const shouldDisplayViolations = ReportUtils.shouldDisplayViolationsRBRInLHN(report, allTransactionViolations); const shouldDisplayReportViolations = ReportUtils.isReportOwner(report) && ReportUtils.hasReportViolations(report.reportID); const hasViolations = shouldDisplayViolations || shouldDisplayReportViolations; if (hasViolations) { @@ -78,13 +76,7 @@ const getBrickRoadForPolicy = (report: Report, altReportActions?: OnyxCollection if (oneTransactionThreadReportID && !doesReportContainErrors) { const oneTransactionThreadReport = ReportUtils.getReport(oneTransactionThreadReportID); - if ( - ReportUtils.shouldDisplayTransactionThreadViolations( - oneTransactionThreadReport, - allTransactionViolations, - reportActions[oneTransactionThreadReport?.parentReportActionID ?? '-1'], - ) - ) { + if (ReportUtils.shouldDisplayViolationsRBRInLHN(oneTransactionThreadReport, allTransactionViolations)) { doesReportContainErrors = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; } } diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index b04a5e49bea5..1c60d49e9170 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -392,6 +392,10 @@ function clearIssueNewCardFlow() { }); } +function clearIssueNewCardError(issueNewCard: IssueNewCardFlowData) { + Onyx.set(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, {...issueNewCard, errors: null}); +} + function updateExpensifyCardLimit(workspaceAccountID: number, cardID: number, newLimit: number, newAvailableSpend: number, oldLimit?: number, oldAvailableSpend?: number) { const authToken = NetworkStore.getAuthToken(); @@ -721,7 +725,7 @@ function configureExpensifyCardsForPolicy(policyID: string, bankAccountID?: numb }); } -function issueExpensifyCard(policyID: string, feedCountry: string, data?: IssueNewCardData) { +function issueExpensifyCard(policyID: string, feedCountry: string, validateCode: string, data?: IssueNewCardData) { if (!data) { return; } @@ -768,6 +772,7 @@ function issueExpensifyCard(policyID: string, feedCountry: string, data?: IssueN limit, limitType, cardTitle, + validateCode, }; if (cardType === CONST.EXPENSIFY_CARD.CARD_TYPE.PHYSICAL) { @@ -884,6 +889,7 @@ export { requestReplacementExpensifyCard, activatePhysicalExpensifyCard, clearCardListErrors, + clearIssueNewCardError, reportVirtualExpensifyCardFraud, revealVirtualCardDetails, updateSettlementFrequency, diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 5ec2e81b8c01..9abe98106d38 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -7372,12 +7372,12 @@ function completePaymentOnboarding(paymentSelected: ValueOf (quickAction = val), }); -let allReportDraftComments: Record = {}; +let introSelected: OnyxEntry = {}; +Onyx.connect({ + key: ONYXKEYS.NVP_INTRO_SELECTED, + callback: (val) => (introSelected = val), +}); +let allReportDraftComments: Record = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, waitForCollectionCallback: true, @@ -843,6 +856,39 @@ function openReport( accountIDList: participantAccountIDList ? participantAccountIDList.join(',') : '', parentReportActionID, }; + + const isInviteOnboardingComplete = introSelected?.isInviteOnboardingComplete ?? false; + + if (introSelected && !isInviteOnboardingComplete) { + const {choice, inviteType} = introSelected; + const isInviteIOUorInvoice = inviteType === CONST.ONBOARDING_INVITE_TYPES.IOU || inviteType === CONST.ONBOARDING_INVITE_TYPES.INVOICE; + const isInviteChoiceCorrect = choice === CONST.ONBOARDING_CHOICES.ADMIN || choice === CONST.ONBOARDING_CHOICES.SUBMIT || choice === CONST.ONBOARDING_CHOICES.CHAT_SPLIT; + + if (isInviteChoiceCorrect && !isInviteIOUorInvoice) { + const onboardingMessage = CONST.ONBOARDING_MESSAGES[choice]; + if (choice === CONST.ONBOARDING_CHOICES.CHAT_SPLIT) { + const updatedTasks = onboardingMessage.tasks.map((task) => (task.type === 'startChat' ? {...task, autoCompleted: true} : task)); + onboardingMessage.tasks = updatedTasks; + } + + const onboardingData = prepareOnboardingOptimisticData(choice, onboardingMessage); + + optimisticData.push(...onboardingData.optimisticData, { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_INTRO_SELECTED, + value: { + isInviteOnboardingComplete: true, + }, + }); + + successData.push(...onboardingData.successData); + + failureData.push(...onboardingData.failureData); + + parameters.guidedSetupData = JSON.stringify(onboardingData.guidedSetupData); + } + } + const isGroupChat = ReportUtils.isGroupChat(newReportObject); if (isGroupChat) { parameters.chatType = CONST.REPORT.CHAT_TYPE.GROUP; @@ -993,11 +1039,7 @@ function openReport( } else { // eslint-disable-next-line rulesdir/no-multiple-api-calls API.paginate(CONST.API_REQUEST_TYPE.WRITE, WRITE_COMMANDS.OPEN_REPORT, parameters, {optimisticData, successData, failureData}, paginationConfig, { - checkAndFixConflictingRequest: (persistedRequests) => - resolveDuplicationConflictAction( - persistedRequests, - (request) => request.command === WRITE_COMMANDS.OPEN_REPORT && request.data?.reportID === reportID && request.data?.emailList === parameters.emailList, - ), + checkAndFixConflictingRequest: (persistedRequests) => resolveOpenReportDuplicationConflictAction(persistedRequests, parameters), }); } } @@ -3415,16 +3457,12 @@ function getReportPrivateNote(reportID: string | undefined) { API.read(READ_COMMANDS.GET_REPORT_PRIVATE_NOTE, parameters, {optimisticData, successData, failureData}); } -function completeOnboarding( - engagementChoice: OnboardingPurposeType, +function prepareOnboardingOptimisticData( + engagementChoice: OnboardingPurpose, data: ValueOf, - firstName = '', - lastName = '', adminsChatReportID?: string, onboardingPolicyID?: string, - paymentSelected?: string, - companySize?: OnboardingCompanySizeType, - userReportedIntegration?: OnboardingAccountingType, + userReportedIntegration?: OnboardingAccounting, ) { // If the user has the "combinedTrackSubmit" beta enabled we'll show different tasks for track and submit expense. if (Permissions.canUseCombinedTrackSubmit()) { @@ -3839,6 +3877,28 @@ function completeOnboarding( guidedSetupData.push(...tasksForParameters); + return {optimisticData, successData, failureData, guidedSetupData, actorAccountID}; +} + +function completeOnboarding( + engagementChoice: OnboardingPurpose, + data: ValueOf, + firstName = '', + lastName = '', + adminsChatReportID?: string, + onboardingPolicyID?: string, + paymentSelected?: string, + companySize?: OnboardingCompanySize, + userReportedIntegration?: OnboardingAccounting, +) { + const {optimisticData, successData, failureData, guidedSetupData, actorAccountID} = prepareOnboardingOptimisticData( + engagementChoice, + data, + adminsChatReportID, + onboardingPolicyID, + userReportedIntegration, + ); + const parameters: CompleteGuidedSetupParams = { engagementChoice, firstName, diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index e363ff02a127..7e1092016e28 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -1,6 +1,6 @@ import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import type {UpdateCommentParams} from '@libs/API/parameters'; +import type {OpenReportParams, UpdateCommentParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type OnyxRequest from '@src/types/onyx/Request'; @@ -50,6 +50,37 @@ function resolveDuplicationConflictAction(persistedRequests: OnyxRequest[], requ }; } +function resolveOpenReportDuplicationConflictAction(persistedRequests: OnyxRequest[], parameters: OpenReportParams): ConflictActionData { + for (let index = 0; index < persistedRequests.length; index++) { + const request = persistedRequests.at(index); + if (request && request.command === WRITE_COMMANDS.OPEN_REPORT && request.data?.reportID === parameters.reportID && request.data?.emailList === parameters.emailList) { + // If the previous request had guided setup data, we can safely ignore the new request + if (request.data.guidedSetupData) { + return { + conflictAction: { + type: 'noAction', + }, + }; + } + + // In other cases it's safe to replace the previous request with the new one + return { + conflictAction: { + type: 'replace', + index, + }, + }; + } + } + + // If we didn't find any request to replace, we should push the new request + return { + conflictAction: { + type: 'push', + }, + }; +} + function resolveCommentDeletionConflicts(persistedRequests: OnyxRequest[], reportActionID: string, originalReportID: string): ConflictActionData { const commentIndicesToDelete: number[] = []; const commentCouldBeThread: Record = {}; @@ -144,4 +175,10 @@ function resolveEditCommentWithNewAddCommentRequest(persistedRequests: OnyxReque } as ConflictActionData; } -export {resolveDuplicationConflictAction, resolveCommentDeletionConflicts, resolveEditCommentWithNewAddCommentRequest, createUpdateCommentMatcher}; +export { + resolveDuplicationConflictAction, + resolveOpenReportDuplicationConflictAction, + resolveCommentDeletionConflicts, + resolveEditCommentWithNewAddCommentRequest, + createUpdateCommentMatcher, +}; diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index c5a2442048fc..664bdb3779a6 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -312,6 +312,10 @@ function createTaskAndNavigate( API.write(WRITE_COMMANDS.CREATE_TASK, parameters, {optimisticData, successData, failureData}); + ReportConnection.updateReportData(parentReportID, { + lastReadTime: currentTime, + }); + if (!isCreatedUsingMarkdown) { clearOutTaskInfo(); Navigation.dismissModal(parentReportID); diff --git a/src/libs/actions/Welcome/index.ts b/src/libs/actions/Welcome/index.ts index 5a92ff5e5435..230b212292e2 100644 --- a/src/libs/actions/Welcome/index.ts +++ b/src/libs/actions/Welcome/index.ts @@ -4,8 +4,9 @@ import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import Log from '@libs/Log'; -import type {OnboardingCompanySizeType, OnboardingPurposeType} from '@src/CONST'; +import type {OnboardingCompanySize} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnboardingPurpose} from '@src/types/onyx'; import type Onboarding from '@src/types/onyx/Onboarding'; import type TryNewDot from '@src/types/onyx/TryNewDot'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -90,15 +91,15 @@ function checkOnboardingDataReady() { resolveOnboardingFlowStatus(); } -function setOnboardingCustomChoices(value: OnboardingPurposeType[]) { +function setOnboardingCustomChoices(value: OnboardingPurpose[]) { Onyx.set(ONYXKEYS.ONBOARDING_CUSTOM_CHOICES, value ?? []); } -function setOnboardingPurposeSelected(value: OnboardingPurposeType) { +function setOnboardingPurposeSelected(value: OnboardingPurpose) { Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, value ?? null); } -function setOnboardingCompanySize(value: OnboardingCompanySizeType) { +function setOnboardingCompanySize(value: OnboardingCompanySize) { Onyx.set(ONYXKEYS.ONBOARDING_COMPANY_SIZE, value); } diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx index 5fa26cbf1835..67fa0a6c5113 100644 --- a/src/pages/Debug/Report/DebugReportPage.tsx +++ b/src/pages/Debug/Report/DebugReportPage.tsx @@ -53,15 +53,13 @@ function DebugReportPage({ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.parentReportID ?? '-1'}`); - const parentReportAction = parentReportActions && report?.parentReportID ? parentReportActions[report?.parentReportActionID ?? '-1'] : undefined; const metadata = useMemo(() => { if (!report) { return []; } - const shouldDisplayViolations = ReportUtils.shouldDisplayTransactionThreadViolations(report, transactionViolations, parentReportAction); + const shouldDisplayViolations = ReportUtils.shouldDisplayViolationsRBRInLHN(report, transactionViolations); const shouldDisplayReportViolations = ReportUtils.isReportOwner(report) && ReportUtils.hasReportViolations(reportID); const hasViolations = !!shouldDisplayViolations || shouldDisplayReportViolations; const {reason: reasonGBR, reportAction: reportActionGBR} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(report) ?? {}; @@ -113,7 +111,7 @@ function DebugReportPage({ : undefined, }, ]; - }, [parentReportAction, report, reportActions, reportID, transactionViolations, translate]); + }, [report, reportActions, reportID, transactionViolations, translate]); if (!report) { return ; diff --git a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx index 6e3e4c62aeda..faf531765edd 100644 --- a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx +++ b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx @@ -25,13 +25,13 @@ import * as Policy from '@userActions/Policy/Policy'; import * as Report from '@userActions/Report'; import * as Welcome from '@userActions/Welcome'; import CONST from '@src/CONST'; -import type {OnboardingAccountingType} from '@src/CONST'; +import type {OnboardingAccounting} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {} from '@src/types/onyx/Bank'; import type {BaseOnboardingAccountingProps} from './types'; type OnboardingListItem = ListItem & { - keyForList: OnboardingAccountingType; + keyForList: OnboardingAccounting; }; function BaseOnboardingAccounting({shouldUseNativeStyles, route}: BaseOnboardingAccountingProps) { @@ -51,7 +51,7 @@ function BaseOnboardingAccounting({shouldUseNativeStyles, route}: BaseOnboarding const {canUseDefaultRooms} = usePermissions(); const {activeWorkspaceID} = useActiveWorkspace(); - const [userReportedIntegration, setUserReportedIntegration] = useState(undefined); + const [userReportedIntegration, setUserReportedIntegration] = useState(undefined); const [error, setError] = useState(''); const isVsb = onboardingValues && 'signupQualifier' in onboardingValues && onboardingValues.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.VSB; diff --git a/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx b/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx index 1dd8807cc26e..fee409c6480d 100644 --- a/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx +++ b/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx @@ -15,13 +15,13 @@ import Navigation from '@libs/Navigation/Navigation'; import * as Policy from '@userActions/Policy/Policy'; import * as Welcome from '@userActions/Welcome'; import CONST from '@src/CONST'; -import type {OnboardingCompanySizeType} from '@src/CONST'; +import type {OnboardingCompanySize} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {BaseOnboardingEmployeesProps} from './types'; type OnboardingListItem = ListItem & { - keyForList: OnboardingCompanySizeType; + keyForList: OnboardingCompanySize; }; function BaseOnboardingEmployees({shouldUseNativeStyles, route}: BaseOnboardingEmployeesProps) { const styles = useThemeStyles(); @@ -30,7 +30,7 @@ function BaseOnboardingEmployees({shouldUseNativeStyles, route}: BaseOnboardingE const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED); const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID); const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); - const [selectedCompanySize, setSelectedCompanySize] = useState(onboardingCompanySize); + const [selectedCompanySize, setSelectedCompanySize] = useState(onboardingCompanySize); const [error, setError] = useState(''); const companySizeOptions: OnboardingListItem[] = useMemo(() => { diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 3b05c6bb40a8..74d2facf55dc 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -22,15 +22,15 @@ import type {TOnboardingRef} from '@libs/OnboardingRefManager'; import variables from '@styles/variables'; import * as Welcome from '@userActions/Welcome'; import CONST from '@src/CONST'; -import type {OnboardingPurposeType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {OnboardingPurpose} from '@src/types/onyx'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {BaseOnboardingPurposeProps} from './types'; const selectableOnboardingChoices = Object.values(CONST.SELECTABLE_ONBOARDING_CHOICES); -function getOnboardingChoices(customChoices: OnboardingPurposeType[]) { +function getOnboardingChoices(customChoices: OnboardingPurpose[]) { if (customChoices.length === 0) { return selectableOnboardingChoices; } diff --git a/src/pages/ReimbursementAccount/BankAccountStep.tsx b/src/pages/ReimbursementAccount/BankAccountStep.tsx index f6fab3056cf2..afa2c10bc68e 100644 --- a/src/pages/ReimbursementAccount/BankAccountStep.tsx +++ b/src/pages/ReimbursementAccount/BankAccountStep.tsx @@ -227,7 +227,8 @@ function BankAccountStep({ { + return ReportConnection.getReport(report.reportID)?.lastReadTime ?? report.lastReadTime ?? ''; + }, [report.reportID, report.lastReadTime]); + /** * The timestamp for the unread marker. * @@ -212,9 +217,9 @@ function ReportActionsList({ * - marks a message as read/unread * - reads a new message as it is received */ - const [unreadMarkerTime, setUnreadMarkerTime] = useState(report.lastReadTime ?? ''); + const [unreadMarkerTime, setUnreadMarkerTime] = useState(reportLastReadTime); useEffect(() => { - setUnreadMarkerTime(report.lastReadTime ?? ''); + setUnreadMarkerTime(reportLastReadTime); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [report.reportID]); diff --git a/src/pages/home/report/SystemChatReportFooterMessage.tsx b/src/pages/home/report/SystemChatReportFooterMessage.tsx index 4775b7ff6487..a000550751e3 100644 --- a/src/pages/home/report/SystemChatReportFooterMessage.tsx +++ b/src/pages/home/report/SystemChatReportFooterMessage.tsx @@ -1,6 +1,5 @@ import React, {useMemo} from 'react'; -import {useOnyx, withOnyx} from 'react-native-onyx'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import Banner from '@components/Banner'; import * as Expensicons from '@components/Icon/Expensicons'; import Text from '@components/Text'; @@ -10,29 +9,17 @@ import useThemeStyles from '@hooks/useThemeStyles'; import * as PolicyUtils from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; import * as ReportInstance from '@userActions/Report'; -import type {OnboardingPurposeType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Policy as PolicyType} from '@src/types/onyx'; -type SystemChatReportFooterMessageOnyxProps = { - /** Saved onboarding purpose selected by the user */ - choice: OnyxEntry; - - /** The list of this user's policies */ - policies: OnyxCollection; - - /** policyID for main workspace */ - activePolicyID: OnyxEntry>; -}; - -type SystemChatReportFooterMessageProps = SystemChatReportFooterMessageOnyxProps; - -function SystemChatReportFooterMessage({choice, policies, activePolicyID}: SystemChatReportFooterMessageProps) { +function SystemChatReportFooterMessage() { const {translate} = useLocalize(); const styles = useThemeStyles(); const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email}); + const [choice] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const adminChatReportID = useMemo(() => { const adminPolicy = activePolicyID @@ -88,14 +75,4 @@ function SystemChatReportFooterMessage({choice, policies, activePolicyID}: Syste SystemChatReportFooterMessage.displayName = 'SystemChatReportFooterMessage'; -export default withOnyx({ - choice: { - key: ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, - }, - policies: { - key: ONYXKEYS.COLLECTION.POLICY, - }, - activePolicyID: { - key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, - }, -})(SystemChatReportFooterMessage); +export default SystemChatReportFooterMessage; diff --git a/src/pages/iou/request/step/IOURequestStepTag.tsx b/src/pages/iou/request/step/IOURequestStepTag.tsx index 6c999d7a7f70..6080a4fa3d8d 100644 --- a/src/pages/iou/request/step/IOURequestStepTag.tsx +++ b/src/pages/iou/request/step/IOURequestStepTag.tsx @@ -12,10 +12,10 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as IOUUtils from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; -import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import * as TagsOptionsListUtils from '@libs/TagsOptionsListUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; @@ -64,7 +64,7 @@ function IOURequestStepTag({ const canEditSplitBill = isSplitBill && reportAction && session?.accountID === reportAction.actorAccountID && TransactionUtils.areRequiredFieldsEmpty(transaction); const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]); - const shouldShowTag = transactionTag || OptionsListUtils.hasEnabledTags(policyTagLists); + const shouldShowTag = transactionTag || TagsOptionsListUtils.hasEnabledTags(policyTagLists); // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx index 24325e15beca..4eaaec665df2 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx @@ -269,7 +269,7 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) { setIsValidateCodeActionModalVisible(false); }} sendValidateCode={() => User.requestContactMethodValidateCode(contactMethod)} - description={translate('contacts.enterMagicCode', {contactMethod})} + descriptionPrimary={translate('contacts.enterMagicCode', {contactMethod})} /> {!isValidateCodeActionModalVisible && getMenuItems()} diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx index c2a7e1b6712c..9d6f167fc04e 100644 --- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx +++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx @@ -171,7 +171,7 @@ function NewContactMethodPage({route}: NewContactMethodPageProps) { hasMagicCodeBeenSent={!!loginData?.validateCodeSent} title={translate('delegate.makeSureItIsYou')} sendValidateCode={() => User.requestValidateCodeAction()} - description={translate('contacts.enterMagicCode', {contactMethod})} + descriptionPrimary={translate('contacts.enterMagicCode', {contactMethod})} /> diff --git a/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx b/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx index e466b862ae9a..6c108403c36b 100644 --- a/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx +++ b/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx @@ -56,7 +56,7 @@ function DelegateMagicCodeModal({login, role, onClose, isValidateCodeActionModal sendValidateCode={() => User.requestValidateCodeAction()} hasMagicCodeBeenSent={!!currentDelegate?.validateCodeSent} handleSubmitForm={(validateCode) => Delegate.addDelegate(login, role, validateCode)} - description={translate('delegate.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})} + descriptionPrimary={translate('delegate.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})} /> ); } diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.tsx b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.tsx index 0e0d1919423d..e104397a7936 100644 --- a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.tsx @@ -1,3 +1,5 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; import React, {useEffect, useMemo, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; @@ -15,10 +17,11 @@ import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {READ_COMMANDS} from '@libs/API/types'; import Clipboard from '@libs/Clipboard'; import * as ErrorUtils from '@libs/ErrorUtils'; import localFileDownload from '@libs/localFileDownload'; -import type {BackToParams} from '@libs/Navigation/types'; +import type {BackToParams, SettingsNavigatorParamList} from '@libs/Navigation/types'; import StepWrapper from '@pages/settings/Security/TwoFactorAuth/StepWrapper/StepWrapper'; import useTwoFactorAuthContext from '@pages/settings/Security/TwoFactorAuth/TwoFactorAuthContext/useTwoFactorAuth'; import * as Session from '@userActions/Session'; @@ -26,6 +29,7 @@ import * as TwoFactorAuthActions from '@userActions/TwoFactorAuthActions'; import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; type CodesStepProps = BackToParams; @@ -45,6 +49,7 @@ function CodesStep({backTo}: CodesStepProps) { const isUserValidated = user?.validated; const contactMethod = account?.primaryLogin ?? ''; + const route = useRoute>(); const loginData = useMemo(() => loginList?.[contactMethod], [loginList, contactMethod]); const validateLoginError = ErrorUtils.getEarliestErrorField(loginData, 'validateLogin'); @@ -70,7 +75,9 @@ function CodesStep({backTo}: CodesStepProps) { text: translate('twoFactorAuth.stepCodes'), total: 3, }} - onBackButtonPress={() => TwoFactorAuthActions.quitAndNavigateBack(backTo)} + // When the 2FA code step is open from Xero flow, we don't need to pass backTo because we build the necessary root route + // from the backTo param in the route (in getMatchingRootRouteForRHPRoute) and goBack will not need a fallbackRoute. + onBackButtonPress={() => TwoFactorAuthActions.quitAndNavigateBack(route?.params?.forwardTo?.includes(READ_COMMANDS.CONNECT_POLICY_TO_XERO) ? '' : backTo)} > {!!isUserValidated && ( @@ -163,7 +170,8 @@ function CodesStep({backTo}: CodesStepProps) { )} diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx index 676083f31004..f409241d7f3e 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.tsx +++ b/src/pages/settings/Wallet/PaymentMethodList.tsx @@ -226,7 +226,7 @@ function PaymentMethodList({ if (!CardUtils.isExpensifyCard(card.cardID)) { assignedCardsGrouped.push({ key: card.cardID.toString(), - title: card.cardName, + title: CardUtils.maskCardNumber(card.cardName ?? ''), description: getDescriptionForPolicyDomainCard(card.domainName), shouldShowRightIcon: false, interactive: false, diff --git a/src/pages/settings/Wallet/ReportCardLostPage.tsx b/src/pages/settings/Wallet/ReportCardLostPage.tsx index ec74ea400a13..8e5ef65b82ae 100644 --- a/src/pages/settings/Wallet/ReportCardLostPage.tsx +++ b/src/pages/settings/Wallet/ReportCardLostPage.tsx @@ -81,6 +81,7 @@ function ReportCardLostPage({ const {paddingBottom} = useStyledSafeAreaInsets(); const formattedAddress = PersonalDetailsUtils.getFormattedAddress(privatePersonalDetails ?? {}); + const primaryLogin = account?.primaryLogin ?? ''; useEffect(() => { if (!isEmptyObject(physicalCard?.errors) || !(prevIsLoading && !formData?.isLoading)) { @@ -132,8 +133,6 @@ function ReportCardLostPage({ }; const sendValidateCode = () => { - const primaryLogin = account?.primaryLogin ?? ''; - if (loginList?.[primaryLogin]?.validateCodeSent) { return; } @@ -201,7 +200,7 @@ function ReportCardLostPage({ onClose={() => setIsValidateCodeActionModalVisible(false)} isVisible={isValidateCodeActionModalVisible} title={translate('cardPage.validateCardTitle')} - description={translate('cardPage.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})} + descriptionPrimary={translate('cardPage.enterMagicCode', {contactMethod: primaryLogin})} /> ) : ( diff --git a/src/pages/settings/Wallet/ReportVirtualCardFraudPage.tsx b/src/pages/settings/Wallet/ReportVirtualCardFraudPage.tsx index b1673a8bd51c..4c031ab382e7 100644 --- a/src/pages/settings/Wallet/ReportVirtualCardFraudPage.tsx +++ b/src/pages/settings/Wallet/ReportVirtualCardFraudPage.tsx @@ -108,7 +108,7 @@ function ReportVirtualCardFraudPage({ onClose={() => setIsValidateCodeActionModalVisible(false)} isVisible={isValidateCodeActionModalVisible} title={translate('cardPage.validateCardTitle')} - description={translate('cardPage.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})} + descriptionPrimary={translate('cardPage.enterMagicCode', {contactMethod: primaryLogin})} hasMagicCodeBeenSent={!!loginData?.validateCodeSent} /> diff --git a/src/pages/settings/Wallet/VerifyAccountPage.tsx b/src/pages/settings/Wallet/VerifyAccountPage.tsx index 3bd3c2aa7000..f8e362609d61 100644 --- a/src/pages/settings/Wallet/VerifyAccountPage.tsx +++ b/src/pages/settings/Wallet/VerifyAccountPage.tsx @@ -21,7 +21,6 @@ function VerifyAccountPage({route}: VerifyAccountPageProps) { const loginData = loginList?.[contactMethod]; const validateLoginError = ErrorUtils.getEarliestErrorField(loginData, 'validateLogin'); const [isUserValidated] = useOnyx(ONYXKEYS.USER, {selector: (user) => !!user?.validated}); - const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID ?? 0}); const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(true); @@ -31,9 +30,9 @@ function VerifyAccountPage({route}: VerifyAccountPageProps) { const handleSubmitForm = useCallback( (validateCode: string) => { - User.validateLogin(accountID ?? 0, validateCode); + User.validateSecondaryLogin(loginList, contactMethod, validateCode); }, - [accountID], + [loginList, contactMethod], ); const clearError = useCallback(() => { @@ -74,7 +73,8 @@ function VerifyAccountPage({route}: VerifyAccountPageProps) { hasMagicCodeBeenSent={!!loginData?.validateCodeSent} isVisible={isValidateCodeActionModalVisible} title={translate('contacts.validateAccount')} - description={translate('contacts.featureRequiresValidate')} + descriptionPrimary={translate('contacts.featureRequiresValidate')} + descriptionSecondary={translate('contacts.enterMagicCode', {contactMethod})} onClose={() => setIsValidateCodeActionModalVisible(false)} clearError={clearError} /> diff --git a/src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx b/src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx index 3dfcf4e80f74..6dc6cd9ebcdb 100644 --- a/src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx +++ b/src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx @@ -112,6 +112,7 @@ function CardReconciliationPage({policy, route}: CardReconciliationPageProps) { title={bankAccountTitle} description={translate('workspace.accounting.reconciliationAccount')} shouldShowRightIcon + onPress={() => Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS.getRoute(policyID, connection))} /> )} diff --git a/src/pages/workspace/accounting/reconciliation/ReconciliationAccountSettingsPage.tsx b/src/pages/workspace/accounting/reconciliation/ReconciliationAccountSettingsPage.tsx index f79b7920f77b..573aa4e478bf 100644 --- a/src/pages/workspace/accounting/reconciliation/ReconciliationAccountSettingsPage.tsx +++ b/src/pages/workspace/accounting/reconciliation/ReconciliationAccountSettingsPage.tsx @@ -57,7 +57,7 @@ function ReconciliationAccountSettingsPage({route}: ReconciliationAccountSetting const selectBankAccount = (newBankAccountID?: number) => { Card.updateSettlementAccount(workspaceAccountID, policyID, newBankAccountID, paymentBankAccountID); - Navigation.goBack(); + Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING_CARD_RECONCILIATION.getRoute(policyID, connection)); }; return ( @@ -70,6 +70,7 @@ function ReconciliationAccountSettingsPage({route}: ReconciliationAccountSetting contentContainerStyle={[styles.flex1, styles.pb2]} connectionName={connectionName} shouldUseScrollView={false} + onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING_CARD_RECONCILIATION.getRoute(policyID, connection))} > {translate('workspace.accounting.chooseReconciliationAccount.chooseBankAccount')} diff --git a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx index 0170ff04ca6a..4de62f4285f6 100644 --- a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useRef} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; @@ -6,6 +6,7 @@ import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; +import ValidateCodeActionModal from '@components/ValidateCodeActionModal'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -15,6 +16,7 @@ import * as ErrorUtils from '@libs/ErrorUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import Navigation from '@navigation/Navigation'; import * as Card from '@userActions/Card'; +import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -33,11 +35,14 @@ function ConfirmationStep({policyID, backTo}: ConfirmationStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); - + const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [issueNewCard] = useOnyx(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD); - + const [validateCodeAction] = useOnyx(ONYXKEYS.VALIDATE_ACTION_CODE); + const validateError = ErrorUtils.getLatestErrorMessageField(issueNewCard); + const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(false); const data = issueNewCard?.data; const isSuccessful = issueNewCard?.isSuccessful; + const validateCodeSent = validateCodeAction?.validateCodeSent; const submitButton = useRef(null); @@ -53,8 +58,8 @@ function ConfirmationStep({policyID, backTo}: ConfirmationStepProps) { Card.clearIssueNewCardFlow(); }, [backTo, policyID, isSuccessful]); - const submit = () => { - Card.issueExpensifyCard(policyID, CONST.COUNTRY.US, data); + const submit = (validateCode: string) => { + Card.issueExpensifyCard(policyID, CONST.COUNTRY.US, validateCode, data); }; const errorMessage = ErrorUtils.getLatestErrorMessage(issueNewCard); @@ -122,11 +127,32 @@ function ConfirmationStep({policyID, backTo}: ConfirmationStepProps) { isAlertVisible={!!errorMessage} isDisabled={isOffline} isLoading={issueNewCard?.isLoading} - onSubmit={submit} + onSubmit={() => setIsValidateCodeActionModalVisible(true)} buttonText={translate('workspace.card.issueCard')} /> + {!!issueNewCard && ( + User.requestValidateCodeAction()} + validateError={validateError} + hasMagicCodeBeenSent={validateCodeSent} + clearError={() => { + Card.clearIssueNewCardError(issueNewCard); + }} + onClose={() => { + if (validateError) { + Card.clearIssueNewCardError(issueNewCard); + } + setIsValidateCodeActionModalVisible(false); + }} + isVisible={isValidateCodeActionModalVisible} + title={translate('cardPage.validateCardTitle')} + descriptionPrimary={translate('cardPage.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})} + /> + )} ); } diff --git a/src/types/form/DebugReportForm.ts b/src/types/form/DebugReportForm.ts index b16f3b93f7f0..c21ca6f2dc73 100644 --- a/src/types/form/DebugReportForm.ts +++ b/src/types/form/DebugReportForm.ts @@ -28,7 +28,6 @@ const INPUT_IDS = { NOTIFICATION_PREFERENCE: 'notificationPreference', OLD_POLICY_NAME: 'oldPolicyName', OWNER_ACCOUNT_ID: 'ownerAccountID', - PARTICIPANT_ACCOUNT_IDS: 'participantAccountIDs', PARTICIPANTS: 'participants', PERMISSIONS: 'permissions', POLICY_AVATAR: 'policyAvatar', @@ -78,7 +77,6 @@ type DebugReportForm = Form< [INPUT_IDS.NOTIFICATION_PREFERENCE]: ValueOf; [INPUT_IDS.OLD_POLICY_NAME]: string; [INPUT_IDS.OWNER_ACCOUNT_ID]: string; - [INPUT_IDS.PARTICIPANT_ACCOUNT_IDS]: string; [INPUT_IDS.PARTICIPANTS]: string; [INPUT_IDS.PERMISSIONS]: string; [INPUT_IDS.POLICY_AVATAR]: string; diff --git a/src/types/onyx/IntroSelected.ts b/src/types/onyx/IntroSelected.ts index 0e1b4ec60ae4..edeb79d34113 100644 --- a/src/types/onyx/IntroSelected.ts +++ b/src/types/onyx/IntroSelected.ts @@ -1,15 +1,16 @@ -import type {OnboardingInviteType, OnboardingPurposeType} from '@src/CONST'; +import type {OnboardingInvite} from '@src/CONST'; +import type {OnboardingPurpose} from './index'; /** Model of onboarding */ -type IntroSelected = Partial<{ +type IntroSelected = { /** The choice that the user selected in the engagement modal */ - choice: OnboardingPurposeType; + choice?: OnboardingPurpose; /** The invite type */ - inviteType: OnboardingInviteType; + inviteType?: OnboardingInvite; /** Whether the onboarding is complete */ - isInviteOnboardingComplete: boolean; -}>; + isInviteOnboardingComplete?: boolean; +}; export default IntroSelected; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 1dffac0701ea..cb02c5f5751a 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -3,7 +3,6 @@ import type CONST from '@src/CONST'; import type ONYXKEYS from '@src/ONYXKEYS'; import type CollectionDataSet from '@src/types/utils/CollectionDataSet'; import type * as OnyxCommon from './OnyxCommon'; -import type PersonalDetails from './PersonalDetails'; import type {PolicyReportField} from './Policy'; /** Preference that defines how regular the chat notifications are sent to the user */ @@ -233,24 +232,12 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback< /** Whether the report is hidden from options list */ isHidden?: boolean; - /** Whether the report is a chat room */ - isChatRoom?: boolean; - - /** Collection of participants personal details */ - participantsList?: PersonalDetails[]; - - /** Text to be displayed in options list, which matches reportName by default */ - text?: string; - /** Collection of participant private notes, indexed by their accountID */ privateNotes?: Record; /** Whether participants private notes are being currently loaded */ isLoadingPrivateNotes?: boolean; - /** Whether the report is currently selected in the options list */ - selected?: boolean; - /** Pending members of the report */ pendingChatMembers?: PendingChatMember[]; @@ -278,9 +265,6 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback< /** Whether the report is archived */ // eslint-disable-next-line @typescript-eslint/naming-convention private_isArchived?: string; - - /** Participant account id's */ - participantAccountIDs?: number[]; }, PolicyReportField['fieldID'] >; diff --git a/src/types/onyx/TransactionViolation.ts b/src/types/onyx/TransactionViolation.ts index bf8ecc7ebdde..bfb215a1bbdb 100644 --- a/src/types/onyx/TransactionViolation.ts +++ b/src/types/onyx/TransactionViolation.ts @@ -92,6 +92,9 @@ type TransactionViolation = { /** Additional violation information to provide the user */ data?: TransactionViolationData; + + /** Indicates if this violation should be shown in review */ + showInReview?: boolean; }; /** Collection of transaction violations */ diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 7702da678105..79c2b4f230d4 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -1,3 +1,4 @@ +import type {OnboardingPurpose} from '@src/CONST'; import type Account from './Account'; import type AccountData from './AccountData'; import type {ApprovalWorkflowOnyx} from './ApprovalWorkflow'; @@ -236,5 +237,6 @@ export type { SaveSearch, RecentSearchItem, ImportedSpreadsheet, + OnboardingPurpose, ValidateMagicCodeAction, }; diff --git a/tests/unit/DebugUtilsTest.ts b/tests/unit/DebugUtilsTest.ts index c5d84341deee..abeaff971194 100644 --- a/tests/unit/DebugUtilsTest.ts +++ b/tests/unit/DebugUtilsTest.ts @@ -693,46 +693,6 @@ describe('DebugUtils', () => { }); expect(reason).toBe('debug.reasonVisibleInLHN.pinnedByUser'); }); - it('returns correct reason when report has IOU violations', async () => { - const threadReport = { - ...baseReport, - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - parentReportID: '0', - parentReportActionID: '0', - }; - await Onyx.multiSet({ - [ONYXKEYS.SESSION]: { - accountID: 1234, - }, - [`${ONYXKEYS.COLLECTION.REPORT}0` as const]: { - reportID: '0', - type: CONST.REPORT.TYPE.EXPENSE, - ownerAccountID: 1234, - }, - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}0` as const]: { - // eslint-disable-next-line @typescript-eslint/naming-convention - '0': { - reportActionID: '0', - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - message: { - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: '0', - IOUReportID: '0', - }, - }, - }, - [`${ONYXKEYS.COLLECTION.REPORT}1` as const]: threadReport, - [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}0` as const]: [ - { - type: CONST.VIOLATION_TYPES.VIOLATION, - name: CONST.VIOLATIONS.MODIFIED_AMOUNT, - }, - ], - }); - const reason = DebugUtils.getReasonForShowingRowInLHN(threadReport); - expect(reason).toBe('debug.reasonVisibleInLHN.hasIOUViolations'); - }); it('returns correct reason when report has add workspace room errors', () => { const reason = DebugUtils.getReasonForShowingRowInLHN({ ...baseReport, @@ -1530,28 +1490,13 @@ describe('DebugUtils', () => { ) ?? {}; expect(reason).toBe('debug.reasonRBR.hasViolations'); }); - it('returns correct reason when there are transaction thread violations', async () => { + it('returns correct reason when there are reports on the workspace chat with violations', async () => { const report: Report = { reportID: '0', - type: CONST.REPORT.TYPE.EXPENSE, + type: CONST.REPORT.TYPE.CHAT, ownerAccountID: 1234, - }; - const reportActions: ReportActions = { - // eslint-disable-next-line @typescript-eslint/naming-convention - '0': { - reportActionID: '0', - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - message: { - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: '0', - IOUReportID: '0', - amount: 10, - currency: CONST.CURRENCY.USD, - text: '', - }, - created: '2024-07-13 06:02:11.111', - childReportID: '1', - }, + policyID: '1', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, }; await Onyx.multiSet({ [ONYXKEYS.SESSION]: { @@ -1562,17 +1507,23 @@ describe('DebugUtils', () => { reportID: '1', parentReportActionID: '0', stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATE_NUM.SUBMITTED, + ownerAccountID: 1234, + policyID: '1', + }, + [`${ONYXKEYS.COLLECTION.TRANSACTION}1` as const]: { + transactionID: '1', + amount: 10, + modifiedAmount: 10, + reportID: '0', }, - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}0` as const]: reportActions, - [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}0` as const]: [ + [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}1` as const]: [ { type: CONST.VIOLATION_TYPES.VIOLATION, - name: CONST.VIOLATIONS.MODIFIED_AMOUNT, + name: CONST.VIOLATIONS.MISSING_CATEGORY, }, ], }); - const {reason} = DebugUtils.getReasonAndReportActionForRBRInLHNRow(report, reportActions, false) ?? {}; + const {reason} = DebugUtils.getReasonAndReportActionForRBRInLHNRow(report, {}, false) ?? {}; expect(reason).toBe('debug.reasonRBR.hasTransactionThreadViolations'); }); }); diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index 5a0cd6638a07..d4d66fd1fd62 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import type {SelectedTagOption} from '@components/TagPicker'; import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; import * as OptionsListUtils from '@src/libs/OptionsListUtils'; @@ -1146,320 +1145,6 @@ describe('OptionsListUtils', () => { expect(emptyResult.categoryOptions).toStrictEqual(emptySelectedResultList); }); - it('getFilteredOptions() for tags', () => { - const search = 'ing'; - const emptySearch = ''; - const wrongSearch = 'bla bla'; - const recentlyUsedTags = ['Engineering', 'HR']; - - const selectedOptions = [ - { - name: 'Medical', - }, - ]; - const smallTagsList: Record = { - Engineering: { - enabled: false, - name: 'Engineering', - accountID: undefined, - }, - Medical: { - enabled: true, - name: 'Medical', - accountID: undefined, - }, - Accounting: { - enabled: true, - name: 'Accounting', - accountID: undefined, - }, - HR: { - enabled: true, - name: 'HR', - accountID: undefined, - pendingAction: 'delete', - }, - }; - const smallResultList: OptionsListUtils.CategorySection[] = [ - { - title: '', - shouldShow: false, - // data sorted alphabetically by name - data: [ - { - text: 'Accounting', - keyForList: 'Accounting', - searchText: 'Accounting', - tooltipText: 'Accounting', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'HR', - keyForList: 'HR', - searchText: 'HR', - tooltipText: 'HR', - isDisabled: true, - isSelected: false, - pendingAction: 'delete', - }, - { - text: 'Medical', - keyForList: 'Medical', - searchText: 'Medical', - tooltipText: 'Medical', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - ], - }, - ]; - const smallSearchResultList: OptionsListUtils.CategorySection[] = [ - { - title: '', - shouldShow: true, - data: [ - { - text: 'Accounting', - keyForList: 'Accounting', - searchText: 'Accounting', - tooltipText: 'Accounting', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - ], - }, - ]; - const smallWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [ - { - title: '', - shouldShow: true, - data: [], - }, - ]; - const largeTagsList: Record = { - Engineering: { - enabled: false, - name: 'Engineering', - accountID: undefined, - }, - Medical: { - enabled: true, - name: 'Medical', - accountID: undefined, - }, - Accounting: { - enabled: true, - name: 'Accounting', - accountID: undefined, - }, - HR: { - enabled: true, - name: 'HR', - accountID: undefined, - }, - Food: { - enabled: true, - name: 'Food', - accountID: undefined, - }, - Traveling: { - enabled: false, - name: 'Traveling', - accountID: undefined, - }, - Cleaning: { - enabled: true, - name: 'Cleaning', - accountID: undefined, - }, - Software: { - enabled: true, - name: 'Software', - accountID: undefined, - }, - OfficeSupplies: { - enabled: false, - name: 'Office Supplies', - accountID: undefined, - }, - Taxes: { - enabled: true, - name: 'Taxes', - accountID: undefined, - pendingAction: 'delete', - }, - Benefits: { - enabled: true, - name: 'Benefits', - accountID: undefined, - }, - }; - const largeResultList: OptionsListUtils.CategorySection[] = [ - { - title: '', - shouldShow: true, - data: [ - { - text: 'Medical', - keyForList: 'Medical', - searchText: 'Medical', - tooltipText: 'Medical', - isDisabled: false, - isSelected: true, - pendingAction: undefined, - }, - ], - }, - { - title: 'Recent', - shouldShow: true, - data: [ - { - text: 'HR', - keyForList: 'HR', - searchText: 'HR', - tooltipText: 'HR', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - ], - }, - { - title: 'All', - shouldShow: true, - // data sorted alphabetically by name - data: [ - { - text: 'Accounting', - keyForList: 'Accounting', - searchText: 'Accounting', - tooltipText: 'Accounting', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Benefits', - keyForList: 'Benefits', - searchText: 'Benefits', - tooltipText: 'Benefits', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Cleaning', - keyForList: 'Cleaning', - searchText: 'Cleaning', - tooltipText: 'Cleaning', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Food', - keyForList: 'Food', - searchText: 'Food', - tooltipText: 'Food', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'HR', - keyForList: 'HR', - searchText: 'HR', - tooltipText: 'HR', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Software', - keyForList: 'Software', - searchText: 'Software', - tooltipText: 'Software', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Taxes', - keyForList: 'Taxes', - searchText: 'Taxes', - tooltipText: 'Taxes', - isDisabled: true, - isSelected: false, - pendingAction: 'delete', - }, - ], - }, - ]; - const largeSearchResultList: OptionsListUtils.CategorySection[] = [ - { - title: '', - shouldShow: true, - data: [ - { - text: 'Accounting', - keyForList: 'Accounting', - searchText: 'Accounting', - tooltipText: 'Accounting', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Cleaning', - keyForList: 'Cleaning', - searchText: 'Cleaning', - tooltipText: 'Cleaning', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - ], - }, - ]; - const largeWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [ - { - title: '', - shouldShow: true, - data: [], - }, - ]; - - const smallResult = OptionsListUtils.getFilteredOptions({searchValue: emptySearch, includeP2P: false, includeTags: true, tags: smallTagsList}); - expect(smallResult.tagOptions).toStrictEqual(smallResultList); - - const smallSearchResult = OptionsListUtils.getFilteredOptions({searchValue: search, includeP2P: false, includeTags: true, tags: smallTagsList}); - expect(smallSearchResult.tagOptions).toStrictEqual(smallSearchResultList); - - const smallWrongSearchResult = OptionsListUtils.getFilteredOptions({searchValue: wrongSearch, includeP2P: false, includeTags: true, tags: smallTagsList}); - expect(smallWrongSearchResult.tagOptions).toStrictEqual(smallWrongSearchResultList); - - const largeResult = OptionsListUtils.getFilteredOptions({searchValue: emptySearch, selectedOptions, includeP2P: false, includeTags: true, tags: largeTagsList, recentlyUsedTags}); - expect(largeResult.tagOptions).toStrictEqual(largeResultList); - - const largeSearchResult = OptionsListUtils.getFilteredOptions({searchValue: search, selectedOptions, includeP2P: false, includeTags: true, tags: largeTagsList, recentlyUsedTags}); - expect(largeSearchResult.tagOptions).toStrictEqual(largeSearchResultList); - - const largeWrongSearchResult = OptionsListUtils.getFilteredOptions({ - searchValue: wrongSearch, - selectedOptions, - includeP2P: false, - includeTags: true, - tags: largeTagsList, - recentlyUsedTags, - }); - expect(largeWrongSearchResult.tagOptions).toStrictEqual(largeWrongSearchResultList); - }); - it('getCategoryOptionTree()', () => { const categories = { Meals: { @@ -2200,230 +1885,6 @@ describe('OptionsListUtils', () => { expect(OptionsListUtils.sortCategories(categoriesIncorrectOrdering3)).toStrictEqual(result3); }); - it('sortTags', () => { - const createTagObjects = (names: string[]) => names.map((name) => ({name, enabled: true})); - - const unorderedTagNames = ['10bc', 'b', '0a', '1', '中国', 'b10', '!', '2', '0', '@', 'a1', 'a', '3', 'b1', '日本', '$', '20', '20a', '#', 'a20', 'c', '10']; - const expectedOrderNames = ['!', '#', '$', '0', '0a', '1', '10', '10bc', '2', '20', '20a', '3', '@', 'a', 'a1', 'a20', 'b', 'b1', 'b10', 'c', '中国', '日本']; - const unorderedTags = createTagObjects(unorderedTagNames); - const expectedOrder = createTagObjects(expectedOrderNames); - expect(OptionsListUtils.sortTags(unorderedTags)).toStrictEqual(expectedOrder); - - const unorderedTagNames2 = ['0', 'a1', '1', 'b1', '3', '10', 'b10', 'a', '2', 'c', '20', 'a20', 'b']; - const expectedOrderNames2 = ['0', '1', '10', '2', '20', '3', 'a', 'a1', 'a20', 'b', 'b1', 'b10', 'c']; - const unorderedTags2 = createTagObjects(unorderedTagNames2); - const expectedOrder2 = createTagObjects(expectedOrderNames2); - expect(OptionsListUtils.sortTags(unorderedTags2)).toStrictEqual(expectedOrder2); - - const unorderedTagNames3 = [ - '61', - '39', - '97', - '93', - '77', - '71', - '22', - '27', - '30', - '64', - '91', - '24', - '33', - '60', - '21', - '85', - '59', - '76', - '42', - '67', - '13', - '96', - '84', - '44', - '68', - '31', - '62', - '87', - '50', - '4', - '100', - '12', - '28', - '49', - '53', - '5', - '45', - '14', - '55', - '78', - '11', - '35', - '75', - '18', - '9', - '80', - '54', - '2', - '34', - '48', - '81', - '6', - '73', - '15', - '98', - '25', - '8', - '99', - '17', - '90', - '47', - '1', - '10', - '38', - '66', - '57', - '23', - '86', - '29', - '3', - '65', - '74', - '19', - '56', - '63', - '20', - '7', - '32', - '46', - '70', - '26', - '16', - '83', - '37', - '58', - '43', - '36', - '69', - '79', - '72', - '41', - '94', - '95', - '82', - '51', - '52', - '89', - '88', - '40', - '92', - ]; - const expectedOrderNames3 = [ - '1', - '10', - '100', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '19', - '2', - '20', - '21', - '22', - '23', - '24', - '25', - '26', - '27', - '28', - '29', - '3', - '30', - '31', - '32', - '33', - '34', - '35', - '36', - '37', - '38', - '39', - '4', - '40', - '41', - '42', - '43', - '44', - '45', - '46', - '47', - '48', - '49', - '5', - '50', - '51', - '52', - '53', - '54', - '55', - '56', - '57', - '58', - '59', - '6', - '60', - '61', - '62', - '63', - '64', - '65', - '66', - '67', - '68', - '69', - '7', - '70', - '71', - '72', - '73', - '74', - '75', - '76', - '77', - '78', - '79', - '8', - '80', - '81', - '82', - '83', - '84', - '85', - '86', - '87', - '88', - '89', - '9', - '90', - '91', - '92', - '93', - '94', - '95', - '96', - '97', - '98', - '99', - ]; - const unorderedTags3 = createTagObjects(unorderedTagNames3); - const expectedOrder3 = createTagObjects(expectedOrderNames3); - expect(OptionsListUtils.sortTags(unorderedTags3)).toStrictEqual(expectedOrder3); - }); - it('getFilteredOptions() for taxRate', () => { const search = 'rate'; const emptySearch = ''; diff --git a/tests/unit/TagsOptionsListUtilsTest.ts b/tests/unit/TagsOptionsListUtilsTest.ts new file mode 100644 index 000000000000..f3051c63be6a --- /dev/null +++ b/tests/unit/TagsOptionsListUtilsTest.ts @@ -0,0 +1,572 @@ +import type * as OptionsListUtils from '@libs/OptionsListUtils'; +import type {SelectedTagOption} from '@libs/TagsOptionsListUtils'; +import * as TagsOptionsListUtils from '@libs/TagsOptionsListUtils'; + +describe('TagsOptionsListUtils', () => { + it('getTagListSections()', () => { + const search = 'ing'; + const emptySearch = ''; + const wrongSearch = 'bla bla'; + const recentlyUsedTags = ['Engineering', 'HR']; + + const selectedOptions: SelectedTagOption[] = [ + { + name: 'Medical', + enabled: true, + accountID: undefined, + }, + ]; + const smallTagsList: Record = { + Engineering: { + enabled: false, + name: 'Engineering', + accountID: undefined, + }, + Medical: { + enabled: true, + name: 'Medical', + accountID: undefined, + }, + Accounting: { + enabled: true, + name: 'Accounting', + accountID: undefined, + }, + HR: { + enabled: true, + name: 'HR', + accountID: undefined, + pendingAction: 'delete', + }, + }; + const smallResultList: OptionsListUtils.CategorySection[] = [ + { + title: '', + shouldShow: false, + // data sorted alphabetically by name + data: [ + { + text: 'Accounting', + keyForList: 'Accounting', + searchText: 'Accounting', + tooltipText: 'Accounting', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'HR', + keyForList: 'HR', + searchText: 'HR', + tooltipText: 'HR', + isDisabled: true, + isSelected: false, + pendingAction: 'delete', + }, + { + text: 'Medical', + keyForList: 'Medical', + searchText: 'Medical', + tooltipText: 'Medical', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + ], + }, + ]; + const smallSearchResultList: OptionsListUtils.CategorySection[] = [ + { + title: '', + shouldShow: true, + data: [ + { + text: 'Accounting', + keyForList: 'Accounting', + searchText: 'Accounting', + tooltipText: 'Accounting', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + ], + }, + ]; + const smallWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [ + { + title: '', + shouldShow: true, + data: [], + }, + ]; + const largeTagsList: Record = { + Engineering: { + enabled: false, + name: 'Engineering', + accountID: undefined, + }, + Medical: { + enabled: true, + name: 'Medical', + accountID: undefined, + }, + Accounting: { + enabled: true, + name: 'Accounting', + accountID: undefined, + }, + HR: { + enabled: true, + name: 'HR', + accountID: undefined, + }, + Food: { + enabled: true, + name: 'Food', + accountID: undefined, + }, + Traveling: { + enabled: false, + name: 'Traveling', + accountID: undefined, + }, + Cleaning: { + enabled: true, + name: 'Cleaning', + accountID: undefined, + }, + Software: { + enabled: true, + name: 'Software', + accountID: undefined, + }, + OfficeSupplies: { + enabled: false, + name: 'Office Supplies', + accountID: undefined, + }, + Taxes: { + enabled: true, + name: 'Taxes', + accountID: undefined, + pendingAction: 'delete', + }, + Benefits: { + enabled: true, + name: 'Benefits', + accountID: undefined, + }, + }; + const largeResultList: OptionsListUtils.CategorySection[] = [ + { + title: '', + shouldShow: true, + data: [ + { + text: 'Medical', + keyForList: 'Medical', + searchText: 'Medical', + tooltipText: 'Medical', + isDisabled: false, + isSelected: true, + pendingAction: undefined, + }, + ], + }, + { + title: 'Recent', + shouldShow: true, + data: [ + { + text: 'HR', + keyForList: 'HR', + searchText: 'HR', + tooltipText: 'HR', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + ], + }, + { + title: 'All', + shouldShow: true, + // data sorted alphabetically by name + data: [ + { + text: 'Accounting', + keyForList: 'Accounting', + searchText: 'Accounting', + tooltipText: 'Accounting', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Benefits', + keyForList: 'Benefits', + searchText: 'Benefits', + tooltipText: 'Benefits', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Cleaning', + keyForList: 'Cleaning', + searchText: 'Cleaning', + tooltipText: 'Cleaning', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Food', + keyForList: 'Food', + searchText: 'Food', + tooltipText: 'Food', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'HR', + keyForList: 'HR', + searchText: 'HR', + tooltipText: 'HR', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Software', + keyForList: 'Software', + searchText: 'Software', + tooltipText: 'Software', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Taxes', + keyForList: 'Taxes', + searchText: 'Taxes', + tooltipText: 'Taxes', + isDisabled: true, + isSelected: false, + pendingAction: 'delete', + }, + ], + }, + ]; + const largeSearchResultList: OptionsListUtils.CategorySection[] = [ + { + title: '', + shouldShow: true, + data: [ + { + text: 'Accounting', + keyForList: 'Accounting', + searchText: 'Accounting', + tooltipText: 'Accounting', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Cleaning', + keyForList: 'Cleaning', + searchText: 'Cleaning', + tooltipText: 'Cleaning', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + ], + }, + ]; + const largeWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [ + { + title: '', + shouldShow: true, + data: [], + }, + ]; + + const smallResult = TagsOptionsListUtils.getTagListSections({searchValue: emptySearch, tags: smallTagsList}); + expect(smallResult).toStrictEqual(smallResultList); + + const smallSearchResult = TagsOptionsListUtils.getTagListSections({searchValue: search, tags: smallTagsList}); + expect(smallSearchResult).toStrictEqual(smallSearchResultList); + + const smallWrongSearchResult = TagsOptionsListUtils.getTagListSections({searchValue: wrongSearch, tags: smallTagsList}); + expect(smallWrongSearchResult).toStrictEqual(smallWrongSearchResultList); + + const largeResult = TagsOptionsListUtils.getTagListSections({searchValue: emptySearch, selectedOptions, tags: largeTagsList, recentlyUsedTags}); + expect(largeResult).toStrictEqual(largeResultList); + + const largeSearchResult = TagsOptionsListUtils.getTagListSections({searchValue: search, selectedOptions, tags: largeTagsList, recentlyUsedTags}); + expect(largeSearchResult).toStrictEqual(largeSearchResultList); + + const largeWrongSearchResult = TagsOptionsListUtils.getTagListSections({ + searchValue: wrongSearch, + selectedOptions, + tags: largeTagsList, + recentlyUsedTags, + }); + expect(largeWrongSearchResult).toStrictEqual(largeWrongSearchResultList); + }); + + it('sortTags', () => { + const createTagObjects = (names: string[]) => names.map((name) => ({name, enabled: true})); + + const unorderedTagNames = ['10bc', 'b', '0a', '1', '中国', 'b10', '!', '2', '0', '@', 'a1', 'a', '3', 'b1', '日本', '$', '20', '20a', '#', 'a20', 'c', '10']; + const expectedOrderNames = ['!', '#', '$', '0', '0a', '1', '10', '10bc', '2', '20', '20a', '3', '@', 'a', 'a1', 'a20', 'b', 'b1', 'b10', 'c', '中国', '日本']; + const unorderedTags = createTagObjects(unorderedTagNames); + const expectedOrder = createTagObjects(expectedOrderNames); + expect(TagsOptionsListUtils.sortTags(unorderedTags)).toStrictEqual(expectedOrder); + + const unorderedTagNames2 = ['0', 'a1', '1', 'b1', '3', '10', 'b10', 'a', '2', 'c', '20', 'a20', 'b']; + const expectedOrderNames2 = ['0', '1', '10', '2', '20', '3', 'a', 'a1', 'a20', 'b', 'b1', 'b10', 'c']; + const unorderedTags2 = createTagObjects(unorderedTagNames2); + const expectedOrder2 = createTagObjects(expectedOrderNames2); + expect(TagsOptionsListUtils.sortTags(unorderedTags2)).toStrictEqual(expectedOrder2); + + const unorderedTagNames3 = [ + '61', + '39', + '97', + '93', + '77', + '71', + '22', + '27', + '30', + '64', + '91', + '24', + '33', + '60', + '21', + '85', + '59', + '76', + '42', + '67', + '13', + '96', + '84', + '44', + '68', + '31', + '62', + '87', + '50', + '4', + '100', + '12', + '28', + '49', + '53', + '5', + '45', + '14', + '55', + '78', + '11', + '35', + '75', + '18', + '9', + '80', + '54', + '2', + '34', + '48', + '81', + '6', + '73', + '15', + '98', + '25', + '8', + '99', + '17', + '90', + '47', + '1', + '10', + '38', + '66', + '57', + '23', + '86', + '29', + '3', + '65', + '74', + '19', + '56', + '63', + '20', + '7', + '32', + '46', + '70', + '26', + '16', + '83', + '37', + '58', + '43', + '36', + '69', + '79', + '72', + '41', + '94', + '95', + '82', + '51', + '52', + '89', + '88', + '40', + '92', + ]; + const expectedOrderNames3 = [ + '1', + '10', + '100', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '19', + '2', + '20', + '21', + '22', + '23', + '24', + '25', + '26', + '27', + '28', + '29', + '3', + '30', + '31', + '32', + '33', + '34', + '35', + '36', + '37', + '38', + '39', + '4', + '40', + '41', + '42', + '43', + '44', + '45', + '46', + '47', + '48', + '49', + '5', + '50', + '51', + '52', + '53', + '54', + '55', + '56', + '57', + '58', + '59', + '6', + '60', + '61', + '62', + '63', + '64', + '65', + '66', + '67', + '68', + '69', + '7', + '70', + '71', + '72', + '73', + '74', + '75', + '76', + '77', + '78', + '79', + '8', + '80', + '81', + '82', + '83', + '84', + '85', + '86', + '87', + '88', + '89', + '9', + '90', + '91', + '92', + '93', + '94', + '95', + '96', + '97', + '98', + '99', + ]; + const unorderedTags3 = createTagObjects(unorderedTagNames3); + const expectedOrder3 = createTagObjects(expectedOrderNames3); + expect(TagsOptionsListUtils.sortTags(unorderedTags3)).toStrictEqual(expectedOrder3); + }); + + it('sortTags by object works the same', () => { + const tagsObject = { + name: 'Tag', + orderWeight: 0, + required: false, + tags: { + OfficeSupplies: { + enabled: true, + name: 'OfficeSupplies', + }, + DisabledTag: { + enabled: false, + name: 'DisabledTag', + }, + Car: { + enabled: true, + name: 'Car', + }, + }, + }; + + const sorted = TagsOptionsListUtils.sortTags(tagsObject.tags); + expect(Array.isArray(sorted)).toBe(true); + // Expect to be sorted alphabetically + expect(sorted.at(0)?.name).toBe('Car'); + expect(sorted.at(1)?.name).toBe('DisabledTag'); + expect(sorted.at(2)?.name).toBe('OfficeSupplies'); + }); +}); diff --git a/tests/unit/WorkspaceSettingsUtilsTest.json b/tests/unit/WorkspaceSettingsUtilsTest.json index ff83fe078adf..52048f5efccb 100644 --- a/tests/unit/WorkspaceSettingsUtilsTest.json +++ b/tests/unit/WorkspaceSettingsUtilsTest.json @@ -5,8 +5,9 @@ "reports": { "report_4286515777714555": { "type": "chat", + "chatType": "policyExpenseChat", "isOwnPolicyExpenseChat": false, - "ownerAccountID": 0, + "ownerAccountID": 18634488, "parentReportActionID": "8722650843049927838", "parentReportID": "6955627196303088", "policyID": "57D0F454E0BCE54B", @@ -26,6 +27,17 @@ "parentReportActionID": "7978085421707288417" } }, + "transactions": { + "transactions_3106135972713435169": { + "transactionID": "3106135972713435169", + "created": "2024-11-13", + "currency": "USD", + "merchant": "test", + "amount": 10, + "modifiedAmount": 10, + "reportID": "4286515777714555" + } + }, "transactionViolations": { "transactionViolations_3106135972713435169": [ { diff --git a/tests/unit/WorkspaceSettingsUtilsTest.ts b/tests/unit/WorkspaceSettingsUtilsTest.ts index 9ee2b511379f..37f235eb73f9 100644 --- a/tests/unit/WorkspaceSettingsUtilsTest.ts +++ b/tests/unit/WorkspaceSettingsUtilsTest.ts @@ -2,7 +2,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import {getBrickRoadForPolicy} from '@libs/WorkspacesSettingsUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportActions, TransactionViolations} from '@src/types/onyx'; +import type {Report, ReportActions, Transaction, TransactionViolations} from '@src/types/onyx'; import type {ReportCollectionDataSet} from '@src/types/onyx/Report'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -27,11 +27,13 @@ describe('WorkspacesSettingsUtils', () => { const reports = mockData.reports; const session = mockData.session; const reportActions = mockData.reportActions; + const transactions = mockData.transactions; await Onyx.multiSet({ ...(reports as ReportCollectionDataSet), ...(reportActions as OnyxCollection), ...(transactionViolations as OnyxCollection), + ...(transactions as OnyxCollection), session, }); diff --git a/web/index.html b/web/index.html index c15f79b428a7..12d2c6c67782 100644 --- a/web/index.html +++ b/web/index.html @@ -19,14 +19,14 @@ <% if (htmlWebpackPlugin.options.isStaging) { %> <% } %> - + <% if (htmlWebpackPlugin.options.isWeb && (htmlWebpackPlugin.options.isStaging || htmlWebpackPlugin.options.isProduction)) { %> - <% } %> - +