diff --git a/android/app/build.gradle b/android/app/build.gradle
index 098f8d5fc224..bc08cbbeed21 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -98,8 +98,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001046007
- versionName "1.4.60-7"
+ versionCode 1001046013
+ versionName "1.4.60-13"
}
flavorDimensions "default"
diff --git a/docs/_data/_routes.yml b/docs/_data/_routes.yml
index b608e9cd02bd..20de9415bcd6 100644
--- a/docs/_data/_routes.yml
+++ b/docs/_data/_routes.yml
@@ -17,73 +17,74 @@ platforms:
- href: getting-started
title: Getting Started
icon: /assets/images/accounting.svg
- description: From setting up your account to ensuring you get the most out of Expensify’s suite of features, click here to get started on streamlining your expense management journey.
-
- - href: settings
- title: Settings
- icon: /assets/images/gears.svg
- description: Discover how to personalize your profile, add secondary logins, and grant delegated access to employees with our comprehensive guide on Account Settings.
+ description: Set up your account to optimize Expensify's features.
- - href: bank-accounts-and-credit-cards
- title: Bank Accounts & Credit Cards
- icon: /assets/images/bank-card.svg
- description: Find out how to connect Expensify to your financial institutions, track credit card transactions, and best practices for reconciling company cards.
+ - href: workspaces
+ title: Workspaces
+ icon: /assets/images/shield.svg
+ description: Configure rules, settings, and limits for your company’s spending.
- - href: expensify-billing
- title: Expensify Billing
- icon: /assets/images/subscription-annual.svg
- description: Review Expensify's subscription options, plan types, and payment methods.
+ - href: expenses
+ title: Expenses
+ icon: /assets/images/money-into-wallet.svg
+ description: Learn more about expense tracking and submission.
- href: reports
title: Reports
icon: /assets/images/money-receipt.svg
description: Set approval workflows and use Expensify’s automated report features.
+ - href: domains
+ title: Domains
+ icon: /assets/images/domains.svg
+ description: Claim and verify your company’s domain to access additional management and security features.
+
+ - href: bank-accounts-and-payments
+ title: Bank Accounts & Payments
+ icon: /assets/images/send-money.svg
+ description: Send direct reimbursements, pay invoices, and receive payment.
+
+ - href: connect-credit-cards
+ title: Connect Credit Cards
+ icon: /assets/images/bank-card.svg
+ description: Track credit card transactions and reconcile company cards.
+
- href: expensify-card
title: Expensify Card
icon: /assets/images/hand-card.svg
- description: Explore how the Expensify Card combines convenience and security to enhance everyday business transactions. Discover how to apply for, oversee, and maximize your card perks here.
+ description: Explore the perks and benefits of the Expensify Card.
+
+ - href: copilots-and-delegates
+ title: Copilots & Delegates
+ icon: /assets/images/envelope-receipt.svg
+ description: Assign Copilots and delegate report approvals.
- href: expensify-partner-program
title: Expensify Partner Program
icon: /assets/images/handshake.svg
- description: Discover how to get the most out of Expensify as an ExpensifyApproved! accountant partner. Learn how to set up your clients, receive CPE credits, and take advantage of your partner discount.
-
- - href: expenses
- title: Expenses
- icon: /assets/images/money-into-wallet.svg
- description: Learn more about expense tracking and submission.
-
- - href: insights-and-custom-reporting
- title: Insights & Custom Reporting
- icon: /assets/images/monitor.svg
- description: From exporting reports to creating custom templates, here is where you can learn more about Expensify's versatile export options.
+ description: Discover the benefits of becoming an Expensify Partner.
- href: integrations
title: Integrations
icon: /assets/images/workflow.svg
- description: Enhance Expensify’s capabilities by integrating it with your accounting or HR software. Here is where you can learn more about creating a synchronized financial management ecosystem.
+ description: Integrate with accounting or HR software to streamline expense approvals.
- - href: copilots-and-delegates
- title: Copilots & Delegates
- icon: /assets/images/envelope-receipt.svg
- description: Assign Copilots and delegate report approvals.
-
- - href: send-payments
- title: Send Payments
- icon: /assets/images/send-money.svg
- description: Uncover step-by-step guidance on sending direct reimbursements to employees, paying an invoice to a vendor, and utilizing third-party payment options.
-
- - href: workspaces
- title: Workspaces
- icon: /assets/images/shield.svg
- description: Configure rules, settings, and limits for your company’s spending.
+ - href: spending-insights
+ title: Spending Insights
+ icon: /assets/images/monitor.svg
+ description: Create custom export templates to understand spending insights.
- - href: domains
- title: Domains
- icon: /assets/images/domains.svg
- description: Claim and verify your company’s domain to access additional management and security features.
-
+ - href: settings
+ title: Settings
+ icon: /assets/images/gears.svg
+ description: Manage profile settings and notifications.
+
+ - href: expensify-billing
+ title: Expensify Billing
+ icon: /assets/images/subscription-annual.svg
+ description: Review Expensify's subscription options, plan types, and payment methods.
+
+
- href: new-expensify
title: New Expensify
hub-title: New Expensify - Help & Resources
diff --git a/docs/articles/expensify-classic/send-payments/Pay-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Pay-Bills.md
similarity index 100%
rename from docs/articles/expensify-classic/send-payments/Pay-Bills.md
rename to docs/articles/expensify-classic/bank-accounts-and-payments/Pay-Bills.md
diff --git a/docs/articles/expensify-classic/send-payments/Reimbursing-Reports.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Reimbursing-Reports.md
similarity index 100%
rename from docs/articles/expensify-classic/send-payments/Reimbursing-Reports.md
rename to docs/articles/expensify-classic/bank-accounts-and-payments/Reimbursing-Reports.md
diff --git a/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments.md
similarity index 100%
rename from docs/articles/expensify-classic/send-payments/Third-Party-Payments.md
rename to docs/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments.md
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursements.md b/docs/articles/expensify-classic/connect-credit-cards/Global-Reimbursements.md
similarity index 100%
rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursements.md
rename to docs/articles/expensify-classic/connect-credit-cards/Global-Reimbursements.md
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md b/docs/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards.md
similarity index 100%
rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md
rename to docs/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards.md
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md b/docs/articles/expensify-classic/connect-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md
similarity index 100%
rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md
rename to docs/articles/expensify-classic/connect-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-USD.md b/docs/articles/expensify-classic/connect-credit-cards/business-bank-accounts/Business-Bank-Accounts-USD.md
similarity index 100%
rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-USD.md
rename to docs/articles/expensify-classic/connect-credit-cards/business-bank-accounts/Business-Bank-Accounts-USD.md
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/CSV-Import.md
similarity index 100%
rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md
rename to docs/articles/expensify-classic/connect-credit-cards/company-cards/CSV-Import.md
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Commercial-Card-Feeds.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Commercial-Card-Feeds.md
similarity index 100%
rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Commercial-Card-Feeds.md
rename to docs/articles/expensify-classic/connect-credit-cards/company-cards/Commercial-Card-Feeds.md
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Company-Card-Settings.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings.md
similarity index 100%
rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Company-Card-Settings.md
rename to docs/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings.md
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Connect-ANZ.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Connect-ANZ.md
similarity index 100%
rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Connect-ANZ.md
rename to docs/articles/expensify-classic/connect-credit-cards/company-cards/Connect-ANZ.md
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Direct-Bank-Connections.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Direct-Bank-Connections.md
similarity index 100%
rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Direct-Bank-Connections.md
rename to docs/articles/expensify-classic/connect-credit-cards/company-cards/Direct-Bank-Connections.md
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Reconciliation.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Reconciliation.md
similarity index 100%
rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Reconciliation.md
rename to docs/articles/expensify-classic/connect-credit-cards/company-cards/Reconciliation.md
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Troubleshooting.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md
similarity index 100%
rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Troubleshooting.md
rename to docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUD.md b/docs/articles/expensify-classic/connect-credit-cards/deposit-accounts/Deposit-Accounts-AUD.md
similarity index 100%
rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUD.md
rename to docs/articles/expensify-classic/connect-credit-cards/deposit-accounts/Deposit-Accounts-AUD.md
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-USD.md b/docs/articles/expensify-classic/connect-credit-cards/deposit-accounts/Deposit-Accounts-USD.md
similarity index 100%
rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-USD.md
rename to docs/articles/expensify-classic/connect-credit-cards/deposit-accounts/Deposit-Accounts-USD.md
diff --git a/docs/articles/expensify-classic/reports/The-Expenses-Page.md b/docs/articles/expensify-classic/reports/The-Expenses-Page.md
deleted file mode 100644
index 57a7f7de298c..000000000000
--- a/docs/articles/expensify-classic/reports/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/expensify-classic/insights-and-custom-reporting/Custom-Templates.md b/docs/articles/expensify-classic/spending-insights/Custom-Templates.md
similarity index 100%
rename from docs/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates.md
rename to docs/articles/expensify-classic/spending-insights/Custom-Templates.md
diff --git a/docs/articles/expensify-classic/insights-and-custom-reporting/Default-Export-Templates.md b/docs/articles/expensify-classic/spending-insights/Default-Export-Templates.md
similarity index 100%
rename from docs/articles/expensify-classic/insights-and-custom-reporting/Default-Export-Templates.md
rename to docs/articles/expensify-classic/spending-insights/Default-Export-Templates.md
diff --git a/docs/articles/expensify-classic/insights-and-custom-reporting/Fringe-Benefits.md b/docs/articles/expensify-classic/spending-insights/Fringe-Benefits.md
similarity index 100%
rename from docs/articles/expensify-classic/insights-and-custom-reporting/Fringe-Benefits.md
rename to docs/articles/expensify-classic/spending-insights/Fringe-Benefits.md
diff --git a/docs/articles/expensify-classic/insights-and-custom-reporting/Insights.md b/docs/articles/expensify-classic/spending-insights/Insights.md
similarity index 100%
rename from docs/articles/expensify-classic/insights-and-custom-reporting/Insights.md
rename to docs/articles/expensify-classic/spending-insights/Insights.md
diff --git a/docs/articles/expensify-classic/insights-and-custom-reporting/Other-Export-Options.md b/docs/articles/expensify-classic/spending-insights/Other-Export-Options.md
similarity index 100%
rename from docs/articles/expensify-classic/insights-and-custom-reporting/Other-Export-Options.md
rename to docs/articles/expensify-classic/spending-insights/Other-Export-Options.md
diff --git a/docs/expensify-classic/hubs/bank-accounts-and-credit-cards/index.html b/docs/expensify-classic/hubs/bank-accounts-and-payments/index.html
similarity index 56%
rename from docs/expensify-classic/hubs/bank-accounts-and-credit-cards/index.html
rename to docs/expensify-classic/hubs/bank-accounts-and-payments/index.html
index 2f91f0913d6e..22e39250aea4 100644
--- a/docs/expensify-classic/hubs/bank-accounts-and-credit-cards/index.html
+++ b/docs/expensify-classic/hubs/bank-accounts-and-payments/index.html
@@ -1,6 +1,6 @@
---
layout: default
-title: Bank Accounts & Credit Cards
+title: Bank accounts & payments
---
{% include hub.html %}
\ No newline at end of file
diff --git a/docs/expensify-classic/hubs/bank-accounts-and-credit-cards/business-bank-accounts.html b/docs/expensify-classic/hubs/connect-credit-cards/business-bank-accounts.html
similarity index 100%
rename from docs/expensify-classic/hubs/bank-accounts-and-credit-cards/business-bank-accounts.html
rename to docs/expensify-classic/hubs/connect-credit-cards/business-bank-accounts.html
diff --git a/docs/expensify-classic/hubs/bank-accounts-and-credit-cards/company-cards.html b/docs/expensify-classic/hubs/connect-credit-cards/company-cards.html
similarity index 100%
rename from docs/expensify-classic/hubs/bank-accounts-and-credit-cards/company-cards.html
rename to docs/expensify-classic/hubs/connect-credit-cards/company-cards.html
diff --git a/docs/expensify-classic/hubs/bank-accounts-and-credit-cards/deposit-accounts.html b/docs/expensify-classic/hubs/connect-credit-cards/deposit-accounts.html
similarity index 100%
rename from docs/expensify-classic/hubs/bank-accounts-and-credit-cards/deposit-accounts.html
rename to docs/expensify-classic/hubs/connect-credit-cards/deposit-accounts.html
diff --git a/docs/expensify-classic/hubs/send-payments/index.html b/docs/expensify-classic/hubs/connect-credit-cards/index.html
similarity index 62%
rename from docs/expensify-classic/hubs/send-payments/index.html
rename to docs/expensify-classic/hubs/connect-credit-cards/index.html
index c8275af5c353..e21df799a132 100644
--- a/docs/expensify-classic/hubs/send-payments/index.html
+++ b/docs/expensify-classic/hubs/connect-credit-cards/index.html
@@ -1,6 +1,6 @@
---
layout: default
-title: Send Payments
+title: Connect Credit Cards
---
{% include hub.html %}
\ No newline at end of file
diff --git a/docs/expensify-classic/hubs/insights-and-custom-reporting/index.html b/docs/expensify-classic/hubs/spending-insights/index.html
similarity index 65%
rename from docs/expensify-classic/hubs/insights-and-custom-reporting/index.html
rename to docs/expensify-classic/hubs/spending-insights/index.html
index 16c96cb51d01..68b302394ff3 100644
--- a/docs/expensify-classic/hubs/insights-and-custom-reporting/index.html
+++ b/docs/expensify-classic/hubs/spending-insights/index.html
@@ -1,6 +1,6 @@
---
layout: default
-title: Exports
+title: Spending Insights
---
{% include hub.html %}
\ No newline at end of file
diff --git a/docs/redirects.csv b/docs/redirects.csv
index 32fc61642bda..ca87274a2fbb 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -77,3 +77,6 @@ https://help.expensify.com/articles/expensify-classic/workspace-and-domain-setti
https://help.expensify.com/articles/expensify-classic/workspace-and-domain-settings/Reimbursement,https://help.expensify.com/articles/expensify-classic/send-payments/Reimbursing-Reports
https://help.expensify.com/articles/expensify-classic/workspace-and-domain-settings/Tags,https://help.expensify.com/articles/expensify-classic/workspaces/Tags
https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/User-Roles,https://help.expensify.com/articles/expensify-classic/workspaces/Change-member-workspace-roles
+https://help.expensify.com/articles/expensify-classic/workspace-and-domain-settings/tax-tracking,https://help.expensify.com/articles/expensify-classic/expenses/expenses/Apply-Tax
+https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/User-Roles.html,https://help.expensify.com/expensify-classic/hubs/copilots-and-delegates/
+https://help.expensify.com/articles/expensify-classic/expensify-billing/Billing-Owner,https://help.expensify.com/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account
diff --git a/ios/NewExpensify/AppDelegate.mm b/ios/NewExpensify/AppDelegate.mm
index f5ddba46f5f1..f4f7f3bc8dbc 100644
--- a/ios/NewExpensify/AppDelegate.mm
+++ b/ios/NewExpensify/AppDelegate.mm
@@ -44,7 +44,12 @@ - (BOOL)application:(UIApplication *)application
// stopped by a native module in the JS so we can measure total time starting
// in the native layer and ending in the JS layer.
[RCTStartupTimer start];
-
+
+ if (![[NSUserDefaults standardUserDefaults] boolForKey:@"isFirstRunComplete"]) {
+ [UIApplication sharedApplication].applicationIconBadgeNumber = 0;
+ [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"isFirstRunComplete"];
+ }
+
return YES;
}
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index a98f3eb90d63..0db15a68744f 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.60.7
+ 1.4.60.13
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 91e8a4657385..e133f93aa125 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 1.4.60.7
+ 1.4.60.13
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index d629dd873734..12e153cd1f3b 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -13,7 +13,7 @@
CFBundleShortVersionString
1.4.60
CFBundleVersion
- 1.4.60.7
+ 1.4.60.13
NSExtension
NSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index d5e2fcef2392..391b13e99305 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.60-7",
+ "version": "1.4.60-13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.60-7",
+ "version": "1.4.60-13",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index 35a89ae364eb..5e00815f1e3e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.60-7",
+ "version": "1.4.60-13",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
diff --git a/src/App.tsx b/src/App.tsx
index 61874dc72fb0..a3a9f7a3f3b6 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -16,7 +16,6 @@ import HTMLEngineProvider from './components/HTMLEngineProvider';
import InitialURLContextProvider from './components/InitialURLContextProvider';
import {LocaleContextProvider} from './components/LocaleContextProvider';
import OnyxProvider from './components/OnyxProvider';
-import OptionsListContextProvider from './components/OptionListContextProvider';
import PopoverContextProvider from './components/PopoverProvider';
import SafeArea from './components/SafeArea';
import ScrollOffsetContextProvider from './components/ScrollOffsetContextProvider';
@@ -83,7 +82,6 @@ function App({url}: AppProps) {
FullScreenContextProvider,
VolumeContextProvider,
VideoPopoverMenuContextProvider,
- OptionsListContextProvider,
]}
>
diff --git a/src/CONST.ts b/src/CONST.ts
index 6d1195ff5c79..f9229d5185b4 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -864,6 +864,11 @@ const CONST = {
RIGHT: 'right',
},
POPOVER_MENU_PADDING: 8,
+ RESTORE_FOCUS_TYPE: {
+ DEFAULT: 'default',
+ DELETE: 'delete',
+ PRESERVE: 'preserve',
+ },
},
TIMING: {
CALCULATE_MOST_RECENT_LAST_MODIFIED_ACTION: 'calc_most_recent_last_modified_action',
diff --git a/src/components/ArrowKeyFocusManager.js b/src/components/ArrowKeyFocusManager.js
index 19dc3a7ac614..2532e52156df 100644
--- a/src/components/ArrowKeyFocusManager.js
+++ b/src/components/ArrowKeyFocusManager.js
@@ -1,5 +1,6 @@
+import {useIsFocused} from '@react-navigation/native';
import PropTypes from 'prop-types';
-import {Component} from 'react';
+import React, {Component} from 'react';
import KeyboardShortcut from '@libs/KeyboardShortcut';
import CONST from '@src/CONST';
@@ -16,6 +17,9 @@ const propTypes = {
/** The maximum index – provided so that the focus can be sent back to the beginning of the list when the end is reached. */
maxIndex: PropTypes.number.isRequired,
+ /** Whether navigation is focused */
+ isFocused: PropTypes.bool.isRequired,
+
/** A callback executed when the focused input changes. */
onFocusedIndexChanged: PropTypes.func.isRequired,
@@ -32,7 +36,7 @@ const defaultProps = {
shouldResetIndexOnEndReached: true,
};
-class ArrowKeyFocusManager extends Component {
+class BaseArrowKeyFocusManager extends Component {
componentDidMount() {
const arrowUpConfig = CONST.KEYBOARD_SHORTCUTS.ARROW_UP;
const arrowDownConfig = CONST.KEYBOARD_SHORTCUTS.ARROW_DOWN;
@@ -77,7 +81,7 @@ class ArrowKeyFocusManager extends Component {
}
onArrowUpKey() {
- if (this.props.maxIndex < 0) {
+ if (this.props.maxIndex < 0 || !this.props.isFocused) {
return;
}
@@ -96,7 +100,7 @@ class ArrowKeyFocusManager extends Component {
}
onArrowDownKey() {
- if (this.props.maxIndex < 0) {
+ if (this.props.maxIndex < 0 || !this.props.isFocused) {
return;
}
@@ -119,7 +123,20 @@ class ArrowKeyFocusManager extends Component {
}
}
-ArrowKeyFocusManager.propTypes = propTypes;
-ArrowKeyFocusManager.defaultProps = defaultProps;
+function ArrowKeyFocusManager(props) {
+ const isFocused = useIsFocused();
+
+ return (
+
+ );
+}
+
+BaseArrowKeyFocusManager.propTypes = propTypes;
+BaseArrowKeyFocusManager.defaultProps = defaultProps;
+ArrowKeyFocusManager.displayName = 'ArrowKeyFocusManager';
export default ArrowKeyFocusManager;
diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx
index 5d01b05bb51f..69cc6b208652 100755
--- a/src/components/Composer/index.tsx
+++ b/src/components/Composer/index.tsx
@@ -388,13 +388,20 @@ function Composer(
disabled={isDisabled}
onKeyPress={handleKeyPress}
onFocus={(e) => {
- ReportActionComposeFocusManager.onComposerFocus(() => {
- if (!textInput.current) {
- return;
- }
-
- textInput.current.focus();
- });
+ if (isReportActionCompose) {
+ ReportActionComposeFocusManager.onComposerFocus(null);
+ } else {
+ // While a user edits a comment, if they open the LHN menu, we want to ensure that
+ // the focus returns to the message edit composer after they click on a menu item (e.g. mark as read).
+ // To achieve this, we re-assign the focus callback here.
+ ReportActionComposeFocusManager.onComposerFocus(() => {
+ if (!textInput.current) {
+ return;
+ }
+
+ textInput.current.focus();
+ });
+ }
props.onFocus?.(e);
}}
diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js
index fbfa4563d70e..410d600dcce2 100644
--- a/src/components/EmojiPicker/EmojiPicker.js
+++ b/src/components/EmojiPicker/EmojiPicker.js
@@ -213,6 +213,8 @@ const EmojiPicker = forwardRef((props, ref) => {
anchorDimensions={emojiAnchorDimension.current}
avoidKeyboard
shoudSwitchPositionIfOverflow
+ shouldEnableNewFocusManagement
+ restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE}
>
,
) {
@@ -56,6 +59,14 @@ function BaseModal(
const isVisibleRef = useRef(isVisible);
const wasVisible = usePrevious(isVisible);
+ const modalId = useMemo(() => ComposerFocusManager.getId(), []);
+ const saveFocusState = () => {
+ if (shouldEnableNewFocusManagement) {
+ ComposerFocusManager.saveFocusState(modalId);
+ }
+ ComposerFocusManager.resetReadyToFocus(modalId);
+ };
+
/**
* Hides modal
* @param callHideCallback - Should we call the onModalHide callback
@@ -70,11 +81,9 @@ function BaseModal(
onModalHide();
}
Modal.onModalDidClose();
- if (!fullscreen) {
- ComposerFocusManager.setReadyToFocus();
- }
+ ComposerFocusManager.refocusAfterModalFullyClosed(modalId, restoreFocusType);
},
- [shouldSetModalVisibility, onModalHide, fullscreen],
+ [shouldSetModalVisibility, onModalHide, restoreFocusType, modalId],
);
useEffect(() => {
@@ -126,7 +135,7 @@ function BaseModal(
};
const handleDismissModal = () => {
- ComposerFocusManager.setReadyToFocus();
+ ComposerFocusManager.setReadyToFocus(modalId);
};
const {
@@ -190,7 +199,7 @@ function BaseModal(
onModalShow={handleShowModal}
propagateSwipe={propagateSwipe}
onModalHide={hideModal}
- onModalWillShow={() => ComposerFocusManager.resetReadyToFocus()}
+ onModalWillShow={saveFocusState}
onDismiss={handleDismissModal}
onSwipeComplete={() => onClose?.()}
swipeDirection={swipeDirection}
@@ -214,12 +223,14 @@ function BaseModal(
avoidKeyboard={avoidKeyboard}
customBackdrop={shouldUseCustomBackdrop ? : undefined}
>
-
- {children}
-
+
+
+ {children}
+
+
);
}
diff --git a/src/components/Modal/ModalContent.tsx b/src/components/Modal/ModalContent.tsx
new file mode 100644
index 000000000000..49d3b049220f
--- /dev/null
+++ b/src/components/Modal/ModalContent.tsx
@@ -0,0 +1,23 @@
+import type {ReactNode} from 'react';
+import React from 'react';
+
+type ModalContentProps = {
+ /** Modal contents */
+ children: ReactNode;
+
+ /**
+ * Callback method fired after modal content is unmounted.
+ * isVisible is not enough to cover all modal close cases,
+ * such as closing the attachment modal through the browser's back button.
+ * */
+ onDismiss: () => void;
+};
+
+function ModalContent({children, onDismiss = () => {}}: ModalContentProps) {
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ React.useEffect(() => () => onDismiss?.(), []);
+ return children;
+}
+ModalContent.displayName = 'ModalContent';
+
+export default ModalContent;
diff --git a/src/components/Modal/index.android.tsx b/src/components/Modal/index.android.tsx
index 86a1fd272185..7cb2c6083752 100644
--- a/src/components/Modal/index.android.tsx
+++ b/src/components/Modal/index.android.tsx
@@ -1,17 +1,7 @@
import React from 'react';
-import {AppState} from 'react-native';
-import ComposerFocusManager from '@libs/ComposerFocusManager';
import BaseModal from './BaseModal';
import type BaseModalProps from './types';
-AppState.addEventListener('focus', () => {
- ComposerFocusManager.setReadyToFocus();
-});
-
-AppState.addEventListener('blur', () => {
- ComposerFocusManager.resetReadyToFocus();
-});
-
// Only want to use useNativeDriver on Android. It has strange flashes issue on IOS
// https://github.com/react-native-modal/react-native-modal#the-modal-flashes-in-a-weird-way-when-animating
function Modal({useNativeDriver = true, ...rest}: BaseModalProps) {
diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts
index 9c394fdf0289..6111987e9c8d 100644
--- a/src/components/Modal/types.ts
+++ b/src/components/Modal/types.ts
@@ -68,6 +68,15 @@ type BaseModalProps = Partial & {
/** Should we use a custom backdrop for the modal? (This prevents focus issues on desktop) */
shouldUseCustomBackdrop?: boolean;
+
+ /**
+ * Whether the modal should enable the new focus manager.
+ * We are attempting to migrate to a new refocus manager, adding this property for gradual migration.
+ * */
+ shouldEnableNewFocusManagement?: boolean;
+
+ /** How to re-focus after the modal is dismissed */
+ restoreFocusType?: ValueOf;
};
export default BaseModalProps;
diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
index 8c236d020645..7d5061b25b95 100755
--- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
+++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
@@ -329,7 +329,6 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
const isMerchantRequired = isPolicyExpenseChat && !isScanRequest && shouldShowMerchant;
const isCategoryRequired = canUseViolations && lodashGet(policy, 'requiresCategory', false);
- const isTagRequired = canUseViolations && lodashGet(policy, 'requiresTag', false);
useEffect(() => {
if ((!isMerchantRequired && isMerchantEmpty) || !merchantError) {
@@ -534,7 +533,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
if (updatedTagsString !== TransactionUtils.getTag(transaction) && updatedTagsString) {
IOU.setMoneyRequestTag(transaction.transactionID, updatedTagsString);
}
- }, [policyTagLists, transaction, policyTags, isTagRequired, canUseViolations]);
+ }, [policyTagLists, transaction, policyTags, canUseViolations]);
/**
* @param {Object} option
@@ -829,28 +828,38 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
shouldShow: shouldShowCategories,
isSupplementary: !isCategoryRequired,
},
- ..._.map(policyTagLists, ({name}, index) => ({
- item: (
-
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.CREATE, iouType, index, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()),
- )
- }
- style={[styles.moneyRequestMenuItem]}
- disabled={didConfirm}
- interactive={!isReadOnly}
- rightLabel={isTagRequired ? translate('common.required') : ''}
- />
- ),
- shouldShow: shouldShowTags,
- isSupplementary: !isTagRequired,
- })),
+ ..._.map(policyTagLists, ({name, required}, index) => {
+ const isTagRequired = isUndefined(required) ? false : canUseViolations && required;
+ return {
+ item: (
+
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(
+ CONST.IOU.ACTION.CREATE,
+ iouType,
+ index,
+ transaction.transactionID,
+ reportID,
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ )
+ }
+ style={[styles.moneyRequestMenuItem]}
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ rightLabel={isTagRequired ? translate('common.required') : ''}
+ />
+ ),
+ shouldShow: shouldShowTags,
+ isSupplementary: !isTagRequired,
+ };
+ }),
{
item: (
reportActions.clearReportFieldErrors(report.reportID, reportField)}
>
- {({report}) => (
+ {({report, transactionThreadReport}) => (
Navigation.navigate(ROUTES.TRANSACTION_RECEIPT.getRoute(report?.reportID ?? '', transaction?.transactionID ?? ''))}
+ onPress={() =>
+ Navigation.navigate(ROUTES.TRANSACTION_RECEIPT.getRoute(transactionThreadReport?.reportID ?? report?.reportID ?? '', transaction?.transactionID ?? ''))
+ }
accessibilityLabel={translate('accessibilityHints.viewAttachment')}
accessibilityRole={CONST.ROLE.BUTTON}
>
diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts
index e401dd5456b2..8e934d9f6490 100644
--- a/src/components/SelectionList/types.ts
+++ b/src/components/SelectionList/types.ts
@@ -33,7 +33,7 @@ type CommonListItemProps = {
onDismissError?: (item: TItem) => void;
/** Component to display on the right side */
- rightHandSideComponent?: ((item: TItem) => ReactElement) | ReactElement | null;
+ rightHandSideComponent?: ((item: TItem) => ReactElement | null) | ReactElement | null;
/** Styles for the pressable component */
pressableStyle?: StyleProp;
@@ -278,7 +278,7 @@ type BaseSelectionListProps = Partial & {
isKeyboardShown?: boolean;
/** Component to display on the right side of each child */
- rightHandSideComponent?: ((item: ListItem) => ReactElement) | ReactElement | null;
+ rightHandSideComponent?: ((item: ListItem) => ReactElement | null) | ReactElement | null;
/** Whether to show the loading indicator for new options */
isLoadingNewOptions?: boolean;
diff --git a/src/components/ShowContextMenuContext.ts b/src/components/ShowContextMenuContext.ts
index 374ca8a2f1e5..3a996a8d2c64 100644
--- a/src/components/ShowContextMenuContext.ts
+++ b/src/components/ShowContextMenuContext.ts
@@ -13,6 +13,7 @@ type ShowContextMenuContextProps = {
anchor: ContextMenuAnchor;
report: OnyxEntry;
action: OnyxEntry;
+ transactionThreadReport: OnyxEntry;
checkIfContextMenuActive: () => void;
};
@@ -20,6 +21,7 @@ const ShowContextMenuContext = createContext({
anchor: null,
report: null,
action: null,
+ transactionThreadReport: null,
checkIfContextMenuActive: () => {},
});
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 55a4c586716a..ab1a53a55da9 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -1915,10 +1915,14 @@ export default {
subtitle: 'Set up custom fields for spend.',
},
connections: {
- title: 'Connections',
+ title: 'Accounting',
subtitle: 'Sync your chart of accounts and more.',
},
},
+ reportFields: {
+ delete: 'Delete field',
+ deleteConfirmation: 'Are you sure that you want to delete this field?',
+ },
tags: {
tagName: 'Tag name',
requiresTag: 'Members must tag all spend',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 5956f1457005..6330ade811ca 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -1942,10 +1942,14 @@ export default {
subtitle: 'Configura campos personalizados para los gastos.',
},
connections: {
- title: 'Conexión',
+ title: 'Contabilidad',
subtitle: 'Sincroniza tu plan de cuentas y otras opciones.',
},
},
+ reportFields: {
+ delete: 'Eliminar campos',
+ deleteConfirmation: '¿Estás seguro de que quieres eliminar esta campos?',
+ },
tags: {
tagName: 'Nombre de etiqueta',
requiresTag: 'Los miembros deben etiquetar todos los gastos',
diff --git a/src/libs/API/parameters/DeleteReportFieldParams.ts b/src/libs/API/parameters/DeleteReportFieldParams.ts
new file mode 100644
index 000000000000..f64659512f6b
--- /dev/null
+++ b/src/libs/API/parameters/DeleteReportFieldParams.ts
@@ -0,0 +1,5 @@
+type DeleteReportFieldParams = {
+ fieldID: string;
+};
+
+export default DeleteReportFieldParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index 1bc80e4c08c4..8ef3f255184e 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -128,6 +128,7 @@ export type {default as CompleteEngagementModalParams} from './CompleteEngagemen
export type {default as SetNameValuePairParams} from './SetNameValuePairParams';
export type {default as SetReportFieldParams} from './SetReportFieldParams';
export type {default as SetReportNameParams} from './SetReportNameParams';
+export type {default as DeleteReportFieldParams} from './DeleteReportFieldParams';
export type {default as CompleteSplitBillParams} from './CompleteSplitBillParams';
export type {default as UpdateMoneyRequestParams} from './UpdateMoneyRequestParams';
export type {default as RequestMoneyParams} from './RequestMoneyParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 3cff726a530c..aa4e57561ba0 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -136,6 +136,7 @@ const WRITE_COMMANDS = {
COMPLETE_ENGAGEMENT_MODAL: 'CompleteEngagementModal',
SET_NAME_VALUE_PAIR: 'SetNameValuePair',
SET_REPORT_FIELD: 'Report_SetFields',
+ DELETE_REPORT_FIELD: 'RemoveReportField',
SET_REPORT_NAME: 'RenameReport',
COMPLETE_SPLIT_BILL: 'CompleteSplitBill',
UPDATE_MONEY_REQUEST_DATE: 'UpdateMoneyRequestDate',
@@ -324,6 +325,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.SET_NAME_VALUE_PAIR]: Parameters.SetNameValuePairParams;
[WRITE_COMMANDS.SET_REPORT_FIELD]: Parameters.SetReportFieldParams;
[WRITE_COMMANDS.SET_REPORT_NAME]: Parameters.SetReportNameParams;
+ [WRITE_COMMANDS.DELETE_REPORT_FIELD]: Parameters.DeleteReportFieldParams;
[WRITE_COMMANDS.COMPLETE_SPLIT_BILL]: Parameters.CompleteSplitBillParams;
[WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DATE]: Parameters.UpdateMoneyRequestParams;
[WRITE_COMMANDS.UPDATE_MONEY_REQUEST_MERCHANT]: Parameters.UpdateMoneyRequestParams;
diff --git a/src/libs/ComposerFocusManager.ts b/src/libs/ComposerFocusManager.ts
index b66bbe92599e..d793c202d243 100644
--- a/src/libs/ComposerFocusManager.ts
+++ b/src/libs/ComposerFocusManager.ts
@@ -1,25 +1,244 @@
-let isReadyToFocusPromise = Promise.resolve();
-let resolveIsReadyToFocus: (value: void | PromiseLike) => void;
+import type {View} from 'react-native';
+import {TextInput} from 'react-native';
+import type {ValueOf} from 'type-fest';
+import CONST from '@src/CONST';
+import isWindowReadyToFocus from './isWindowReadyToFocus';
-function resetReadyToFocus() {
- isReadyToFocusPromise = new Promise((resolve) => {
- resolveIsReadyToFocus = resolve;
+type ModalId = number | undefined;
+
+type InputElement = (TextInput & HTMLElement) | null;
+
+type RestoreFocusType = ValueOf | undefined;
+
+type ModalContainer = (View & HTMLElement) | undefined | null;
+
+/**
+ * So far, modern browsers only support the file cancel event in some newer versions
+ * (i.e., Chrome: 113+ / Firefox: 91+ / Safari 16.4+), and there is no standard feature detection method available.
+ * We will introduce the isInUploadingContext field to isolate the impact of the upload modal on the other modals.
+ */
+type FocusMapValue = {
+ input: InputElement;
+ isInUploadingContext?: boolean;
+};
+
+type PromiseMapValue = {
+ ready: Promise;
+ resolve: () => void;
+};
+
+let focusedInput: InputElement = null;
+let uniqueModalId = 1;
+const focusMap = new Map();
+const activeModals: ModalId[] = [];
+const promiseMap = new Map();
+
+/**
+ * Returns the ref of the currently focused text field, if one exists.
+ * react-native-web doesn't support `currentlyFocusedInput`, so we need to make it compatible by using `currentlyFocusedField` instead.
+ */
+function getActiveInput() {
+ return (TextInput.State.currentlyFocusedInput ? TextInput.State.currentlyFocusedInput() : TextInput.State.currentlyFocusedField()) as InputElement;
+}
+
+/**
+ * On web platform, if the modal is displayed by a click, the blur event is fired before the modal appears,
+ * so we need to cache the focused input in the pointerdown handler, which is fired before the blur event.
+ */
+function saveFocusedInput() {
+ focusedInput = getActiveInput();
+}
+
+/**
+ * If a click does not display the modal, we also should clear the cached value to avoid potential issues.
+ */
+function clearFocusedInput() {
+ if (!focusedInput) {
+ return;
+ }
+
+ // For the PopoverWithMeasuredContent component, Modal is only mounted after onLayout event is triggered,
+ // this event is placed within a setTimeout in react-native-web,
+ // so we can safely clear the cached value only after this event.
+ setTimeout(() => (focusedInput = null), CONST.ANIMATION_IN_TIMING);
+}
+
+/**
+ * When a TextInput is unmounted, we also should release the reference here to avoid potential issues.
+ *
+ */
+function releaseInput(input: InputElement) {
+ if (!input) {
+ return;
+ }
+ if (input === focusedInput) {
+ focusedInput = null;
+ }
+ focusMap.forEach((value, key) => {
+ if (value.input !== input) {
+ return;
+ }
+ focusMap.delete(key);
});
}
-function setReadyToFocus() {
- if (!resolveIsReadyToFocus) {
+function getId() {
+ return uniqueModalId++;
+}
+
+/**
+ * Save the focus state when opening the modal.
+ */
+function saveFocusState(id: ModalId, isInUploadingContext = false, shouldClearFocusWithType = false, container: ModalContainer = undefined) {
+ const activeInput = getActiveInput();
+
+ // For popoverWithoutOverlay, react calls autofocus before useEffect.
+ const input = focusedInput ?? activeInput;
+ focusedInput = null;
+ if (activeModals.indexOf(id) < 0) {
+ activeModals.push(id);
+ }
+
+ if (shouldClearFocusWithType) {
+ focusMap.forEach((value, key) => {
+ if (value.isInUploadingContext !== isInUploadingContext) {
+ return;
+ }
+ focusMap.delete(key);
+ });
+ }
+
+ if (container?.contains(input)) {
return;
}
- resolveIsReadyToFocus();
+ focusMap.set(id, {input, isInUploadingContext});
+ input?.blur();
}
-function isReadyToFocus(): Promise {
- return isReadyToFocusPromise;
+/**
+ * On web platform, if we intentionally click on another input box, there is no need to restore focus.
+ * Additionally, if we are closing the RHP, we can ignore the focused input.
+ */
+function focus(input: InputElement, shouldIgnoreFocused = false) {
+ const activeInput = getActiveInput();
+ if (!input || (activeInput && !shouldIgnoreFocused)) {
+ return;
+ }
+ isWindowReadyToFocus().then(() => input.focus());
}
+function tryRestoreTopmostFocus(shouldIgnoreFocused: boolean, isInUploadingContext = false) {
+ const topmost = [...focusMap].filter(([, v]) => v.input && v.isInUploadingContext === isInUploadingContext).at(-1);
+ if (topmost === undefined) {
+ return;
+ }
+ const [modalId, {input}] = topmost;
+
+ // This modal is still active
+ if (activeModals.indexOf(modalId) >= 0) {
+ return;
+ }
+ focus(input, shouldIgnoreFocused);
+ focusMap.delete(modalId);
+}
+
+/**
+ * Restore the focus state after the modal is dismissed.
+ */
+function restoreFocusState(id: ModalId, shouldIgnoreFocused = false, restoreFocusType: RestoreFocusType = CONST.MODAL.RESTORE_FOCUS_TYPE.DEFAULT, isInUploadingContext = false) {
+ if (!id || !activeModals.length) {
+ return;
+ }
+ const activeModalIndex = activeModals.indexOf(id);
+
+ // This id has been removed from the stack.
+ if (activeModalIndex < 0) {
+ return;
+ }
+ activeModals.splice(activeModalIndex, 1);
+ if (restoreFocusType === CONST.MODAL.RESTORE_FOCUS_TYPE.PRESERVE) {
+ return;
+ }
+
+ const {input} = focusMap.get(id) ?? {};
+ focusMap.delete(id);
+ if (restoreFocusType === CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE) {
+ return;
+ }
+
+ // This modal is not the topmost one, do not restore it.
+ if (activeModals.length > activeModalIndex) {
+ if (input) {
+ const lastId = activeModals.at(-1);
+ focusMap.set(lastId, {...focusMap.get(lastId), input});
+ }
+ return;
+ }
+ if (input) {
+ focus(input, shouldIgnoreFocused);
+ return;
+ }
+
+ // Try to find the topmost one and restore it
+ tryRestoreTopmostFocus(shouldIgnoreFocused, isInUploadingContext);
+}
+
+function resetReadyToFocus(id: ModalId) {
+ const promise: PromiseMapValue = {
+ ready: Promise.resolve(),
+ resolve: () => {},
+ };
+ promise.ready = new Promise((resolve) => {
+ promise.resolve = resolve;
+ });
+ promiseMap.set(id, promise);
+}
+
+/**
+ * Backward compatibility, for cases without an ModalId param, it's fine to just take the topmost one.
+ */
+function getTopmostModalId() {
+ if (promiseMap.size < 1) {
+ return 0;
+ }
+ return [...promiseMap.keys()].at(-1);
+}
+
+function setReadyToFocus(id?: ModalId) {
+ const key = id ?? getTopmostModalId();
+ const promise = promiseMap.get(key);
+ if (!promise) {
+ return;
+ }
+ promise.resolve?.();
+ promiseMap.delete(key);
+}
+
+function isReadyToFocus(id?: ModalId) {
+ const key = id ?? getTopmostModalId();
+ const promise = promiseMap.get(key);
+ if (!promise) {
+ return Promise.resolve();
+ }
+ return promise.ready;
+}
+
+function refocusAfterModalFullyClosed(id: ModalId, restoreType: RestoreFocusType, isInUploadingContext?: boolean) {
+ isReadyToFocus(id)?.then(() => restoreFocusState(id, false, restoreType, isInUploadingContext));
+}
+
+export type {InputElement};
+
export default {
+ getId,
+ saveFocusedInput,
+ clearFocusedInput,
+ releaseInput,
+ saveFocusState,
+ restoreFocusState,
resetReadyToFocus,
setReadyToFocus,
isReadyToFocus,
+ refocusAfterModalFullyClosed,
+ tryRestoreTopmostFocus,
};
diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts
index 4d4f8d425681..44c7682b47f2 100644
--- a/src/libs/DateUtils.ts
+++ b/src/libs/DateUtils.ts
@@ -267,7 +267,7 @@ function formatToLongDateWithWeekday(datetime: string | Date): string {
* @returns Sunday
*/
function formatToDayOfWeek(datetime: Date): string {
- return format(new Date(datetime), CONST.DATE.WEEKDAY_TIME_FORMAT);
+ return format(datetime, CONST.DATE.WEEKDAY_TIME_FORMAT);
}
/**
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index 295daa1938e7..fde0202d3d2f 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -2,6 +2,7 @@ import React, {memo, useEffect, useMemo, useRef} from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import Onyx, {withOnyx} from 'react-native-onyx';
+import OptionsListContextProvider from '@components/OptionListContextProvider';
import useOnboardingLayout from '@hooks/useOnboardingLayout';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -266,130 +267,132 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie
}, []);
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx
index 38bfe4af9ab6..fd5282a8cfcd 100644
--- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx
@@ -17,19 +17,21 @@ import * as Session from '@userActions/Session';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {Policy} from '@src/types/onyx';
+import type {Policy, Session as SessionType} from '@src/types/onyx';
type TopBarOnyxProps = {
policy: OnyxEntry;
+ session: OnyxEntry>;
};
// eslint-disable-next-line react/no-unused-prop-types
type TopBarProps = {activeWorkspaceID?: string} & TopBarOnyxProps;
-function TopBar({policy}: TopBarProps) {
+function TopBar({policy, session}: TopBarProps) {
const styles = useThemeStyles();
const theme = useTheme();
const {translate} = useLocalize();
+ const isAnonymousUser = Session.isAnonymousUser(session);
const headerBreadcrumb = policy?.name
? {type: CONST.BREADCRUMB_TYPE.STRONG, text: policy.name}
@@ -57,7 +59,7 @@ function TopBar({policy}: TopBarProps) {
/>
- {Session.isAnonymousUser() ? (
+ {isAnonymousUser ? (
) : (
@@ -84,4 +86,8 @@ export default withOnyx({
policy: {
key: ({activeWorkspaceID}) => `${ONYXKEYS.COLLECTION.POLICY}${activeWorkspaceID}`,
},
+ session: {
+ key: ONYXKEYS.SESSION,
+ selector: (session) => session && {authTokenType: session.authTokenType},
+ },
})(TopBar);
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 3acbd9232c87..6d0a1f2fc175 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -157,6 +157,9 @@ type GetOptionsConfig = {
includeSelectedOptions?: boolean;
includeTaxRates?: boolean;
taxRates?: TaxRatesWithDefault;
+ includePolicyReportFieldOptions?: boolean;
+ policyReportFieldOptions?: string[];
+ recentlyUsedPolicyReportFieldOptions?: string[];
transactionViolations?: OnyxCollection;
};
@@ -184,6 +187,7 @@ type GetOptions = {
categoryOptions: CategoryTreeSection[];
tagOptions: CategorySection[];
taxRatesOptions: CategorySection[];
+ policyReportFieldOptions?: CategorySection[] | null;
};
type PreviewConfig = {showChatPreviewLine?: boolean; forcePolicyNamePreview?: boolean; showPersonalDetails?: boolean};
@@ -1229,6 +1233,81 @@ function hasEnabledTags(policyTagList: Array
return hasEnabledOptions(policyTagValueList);
}
+/**
+ * Transforms the provided report field options into option objects.
+ *
+ * @param reportFieldOptions - an initial report field options array
+ */
+function getReportFieldOptions(reportFieldOptions: string[]): Option[] {
+ return reportFieldOptions.map((name) => ({
+ text: name,
+ keyForList: name,
+ searchText: name,
+ tooltipText: name,
+ isDisabled: false,
+ }));
+}
+
+/**
+ * Build the section list for report field options
+ */
+function getReportFieldOptionsSection(options: string[], recentlyUsedOptions: string[], selectedOptions: Array>, searchInputValue: string) {
+ const reportFieldOptionsSections = [];
+ const selectedOptionKeys = selectedOptions.map(({text, keyForList, name}) => text ?? keyForList ?? name ?? '').filter((o) => !!o);
+ let indexOffset = 0;
+
+ if (searchInputValue) {
+ const searchOptions = options.filter((option) => option.toLowerCase().includes(searchInputValue.toLowerCase()));
+
+ reportFieldOptionsSections.push({
+ // "Search" section
+ title: '',
+ shouldShow: true,
+ indexOffset,
+ data: getReportFieldOptions(searchOptions),
+ });
+
+ return reportFieldOptionsSections;
+ }
+
+ const filteredRecentlyUsedOptions = recentlyUsedOptions.filter((recentlyUsedOption) => !selectedOptionKeys.includes(recentlyUsedOption));
+ const filteredOptions = options.filter((option) => !selectedOptionKeys.includes(option));
+
+ if (selectedOptionKeys.length) {
+ reportFieldOptionsSections.push({
+ // "Selected" section
+ title: '',
+ shouldShow: true,
+ indexOffset,
+ data: getReportFieldOptions(selectedOptionKeys),
+ });
+
+ indexOffset += selectedOptionKeys.length;
+ }
+
+ if (filteredRecentlyUsedOptions.length > 0) {
+ reportFieldOptionsSections.push({
+ // "Recent" section
+ title: Localize.translateLocal('common.recent'),
+ shouldShow: true,
+ indexOffset,
+ data: getReportFieldOptions(filteredRecentlyUsedOptions),
+ });
+
+ indexOffset += filteredRecentlyUsedOptions.length;
+ }
+
+ reportFieldOptionsSections.push({
+ // "All" section when items amount more than the threshold
+ title: Localize.translateLocal('common.all'),
+ shouldShow: true,
+ indexOffset,
+ data: getReportFieldOptions(filteredOptions),
+ });
+
+ return reportFieldOptionsSections;
+}
+
/**
* Transforms tax rates to a new object format - to add codes and new name with concatenated name and value.
*
@@ -1454,6 +1533,9 @@ function getOptions(
includeTaxRates,
taxRates,
includeSelfDM = false,
+ includePolicyReportFieldOptions = false,
+ policyReportFieldOptions = [],
+ recentlyUsedPolicyReportFieldOptions = [],
}: GetOptionsConfig,
): GetOptions {
if (includeCategories) {
@@ -1498,6 +1580,20 @@ function getOptions(
};
}
+ if (includePolicyReportFieldOptions) {
+ const transformedPolicyReportFieldOptions = getReportFieldOptionsSection(policyReportFieldOptions, recentlyUsedPolicyReportFieldOptions, selectedOptions, searchInputValue);
+ return {
+ recentReports: [],
+ personalDetails: [],
+ userToInvite: null,
+ currentUserOption: null,
+ categoryOptions: [],
+ tagOptions: [],
+ taxRatesOptions: [],
+ policyReportFieldOptions: transformedPolicyReportFieldOptions,
+ };
+ }
+
const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchInputValue)));
const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number?.e164 : searchInputValue.toLowerCase();
const topmostReportId = Navigation.getTopmostReportId() ?? '';
@@ -1881,6 +1977,9 @@ function getFilteredOptions(
includeTaxRates = false,
taxRates: TaxRatesWithDefault = {} as TaxRatesWithDefault,
includeSelfDM = false,
+ includePolicyReportFieldOptions = false,
+ policyReportFieldOptions: string[] = [],
+ recentlyUsedPolicyReportFieldOptions: string[] = [],
) {
return getOptions(
{reports, personalDetails},
@@ -1905,6 +2004,9 @@ function getFilteredOptions(
includeTaxRates,
taxRates,
includeSelfDM,
+ includePolicyReportFieldOptions,
+ policyReportFieldOptions,
+ recentlyUsedPolicyReportFieldOptions,
},
);
}
@@ -2138,4 +2240,4 @@ export {
getTaxRatesSection,
};
-export type {MemberForList, CategorySection, GetOptions, OptionList, SearchOption, PayeePersonalDetails, Category, TaxRatesOption};
+export type {MemberForList, CategorySection, CategoryTreeSection, GetOptions, OptionList, SearchOption, PayeePersonalDetails, Category, TaxRatesOption};
diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts
index c9ea65781117..081d1139bf5d 100644
--- a/src/libs/PersonalDetailsUtils.ts
+++ b/src/libs/PersonalDetailsUtils.ts
@@ -17,11 +17,18 @@ type FirstAndLastName = {
let personalDetails: Array = [];
let allPersonalDetails: OnyxEntry = {};
+let emailToPersonalDetailsCache: Record = {};
Onyx.connect({
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (val) => {
personalDetails = Object.values(val ?? {});
allPersonalDetails = val;
+ emailToPersonalDetailsCache = personalDetails.reduce((acc: Record, detail) => {
+ if (detail?.login) {
+ acc[detail.login.toLowerCase()] = detail;
+ }
+ return acc;
+ }, {});
},
});
@@ -77,7 +84,7 @@ function getPersonalDetailsByIDs(accountIDs: number[], currentUserAccountID: num
}
function getPersonalDetailByEmail(email: string): PersonalDetails | undefined {
- return (Object.values(allPersonalDetails ?? {}) as PersonalDetails[]).find((detail) => detail?.login === email);
+ return emailToPersonalDetailsCache[email.toLowerCase()];
}
/**
diff --git a/src/libs/ReportActionComposeFocusManager.ts b/src/libs/ReportActionComposeFocusManager.ts
index 123d97987e14..11c1fd04329f 100644
--- a/src/libs/ReportActionComposeFocusManager.ts
+++ b/src/libs/ReportActionComposeFocusManager.ts
@@ -3,7 +3,7 @@ import type {TextInput} from 'react-native';
import ROUTES from '@src/ROUTES';
import Navigation from './Navigation/Navigation';
-type FocusCallback = () => void;
+type FocusCallback = (shouldFocusForNonBlurInputOnTapOutside?: boolean) => void;
const composerRef = React.createRef();
const editComposerRef = React.createRef();
@@ -18,7 +18,7 @@ let mainComposerFocusCallback: FocusCallback | null = null;
*
* @param callback callback to register
*/
-function onComposerFocus(callback: FocusCallback, isMainComposer = false) {
+function onComposerFocus(callback: FocusCallback | null, isMainComposer = false) {
if (isMainComposer) {
mainComposerFocusCallback = callback;
} else {
@@ -29,7 +29,7 @@ function onComposerFocus(callback: FocusCallback, isMainComposer = false) {
/**
* Request focus on the ReportActionComposer
*/
-function focus() {
+function focus(shouldFocusForNonBlurInputOnTapOutside?: boolean) {
/** Do not trigger the refocusing when the active route is not the report route, */
if (!Navigation.isActiveRoute(ROUTES.REPORT_WITH_ID.getRoute(Navigation.getTopmostReportId() ?? ''))) {
return;
@@ -40,7 +40,7 @@ function focus() {
return;
}
- mainComposerFocusCallback();
+ mainComposerFocusCallback(shouldFocusForNonBlurInputOnTapOutside);
return;
}
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 9d7b6b1d6549..79a3028725f6 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -29,6 +29,7 @@ import type {
ReportMetadata,
Session,
Task,
+ TaxRate,
Transaction,
TransactionViolation,
UserWallet,
@@ -416,6 +417,9 @@ type OptionData = {
isDisabled?: boolean | null;
name?: string | null;
isSelfDM?: boolean | null;
+ reportID?: string;
+ enabled?: boolean;
+ data?: Partial;
} & Report;
type OnyxDataTaskAssigneeChat = {
@@ -995,7 +999,7 @@ function filterReportsByPolicyIDAndMemberAccountIDs(reports: Report[], policyMem
/**
* Given an array of reports, return them sorted by the last read timestamp.
*/
-function sortReportsByLastRead(reports: Report[], reportMetadata: OnyxCollection): Array> {
+function sortReportsByLastRead(reports: Array>, reportMetadata: OnyxCollection): Array> {
return reports
.filter((report) => !!report?.reportID && !!(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]?.lastVisitTime ?? report?.lastReadTime))
.sort((a, b) => {
@@ -2779,10 +2783,11 @@ function getModifiedExpenseOriginalMessage(
if ('taxAmount' in transactionChanges) {
originalMessage.oldTaxAmount = TransactionUtils.getTaxAmount(oldTransaction, isFromExpenseReport);
originalMessage.taxAmount = transactionChanges?.taxAmount;
+ originalMessage.currency = TransactionUtils.getCurrency(oldTransaction);
}
if ('taxCode' in transactionChanges) {
- originalMessage.oldTaxRate = policy?.taxRates?.taxes[TransactionUtils.getTaxCode(oldTransaction)].value;
+ originalMessage.oldTaxRate = policy?.taxRates?.taxes[TransactionUtils.getTaxCode(oldTransaction)]?.value;
originalMessage.taxRate = transactionChanges?.taxCode && policy?.taxRates?.taxes[transactionChanges?.taxCode].value;
}
@@ -4803,6 +4808,7 @@ function canRequestMoney(report: OnyxEntry, policy: OnyxEntry, o
* - Send option should show for:
* - DMs
* - Split options should show for:
+ * - DMs
* - chat/ policy rooms with more than 1 participants
* - groups chats with 3 and more participants
* - corporate workspace chats
@@ -4828,7 +4834,6 @@ function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry currentUserPersonalDetails?.accountID !== accountID);
const hasSingleOtherParticipantInReport = otherParticipants.length === 1;
- const hasMultipleOtherParticipants = otherParticipants.length > 1;
let options: Array> = [];
if (isSelfDM(report)) {
@@ -4837,11 +4842,11 @@ function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry 0) ||
- (isDM(report) && hasMultipleOtherParticipants) ||
+ (isDM(report) && otherParticipants.length > 0) ||
(isGroupChat(report) && otherParticipants.length > 0) ||
(isPolicyExpenseChat(report) && report?.isOwnPolicyExpenseChat)
) {
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index 430100e84b2f..4244f20d0bc3 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -623,9 +623,9 @@ function getEnabledTaxRateCount(options: TaxRates) {
/**
* Gets the default tax name
*/
-function getDefaultTaxName(taxRates: TaxRatesWithDefault, transaction: Transaction) {
+function getDefaultTaxName(taxRates: TaxRatesWithDefault, transaction?: Transaction) {
const defaultTaxKey = taxRates.defaultExternalID;
- const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey].name} (${taxRates.taxes[defaultTaxKey].value}) • ${Localize.translateLocal('common.default')}`) || '';
+ const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey]?.name} (${taxRates.taxes[defaultTaxKey]?.value}) • ${Localize.translateLocal('common.default')}`) || '';
return transaction?.taxRate?.text ?? defaultTaxName;
}
@@ -633,9 +633,9 @@ function getDefaultTaxName(taxRates: TaxRatesWithDefault, transaction: Transacti
* Gets the tax name
*/
function getTaxName(taxes: TaxRates, transactionTaxCode: string) {
- const taxName = `${taxes[transactionTaxCode].name}`;
- const taxValue = `${taxes[transactionTaxCode].value}`;
- return transactionTaxCode ? `${taxName} (${taxValue})` : '';
+ const taxName = taxes[transactionTaxCode]?.name ?? '';
+ const taxValue = taxes[transactionTaxCode]?.value ?? '';
+ return transactionTaxCode && taxName && taxValue ? `${taxName} (${taxValue})` : '';
}
export {
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index ab49305b5f0b..1ee6665cef9a 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -2734,7 +2734,7 @@ function splitBill(
API.write(WRITE_COMMANDS.SPLIT_BILL, parameters, onyxData);
resetMoneyRequestInfo();
- Navigation.dismissModal();
+ Navigation.dismissModal(existingSplitChatReportID);
Report.notifyNewAction(splitData.chatReportID, currentUserAccountID);
}
diff --git a/src/libs/actions/OnyxUpdateManager.ts b/src/libs/actions/OnyxUpdateManager.ts
index 45cb2b78ecde..9c6f30cc5e9e 100644
--- a/src/libs/actions/OnyxUpdateManager.ts
+++ b/src/libs/actions/OnyxUpdateManager.ts
@@ -3,7 +3,6 @@ import Log from '@libs/Log';
import * as SequentialQueue from '@libs/Network/SequentialQueue';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {OnyxUpdatesFromServer, Response} from '@src/types/onyx';
import * as App from './App';
import * as OnyxUpdates from './OnyxUpdates';
@@ -28,134 +27,6 @@ Onyx.connect({
callback: (value) => (lastUpdateIDAppliedToClient = value),
});
-let queryPromise: Promise | undefined;
-
-type DeferredUpdatesDictionary = Record;
-let deferredUpdates: DeferredUpdatesDictionary = {};
-
-// This function will reset the query variables, unpause the SequentialQueue and log an info to the user.
-function finalizeUpdatesAndResumeQueue() {
- console.debug('[OnyxUpdateManager] Done applying all updates');
- queryPromise = undefined;
- deferredUpdates = {};
- Onyx.set(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, null);
- SequentialQueue.unpause();
-}
-
-// This function applies a list of updates to Onyx in order and resolves when all updates have been applied
-const applyUpdates = (updates: DeferredUpdatesDictionary) => Promise.all(Object.values(updates).map((update) => OnyxUpdates.apply(update)));
-
-// In order for the deferred updates to be applied correctly in order,
-// we need to check if there are any gaps between deferred updates.
-type DetectGapAndSplitResult = {applicableUpdates: DeferredUpdatesDictionary; updatesAfterGaps: DeferredUpdatesDictionary; latestMissingUpdateID: number | undefined};
-function detectGapsAndSplit(updates: DeferredUpdatesDictionary): DetectGapAndSplitResult {
- const updateValues = Object.values(updates);
- const applicableUpdates: DeferredUpdatesDictionary = {};
-
- let gapExists = false;
- let firstUpdateAfterGaps: number | undefined;
- let latestMissingUpdateID: number | undefined;
-
- for (const [index, update] of updateValues.entries()) {
- const isFirst = index === 0;
-
- // If any update's previousUpdateID doesn't match the lastUpdateID from the previous update, the deferred updates aren't chained and there's a gap.
- // For the first update, we need to check that the previousUpdateID of the fetched update is the same as the lastUpdateIDAppliedToClient.
- // For any other updates, we need to check if the previousUpdateID of the current update is found in the deferred updates.
- // If an update is chained, we can add it to the applicable updates.
- const isChained = isFirst ? update.previousUpdateID === lastUpdateIDAppliedToClient : !!updates[Number(update.previousUpdateID)];
- if (isChained) {
- // If a gap exists already, we will not add any more updates to the applicable updates.
- // Instead, once there are two chained updates again, we can set "firstUpdateAfterGaps" to the first update after the current gap.
- if (gapExists) {
- // If "firstUpdateAfterGaps" hasn't been set yet and there was a gap, we need to set it to the first update after all gaps.
- if (!firstUpdateAfterGaps) {
- firstUpdateAfterGaps = Number(update.previousUpdateID);
- }
- } else {
- // If no gap exists yet, we can add the update to the applicable updates
- applicableUpdates[Number(update.lastUpdateID)] = update;
- }
- } else {
- // When we find a (new) gap, we need to set "gapExists" to true and reset the "firstUpdateAfterGaps" variable,
- // so that we can continue searching for the next update after all gaps
- gapExists = true;
- firstUpdateAfterGaps = undefined;
-
- // If there is a gap, it means the previous update is the latest missing update.
- latestMissingUpdateID = Number(update.previousUpdateID);
- }
- }
-
- // When "firstUpdateAfterGaps" is not set yet, we need to set it to the last update in the list,
- // because we will fetch all missing updates up to the previous one and can then always apply the last update in the deferred updates.
- if (!firstUpdateAfterGaps) {
- firstUpdateAfterGaps = Number(updateValues[updateValues.length - 1].lastUpdateID);
- }
-
- let updatesAfterGaps: DeferredUpdatesDictionary = {};
- if (gapExists && firstUpdateAfterGaps) {
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- updatesAfterGaps = Object.fromEntries(Object.entries(updates).filter(([lastUpdateID]) => Number(lastUpdateID) >= firstUpdateAfterGaps!));
- }
-
- return {applicableUpdates, updatesAfterGaps, latestMissingUpdateID};
-}
-
-// This function will check for gaps in the deferred updates and
-// apply the updates in order after the missing updates are fetched and applied
-function validateAndApplyDeferredUpdates(): Promise {
- // We only want to apply deferred updates that are newer than the last update that was applied to the client.
- // At this point, the missing updates from "GetMissingOnyxUpdates" have been applied already, so we can safely filter out.
- const pendingDeferredUpdates = Object.fromEntries(
- Object.entries(deferredUpdates).filter(([lastUpdateID]) => {
- // It should not be possible for lastUpdateIDAppliedToClient to be null,
- // after the missing updates have been applied.
- // If still so we want to keep the deferred update in the list.
- if (!lastUpdateIDAppliedToClient) {
- return true;
- }
- return (Number(lastUpdateID) ?? 0) > lastUpdateIDAppliedToClient;
- }),
- );
-
- // If there are no remaining deferred updates after filtering out outdated ones,
- // we can just unpause the queue and return
- if (Object.values(pendingDeferredUpdates).length === 0) {
- return Promise.resolve();
- }
-
- const {applicableUpdates, updatesAfterGaps, latestMissingUpdateID} = detectGapsAndSplit(pendingDeferredUpdates);
-
- // If we detected a gap in the deferred updates, only apply the deferred updates before the gap,
- // re-fetch the missing updates and then apply the remaining deferred updates after the gap
- if (latestMissingUpdateID) {
- return new Promise((resolve, reject) => {
- deferredUpdates = {};
- applyUpdates(applicableUpdates).then(() => {
- // After we have applied the applicable updates, there might have been new deferred updates added.
- // In the next (recursive) call of "validateAndApplyDeferredUpdates",
- // the initial "updatesAfterGaps" and all new deferred updates will be applied in order,
- // as long as there was no new gap detected. Otherwise repeat the process.
- deferredUpdates = {...deferredUpdates, ...updatesAfterGaps};
-
- // It should not be possible for lastUpdateIDAppliedToClient to be null, therefore we can ignore this case.
- // If lastUpdateIDAppliedToClient got updated in the meantime, we will just retrigger the validation and application of the current deferred updates.
- if (!lastUpdateIDAppliedToClient || latestMissingUpdateID <= lastUpdateIDAppliedToClient) {
- validateAndApplyDeferredUpdates().then(resolve).catch(reject);
- return;
- }
-
- // Then we can fetch the missing updates and apply them
- App.getMissingOnyxUpdates(lastUpdateIDAppliedToClient, latestMissingUpdateID).then(validateAndApplyDeferredUpdates).then(resolve).catch(reject);
- });
- });
- }
-
- // If there are no gaps in the deferred updates, we can apply all deferred updates in order
- return applyUpdates(applicableUpdates);
-}
-
export default () => {
console.debug('[OnyxUpdateManager] Listening for updates from the server');
Onyx.connect({
@@ -195,45 +66,32 @@ export default () => {
// applied in their correct and specific order. If this queue was not paused, then there would be a lot of
// onyx data being applied while we are fetching the missing updates and that would put them all out of order.
SequentialQueue.pause();
+ let canUnpauseQueuePromise;
// The flow below is setting the promise to a reconnect app to address flow (1) explained above.
if (!lastUpdateIDAppliedToClient) {
- // If there is a ReconnectApp query in progress, we should not start another one.
- if (queryPromise) {
- return;
- }
-
Log.info('Client has not gotten reliable updates before so reconnecting the app to start the process');
// Since this is a full reconnectApp, we'll not apply the updates we received - those will come in the reconnect app request.
- queryPromise = App.finalReconnectAppAfterActivatingReliableUpdates();
+ canUnpauseQueuePromise = App.finalReconnectAppAfterActivatingReliableUpdates();
} else {
// The flow below is setting the promise to a getMissingOnyxUpdates to address flow (2) explained above.
-
- // Get the number of deferred updates before adding the new one
- const existingDeferredUpdatesCount = Object.keys(deferredUpdates).length;
-
- // Add the new update to the deferred updates
- deferredUpdates[Number(updateParams.lastUpdateID)] = updateParams;
-
- // If there are deferred updates already, we don't need to fetch the missing updates again.
- if (existingDeferredUpdatesCount > 0) {
- return;
- }
-
console.debug(`[OnyxUpdateManager] Client is behind the server by ${Number(previousUpdateIDFromServer) - lastUpdateIDAppliedToClient} so fetching incremental updates`);
Log.info('Gap detected in update IDs from server so fetching incremental updates', true, {
lastUpdateIDFromServer,
previousUpdateIDFromServer,
lastUpdateIDAppliedToClient,
});
-
- // Get the missing Onyx updates from the server and afterwards validate and apply the deferred updates.
- // This will trigger recursive calls to "validateAndApplyDeferredUpdates" if there are gaps in the deferred updates.
- queryPromise = App.getMissingOnyxUpdates(lastUpdateIDAppliedToClient, previousUpdateIDFromServer).then(validateAndApplyDeferredUpdates);
+ canUnpauseQueuePromise = App.getMissingOnyxUpdates(lastUpdateIDAppliedToClient, previousUpdateIDFromServer);
}
- queryPromise.finally(finalizeUpdatesAndResumeQueue);
+ canUnpauseQueuePromise.finally(() => {
+ OnyxUpdates.apply(updateParams).finally(() => {
+ console.debug('[OnyxUpdateManager] Done applying all updates');
+ Onyx.set(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, null);
+ SequentialQueue.unpause();
+ });
+ });
},
});
};
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index 179ee87862ff..91128ac89178 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -70,16 +70,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import INPUT_IDS from '@src/types/form/NewRoomForm';
-import type {
- NewGroupChatDraft,
- PersonalDetails,
- PersonalDetailsList,
- PolicyReportField,
- RecentlyUsedReportFields,
- ReportActionReactions,
- ReportMetadata,
- ReportUserIsTyping,
-} from '@src/types/onyx';
+import type {PersonalDetails, PersonalDetailsList, PolicyReportField, RecentlyUsedReportFields, ReportActionReactions, ReportMetadata, ReportUserIsTyping} from '@src/types/onyx';
import type {Decision, OriginalMessageIOU} from '@src/types/onyx/OriginalMessage';
import type {NotificationPreference, RoomVisibility, WriteCapability} from '@src/types/onyx/Report';
import type Report from '@src/types/onyx/Report';
@@ -212,12 +203,6 @@ Onyx.connect({
callback: (val) => (allRecentlyUsedReportFields = val),
});
-let newGroupDraft: OnyxEntry;
-Onyx.connect({
- key: ONYXKEYS.NEW_GROUP_CHAT_DRAFT,
- callback: (value) => (newGroupDraft = value),
-});
-
function clearGroupChat() {
Onyx.set(ONYXKEYS.NEW_GROUP_CHAT_DRAFT, null);
}
@@ -799,14 +784,15 @@ function navigateToAndOpenReport(userLogins: string[], shouldDismissModal = true
let newChat: ReportUtils.OptimisticChatReport | EmptyObject = {};
let chat: OnyxEntry | EmptyObject = {};
const participantAccountIDs = PersonalDetailsUtils.getAccountIDsByLogins(userLogins);
+ const isGroupChat = participantAccountIDs.length > 1;
// If we are not creating a new Group Chat then we are creating a 1:1 DM and will look for an existing chat
- if (!newGroupDraft) {
+ if (!isGroupChat) {
chat = ReportUtils.getChatByParticipants(participantAccountIDs);
}
if (isEmptyObject(chat)) {
- if (newGroupDraft) {
+ if (isGroupChat) {
newChat = ReportUtils.buildOptimisticChatReport(
participantAccountIDs,
reportName,
@@ -1612,6 +1598,18 @@ function updateReportName(reportID: string, value: string, previousValue: string
API.write(WRITE_COMMANDS.SET_REPORT_NAME, parameters, {optimisticData, failureData, successData});
}
+function clearReportFieldErrors(reportID: string, reportField: PolicyReportField) {
+ const fieldKey = ReportUtils.getReportFieldKey(reportField.fieldID);
+ Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {
+ pendingFields: {
+ [fieldKey]: null,
+ },
+ errorFields: {
+ [fieldKey]: null,
+ },
+ });
+}
+
function updateReportField(reportID: string, reportField: PolicyReportField, previousReportField: PolicyReportField) {
const fieldKey = ReportUtils.getReportFieldKey(reportField.fieldID);
const recentlyUsedValues = allRecentlyUsedReportFields?.[fieldKey] ?? [];
@@ -1692,6 +1690,65 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre
API.write(WRITE_COMMANDS.SET_REPORT_FIELD, parameters, {optimisticData, failureData, successData});
}
+function deleteReportField(reportID: string, reportField: PolicyReportField) {
+ const fieldKey = ReportUtils.getReportFieldKey(reportField.fieldID);
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {
+ fieldList: {
+ [fieldKey]: null,
+ },
+ pendingFields: {
+ [fieldKey]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {
+ fieldList: {
+ [fieldKey]: reportField,
+ },
+ pendingFields: {
+ [fieldKey]: null,
+ },
+ errorFields: {
+ [fieldKey]: ErrorUtils.getMicroSecondOnyxError('report.genericUpdateReportFieldFailureMessage'),
+ },
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {
+ pendingFields: {
+ [fieldKey]: null,
+ },
+ errorFields: {
+ [fieldKey]: null,
+ },
+ },
+ },
+ ];
+
+ const parameters = {
+ reportID,
+ fieldID: fieldKey,
+ };
+
+ API.write(WRITE_COMMANDS.DELETE_REPORT_FIELD, parameters, {optimisticData, failureData, successData});
+}
+
function updateDescription(reportID: string, previousValue: string, newValue: string) {
// No change needed, navigate back
if (previousValue === newValue) {
@@ -3024,6 +3081,8 @@ export {
clearNewRoomFormError,
updateReportField,
updateReportName,
+ deleteReportField,
+ clearReportFieldErrors,
resolveActionableMentionWhisper,
updateRoomVisibility,
setGroupDraft,
diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts
index 17004baef43e..7f7531a094fa 100644
--- a/src/libs/actions/Session/index.ts
+++ b/src/libs/actions/Session/index.ts
@@ -2,7 +2,7 @@ import throttle from 'lodash/throttle';
import type {ChannelAuthorizationData} from 'pusher-js/types/src/core/auth/options';
import type {ChannelAuthorizationCallback} from 'pusher-js/with-encryption';
import {InteractionManager, Linking, NativeModules} from 'react-native';
-import type {OnyxUpdate} from 'react-native-onyx';
+import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import * as PersistedRequests from '@libs/actions/PersistedRequests';
@@ -175,8 +175,8 @@ function signOut() {
/**
* Checks if the account is an anonymous account.
*/
-function isAnonymousUser(): boolean {
- return session.authTokenType === CONST.AUTH_TOKEN_TYPES.ANONYMOUS;
+function isAnonymousUser(sessionParam?: OnyxEntry): boolean {
+ return (sessionParam?.authTokenType ?? session.authTokenType) === CONST.AUTH_TOKEN_TYPES.ANONYMOUS;
}
function hasStashedSession(): boolean {
diff --git a/src/libs/focusComposerWithDelay/index.ts b/src/libs/focusComposerWithDelay/index.ts
index 75e8f6ca8a67..cbd81b884d12 100644
--- a/src/libs/focusComposerWithDelay/index.ts
+++ b/src/libs/focusComposerWithDelay/index.ts
@@ -1,4 +1,5 @@
import ComposerFocusManager from '@libs/ComposerFocusManager';
+import isWindowReadyToFocus from '@libs/isWindowReadyToFocus';
import * as EmojiPickerAction from '@userActions/EmojiPickerAction';
import setTextInputSelection from './setTextInputSelection';
import type {FocusComposerWithDelay, InputType} from './types';
@@ -26,7 +27,7 @@ function focusComposerWithDelay(textInput: InputType | null): FocusComposerWithD
}
return;
}
- ComposerFocusManager.isReadyToFocus().then(() => {
+ Promise.all([ComposerFocusManager.isReadyToFocus(), isWindowReadyToFocus()]).then(() => {
if (!textInput) {
return;
}
diff --git a/src/libs/isWindowReadyToFocus/index.android.ts b/src/libs/isWindowReadyToFocus/index.android.ts
new file mode 100644
index 000000000000..b9cca1b5a294
--- /dev/null
+++ b/src/libs/isWindowReadyToFocus/index.android.ts
@@ -0,0 +1,27 @@
+import {AppState} from 'react-native';
+
+let isWindowReadyPromise = Promise.resolve();
+let resolveWindowReadyToFocus: () => void;
+
+AppState.addEventListener('focus', () => {
+ if (!resolveWindowReadyToFocus) {
+ return;
+ }
+ resolveWindowReadyToFocus();
+});
+
+AppState.addEventListener('blur', () => {
+ isWindowReadyPromise = new Promise((resolve) => {
+ resolveWindowReadyToFocus = resolve;
+ });
+});
+
+/**
+ * If we want to show the soft keyboard reliably, we need to ensure that the input's window gains focus first.
+ * Fortunately, we only need to manage the focus of the app window now,
+ * so we can achieve this by listening to the 'focus' event of the AppState.
+ * See {@link https://developer.android.com/develop/ui/views/touch-and-input/keyboard-input/visibility#ShowReliably}
+ */
+const isWindowReadyToFocus = () => isWindowReadyPromise;
+
+export default isWindowReadyToFocus;
diff --git a/src/libs/isWindowReadyToFocus/index.ts b/src/libs/isWindowReadyToFocus/index.ts
new file mode 100644
index 000000000000..7ae3930c0c1d
--- /dev/null
+++ b/src/libs/isWindowReadyToFocus/index.ts
@@ -0,0 +1,3 @@
+const isWindowReadyToFocus = () => Promise.resolve();
+
+export default isWindowReadyToFocus;
diff --git a/src/pages/EditReportFieldDatePage.tsx b/src/pages/EditReportFieldDate.tsx
similarity index 53%
rename from src/pages/EditReportFieldDatePage.tsx
rename to src/pages/EditReportFieldDate.tsx
index 3d60884d3cfc..e7021f9123d6 100644
--- a/src/pages/EditReportFieldDatePage.tsx
+++ b/src/pages/EditReportFieldDate.tsx
@@ -4,9 +4,7 @@ import DatePicker from '@components/DatePicker';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
import type {AnimatedTextInputRef} from '@components/RNTextInput';
-import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
@@ -46,40 +44,29 @@ function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, f
);
return (
- {
- inputRef.current?.focus();
- }}
- testID={EditReportFieldDatePage.displayName}
+
-
-
-
- {/* @ts-expect-error TODO: Remove this once DatePicker (https://github.com/Expensify/App/issues/25148) is migrated to TypeScript. */}
-
- InputComponent={DatePicker}
- inputID={fieldKey}
- name={fieldKey}
- defaultValue={fieldValue}
- label={fieldName}
- accessibilityLabel={fieldName}
- role={CONST.ROLE.PRESENTATION}
- maxDate={CONST.CALENDAR_PICKER.MAX_DATE}
- minDate={CONST.CALENDAR_PICKER.MIN_DATE}
- ref={inputRef}
- />
-
-
-
+
+
+
+
);
}
diff --git a/src/pages/EditReportFieldDropdown.tsx b/src/pages/EditReportFieldDropdown.tsx
new file mode 100644
index 000000000000..225051238e2b
--- /dev/null
+++ b/src/pages/EditReportFieldDropdown.tsx
@@ -0,0 +1,125 @@
+import React, {useCallback, useMemo} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import type {OnyxEntry} from 'react-native-onyx';
+import Icon from '@components/Icon';
+import * as Expensicons from '@components/Icon/Expensicons';
+import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
+import type {ListItem} from '@components/SelectionList/types';
+import useDebouncedState from '@hooks/useDebouncedState';
+import useLocalize from '@hooks/useLocalize';
+import useTheme from '@hooks/useTheme';
+import * as OptionsListUtils from '@libs/OptionsListUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {RecentlyUsedReportFields} from '@src/types/onyx';
+
+type EditReportFieldDropdownPageComponentProps = {
+ /** Value of the policy report field */
+ fieldValue: string;
+
+ /** Key of the policy report field */
+ fieldKey: string;
+
+ /** ID of the policy this report field belongs to */
+ // eslint-disable-next-line react/no-unused-prop-types
+ policyID: string;
+
+ /** Options of the policy report field */
+ fieldOptions: string[];
+
+ /** Callback to fire when the Save button is pressed */
+ onSubmit: (form: Record) => void;
+};
+
+type EditReportFieldDropdownPageOnyxProps = {
+ recentlyUsedReportFields: OnyxEntry;
+};
+
+type EditReportFieldDropdownPageProps = EditReportFieldDropdownPageComponentProps & EditReportFieldDropdownPageOnyxProps;
+
+function EditReportFieldDropdownPage({onSubmit, fieldKey, fieldValue, fieldOptions, recentlyUsedReportFields}: EditReportFieldDropdownPageProps) {
+ const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState('');
+ const theme = useTheme();
+ const {translate} = useLocalize();
+ const recentlyUsedOptions = useMemo(() => recentlyUsedReportFields?.[fieldKey] ?? [], [recentlyUsedReportFields, fieldKey]);
+
+ const itemRightSideComponent = useCallback(
+ (item: ListItem) => {
+ if (item.text === fieldValue) {
+ return (
+
+ );
+ }
+
+ return null;
+ },
+ [theme.iconSuccessFill, fieldValue],
+ );
+
+ const [sections, headerMessage] = useMemo(() => {
+ const validFieldOptions = fieldOptions?.filter((option) => !!option);
+
+ const {policyReportFieldOptions} = OptionsListUtils.getFilteredOptions(
+ [],
+ [],
+ [],
+ debouncedSearchValue,
+ [
+ {
+ keyForList: fieldValue,
+ searchText: fieldValue,
+ text: fieldValue,
+ },
+ ],
+ [],
+ false,
+ false,
+ false,
+ {},
+ [],
+ false,
+ {},
+ [],
+ false,
+ false,
+ undefined,
+ undefined,
+ undefined,
+ true,
+ validFieldOptions,
+ recentlyUsedOptions,
+ );
+
+ const policyReportFieldData = policyReportFieldOptions?.[0]?.data ?? [];
+ const header = OptionsListUtils.getHeaderMessageForNonUserList(policyReportFieldData.length > 0, debouncedSearchValue);
+
+ return [policyReportFieldOptions, header];
+ }, [recentlyUsedOptions, debouncedSearchValue, fieldValue, fieldOptions]);
+
+ const selectedOptionKey = useMemo(() => (sections?.[0]?.data ?? []).filter((option) => option.searchText === fieldValue)?.[0]?.keyForList, [sections, fieldValue]);
+ return (
+ onSubmit({[fieldKey]: !option?.text || fieldValue === option.text ? '' : option.text})}
+ initiallyFocusedOptionKey={selectedOptionKey ?? undefined}
+ onChangeText={setSearchValue}
+ headerMessage={headerMessage}
+ ListItem={RadioListItem}
+ isRowMultilineSupported
+ rightHandSideComponent={itemRightSideComponent}
+ />
+ );
+}
+
+EditReportFieldDropdownPage.displayName = 'EditReportFieldDropdownPage';
+
+export default withOnyx({
+ recentlyUsedReportFields: {
+ key: () => ONYXKEYS.RECENTLY_USED_REPORT_FIELDS,
+ },
+})(EditReportFieldDropdownPage);
diff --git a/src/pages/EditReportFieldDropdownPage.tsx b/src/pages/EditReportFieldDropdownPage.tsx
deleted file mode 100644
index e887860ae155..000000000000
--- a/src/pages/EditReportFieldDropdownPage.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import React, {useMemo, useState} from 'react';
-import {withOnyx} from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import OptionsSelector from '@components/OptionsSelector';
-import ScreenWrapper from '@components/ScreenWrapper';
-import useLocalize from '@hooks/useLocalize';
-import useStyleUtils from '@hooks/useStyleUtils';
-import useThemeStyles from '@hooks/useThemeStyles';
-import ONYXKEYS from '@src/ONYXKEYS';
-import type {RecentlyUsedReportFields} from '@src/types/onyx';
-
-type EditReportFieldDropdownPageComponentProps = {
- /** Value of the policy report field */
- fieldValue: string;
-
- /** Name of the policy report field */
- fieldName: string;
-
- /** Key of the policy report field */
- fieldKey: string;
-
- /** ID of the policy this report field belongs to */
- // eslint-disable-next-line react/no-unused-prop-types
- policyID: string;
-
- /** Options of the policy report field */
- fieldOptions: string[];
-
- /** Callback to fire when the Save button is pressed */
- onSubmit: (form: Record) => void;
-};
-
-type EditReportFieldDropdownPageOnyxProps = {
- recentlyUsedReportFields: OnyxEntry;
-};
-
-type EditReportFieldDropdownPageProps = EditReportFieldDropdownPageComponentProps & EditReportFieldDropdownPageOnyxProps;
-
-type ReportFieldDropdownData = {
- text: string;
- keyForList: string;
- searchText: string;
- tooltipText: string;
-};
-
-type ReportFieldDropdownSectionItem = {
- data: ReportFieldDropdownData[];
- shouldShow: boolean;
- title?: string;
-};
-
-function EditReportFieldDropdownPage({fieldName, onSubmit, fieldKey, fieldValue, fieldOptions, recentlyUsedReportFields}: EditReportFieldDropdownPageProps) {
- const [searchValue, setSearchValue] = useState('');
- const styles = useThemeStyles();
- const {getSafeAreaMargins} = useStyleUtils();
- const {translate} = useLocalize();
- const recentlyUsedOptions = useMemo(() => recentlyUsedReportFields?.[fieldKey] ?? [], [recentlyUsedReportFields, fieldKey]);
-
- const {sections, headerMessage} = useMemo(() => {
- let newHeaderMessage = '';
- const newSections: ReportFieldDropdownSectionItem[] = [];
-
- if (searchValue) {
- const filteredOptions = fieldOptions.filter((option) => option.toLowerCase().includes(searchValue.toLowerCase()));
- newHeaderMessage = !filteredOptions.length ? translate('common.noResultsFound') : '';
- newSections.push({
- shouldShow: false,
- data: filteredOptions.map((option) => ({
- text: option,
- keyForList: option,
- searchText: option,
- tooltipText: option,
- })),
- });
- } else {
- const selectedValue = fieldValue;
- if (selectedValue) {
- newSections.push({
- shouldShow: false,
- data: [
- {
- text: selectedValue,
- keyForList: selectedValue,
- searchText: selectedValue,
- tooltipText: selectedValue,
- },
- ],
- });
- }
-
- const filteredRecentlyUsedOptions = recentlyUsedOptions.filter((option) => option !== selectedValue && fieldOptions.includes(option));
- if (filteredRecentlyUsedOptions.length > 0) {
- newSections.push({
- title: translate('common.recents'),
- shouldShow: true,
- data: filteredRecentlyUsedOptions.map((option) => ({
- text: option,
- keyForList: option,
- searchText: option,
- tooltipText: option,
- })),
- });
- }
-
- const filteredFieldOptions = fieldOptions.filter((option) => option !== selectedValue);
- if (filteredFieldOptions.length > 0) {
- newSections.push({
- title: translate('common.all'),
- shouldShow: true,
- data: filteredFieldOptions.map((option) => ({
- text: option,
- keyForList: option,
- searchText: option,
- tooltipText: option,
- })),
- });
- }
- }
-
- return {sections: newSections, headerMessage: newHeaderMessage};
- }, [fieldValue, fieldOptions, recentlyUsedOptions, searchValue, translate]);
-
- return (
-
- {({insets}) => (
- <>
-
- ) =>
- onSubmit({
- [fieldKey]: fieldValue === option.text ? '' : option.text,
- })
- }
- onChangeText={setSearchValue}
- highlightSelectedOptions
- isRowMultilineSupported
- headerMessage={headerMessage}
- />
- >
- )}
-
- );
-}
-
-EditReportFieldDropdownPage.displayName = 'EditReportFieldDropdownPage';
-
-export default withOnyx({
- recentlyUsedReportFields: {
- key: () => ONYXKEYS.RECENTLY_USED_REPORT_FIELDS,
- },
-})(EditReportFieldDropdownPage);
diff --git a/src/pages/EditReportFieldPage.tsx b/src/pages/EditReportFieldPage.tsx
index 72a472db3da0..6cc93d05ebbc 100644
--- a/src/pages/EditReportFieldPage.tsx
+++ b/src/pages/EditReportFieldPage.tsx
@@ -1,18 +1,25 @@
import Str from 'expensify-common/lib/str';
-import React from 'react';
+import React, {useState} from 'react';
import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
+import ConfirmModal from '@components/ConfirmModal';
import type {FormOnyxValues} from '@components/Form/types';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import type {ThreeDotsMenuItem} from '@components/HeaderWithBackButton/types';
+import * as Expensicons from '@components/Icon/Expensicons';
import ScreenWrapper from '@components/ScreenWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
import Navigation from '@libs/Navigation/Navigation';
import * as ReportUtils from '@libs/ReportUtils';
import * as ReportActions from '@src/libs/actions/Report';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, Report} from '@src/types/onyx';
-import EditReportFieldDatePage from './EditReportFieldDatePage';
-import EditReportFieldDropdownPage from './EditReportFieldDropdownPage';
-import EditReportFieldTextPage from './EditReportFieldTextPage';
+import EditReportFieldDate from './EditReportFieldDate';
+import EditReportFieldDropdown from './EditReportFieldDropdown';
+import EditReportFieldText from './EditReportFieldText';
type EditReportFieldPageOnyxProps = {
/** The report object for the expense report */
@@ -40,9 +47,13 @@ type EditReportFieldPageProps = EditReportFieldPageOnyxProps & {
};
function EditReportFieldPage({route, policy, report}: EditReportFieldPageProps) {
+ const {windowWidth} = useWindowDimensions();
+ const styles = useThemeStyles();
const fieldKey = ReportUtils.getReportFieldKey(route.params.fieldID);
const reportField = report?.fieldList?.[fieldKey] ?? policy?.fieldList?.[fieldKey];
const isDisabled = ReportUtils.isReportFieldDisabled(report, reportField ?? null, policy);
+ const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
+ const {translate} = useLocalize();
if (!reportField || !report || isDisabled) {
return (
@@ -73,44 +84,78 @@ function EditReportFieldPage({route, policy, report}: EditReportFieldPageProps)
Navigation.dismissModal(report?.reportID);
};
+ const handleReportFieldDelete = () => {
+ ReportActions.deleteReportField(report.reportID, reportField);
+ Navigation.dismissModal(report?.reportID);
+ };
+
const fieldValue = isReportFieldTitle ? report.reportName ?? '' : reportField.value ?? reportField.defaultValue;
- if (reportField.type === 'text' || isReportFieldTitle) {
- return (
-
- );
+ const menuItems: ThreeDotsMenuItem[] = [];
+
+ const isReportFieldDeletable = reportField.deletable && !isReportFieldTitle;
+
+ if (isReportFieldDeletable) {
+ menuItems.push({icon: Expensicons.Trashcan, text: translate('common.delete'), onSelected: () => setIsDeleteModalVisible(true)});
}
- if (reportField.type === 'date') {
- return (
-
+
- );
- }
- if (reportField.type === 'dropdown') {
- return (
- !reportField.disabledOptions[index])}
- onSubmit={handleReportFieldChange}
+ setIsDeleteModalVisible(false)}
+ prompt={translate('workspace.reportFields.deleteConfirmation')}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
/>
- );
- }
+
+ {(reportField.type === 'text' || isReportFieldTitle) && (
+
+ )}
+
+ {reportField.type === 'date' && (
+
+ )}
+
+ {reportField.type === 'dropdown' && (
+ !reportField.disabledOptions[index])}
+ onSubmit={handleReportFieldChange}
+ />
+ )}
+
+ );
}
EditReportFieldPage.displayName = 'EditReportFieldPage';
diff --git a/src/pages/EditReportFieldTextPage.tsx b/src/pages/EditReportFieldText.tsx
similarity index 58%
rename from src/pages/EditReportFieldTextPage.tsx
rename to src/pages/EditReportFieldText.tsx
index 1a6cf96fb37a..d89724f0228b 100644
--- a/src/pages/EditReportFieldTextPage.tsx
+++ b/src/pages/EditReportFieldText.tsx
@@ -3,9 +3,7 @@ import {View} from 'react-native';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
import type {AnimatedTextInputRef} from '@components/RNTextInput';
-import ScreenWrapper from '@components/ScreenWrapper';
import TextInput from '@components/TextInput';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -46,37 +44,27 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f
);
return (
- {
- inputRef.current?.focus();
- }}
- testID={EditReportFieldTextPage.displayName}
+
-
-
-
-
-
-
-
+
+
+
+
);
}
diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js
index ec49e32a5f0f..bef826300af2 100644
--- a/src/pages/EditRequestPage.js
+++ b/src/pages/EditRequestPage.js
@@ -73,7 +73,7 @@ const defaultProps = {
};
const getTaxAmount = (transactionAmount, transactionTaxCode, taxRates) => {
- const percentage = (transactionTaxCode ? taxRates.taxes[transactionTaxCode].value : taxRates.defaultValue) || '';
+ const percentage = (transactionTaxCode && taxRates.taxes[transactionTaxCode] ? taxRates.taxes[transactionTaxCode].value : taxRates.defaultValue) || '';
return CurrencyUtils.convertToBackendAmount(Number.parseFloat(TransactionUtils.calculateTaxAmount(percentage, transactionAmount)));
};
diff --git a/src/pages/EditRequestTaxAmountPage.tsx b/src/pages/EditRequestTaxAmountPage.tsx
index a34ed8a5252d..2b6a6169637a 100644
--- a/src/pages/EditRequestTaxAmountPage.tsx
+++ b/src/pages/EditRequestTaxAmountPage.tsx
@@ -24,13 +24,13 @@ type EditRequestTaxAmountPageProps = {
function EditRequestTaxAmountPage({defaultAmount, defaultTaxAmount, defaultCurrency, onSubmit}: EditRequestTaxAmountPageProps) {
const {translate} = useLocalize();
- const textInput = useRef(null);
+ const textInput = useRef();
const focusTimeoutRef = useRef(null);
useFocusEffect(
useCallback(() => {
- focusTimeoutRef.current = setTimeout(() => textInput.current && textInput.current.focus(), CONST.ANIMATED_TRANSITION);
+ focusTimeoutRef.current = setTimeout(() => textInput.current?.focus(), CONST.ANIMATED_TRANSITION);
return () => {
if (!focusTimeoutRef.current) {
return;
@@ -52,7 +52,7 @@ function EditRequestTaxAmountPage({defaultAmount, defaultTaxAmount, defaultCurre
currency={defaultCurrency}
amount={defaultAmount}
taxAmount={defaultTaxAmount}
- ref={textInput}
+ ref={(e) => (textInput.current = e)}
isCurrencyPressable={false}
onSubmitButtonPress={onSubmit}
isEditing
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
index c8e0b64cc434..4aba9e43b1c0 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
@@ -1,6 +1,7 @@
import ExpensiMark from 'expensify-common/lib/ExpensiMark';
import type {MutableRefObject} from 'react';
import React from 'react';
+import {InteractionManager} from 'react-native';
// eslint-disable-next-line no-restricted-imports
import type {GestureResponderEvent, Text, View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
@@ -200,7 +201,11 @@ const ContextMenuActions: ContextMenuAction[] = [
onPress: (closePopover, {reportAction, reportID}) => {
if (closePopover) {
hideContextMenu(false, () => {
- ReportActionComposeFocusManager.focus();
+ InteractionManager.runAfterInteractions(() => {
+ // Normally the focus callback of the main composer doesn't focus when willBlurTextInputOnTapOutside
+ // is false, so we need to pass true here to override this condition.
+ ReportActionComposeFocusManager.focus(true);
+ });
Report.navigateToAndOpenChildReport(reportAction?.childReportID ?? '0', reportAction, reportID);
});
return;
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
index bfcef66e7c54..f8147dfda81d 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
@@ -591,8 +591,8 @@ function ComposerWithSuggestions(
const setUpComposeFocusManager = useCallback(() => {
// This callback is used in the contextMenuActions to manage giving focus back to the compose input.
- ReportActionComposeFocusManager.onComposerFocus(() => {
- if (!willBlurTextInputOnTapOutside || !isFocused) {
+ ReportActionComposeFocusManager.onComposerFocus((shouldFocusForNonBlurInputOnTapOutside = false) => {
+ if ((!willBlurTextInputOnTapOutside && !shouldFocusForNonBlurInputOnTapOutside) || !isFocused) {
return;
}
diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx
index eeeb5b95273c..02d7a14f4b0e 100644
--- a/src/pages/home/report/ReportActionItem.tsx
+++ b/src/pages/home/report/ReportActionItem.tsx
@@ -366,9 +366,10 @@ function ReportActionItem({
anchor: popoverAnchorRef.current,
report,
action,
+ transactionThreadReport,
checkIfContextMenuActive: toggleContextMenuFromActiveReportAction,
}),
- [report, action, toggleContextMenuFromActiveReportAction],
+ [report, action, toggleContextMenuFromActiveReportAction, transactionThreadReport],
);
const actionableItemButtons: ActionableItem[] = useMemo(() => {
diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.tsx b/src/pages/iou/steps/MoneyRequestAmountForm.tsx
index 755da80fb93f..ca7bb694599a 100644
--- a/src/pages/iou/steps/MoneyRequestAmountForm.tsx
+++ b/src/pages/iou/steps/MoneyRequestAmountForm.tsx
@@ -232,7 +232,7 @@ function MoneyRequestAmountForm(
*/
const submitAndNavigateToNextPage = useCallback(() => {
// Skip the check for tax amount form as 0 is a valid input
- if (!isTaxAmountForm && isAmountInvalid(currentAmount)) {
+ if (!currentAmount.length || (!isTaxAmountForm && isAmountInvalid(currentAmount))) {
setFormError('iou.error.invalidAmount');
return;
}
diff --git a/src/pages/settings/AboutPage/ShareLogList/BaseShareLogList.tsx b/src/pages/settings/AboutPage/ShareLogList/BaseShareLogList.tsx
index cee62380a011..578efbe5317b 100644
--- a/src/pages/settings/AboutPage/ShareLogList/BaseShareLogList.tsx
+++ b/src/pages/settings/AboutPage/ShareLogList/BaseShareLogList.tsx
@@ -1,65 +1,52 @@
-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
-import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
+import React, {useMemo} from 'react';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import {useBetas} from '@components/OnyxProvider';
import {useOptionsList} from '@components/OptionListContextProvider';
-import OptionsSelector from '@components/OptionsSelector';
import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import UserListItem from '@components/SelectionList/UserListItem';
+import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
-import useThemeStyles from '@hooks/useThemeStyles';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
-import ONYXKEYS from '@src/ONYXKEYS';
+import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import type {Report} from '@src/types/onyx';
-import type {BaseShareLogListOnyxProps, BaseShareLogListProps} from './types';
+import type {BaseShareLogListProps} from './types';
-function BaseShareLogList({betas, onAttachLogToReport}: BaseShareLogListProps) {
- const [searchValue, setSearchValue] = useState('');
- const [searchOptions, setSearchOptions] = useState>({
- recentReports: [],
- personalDetails: [],
- userToInvite: null,
- });
+function BaseShareLogList({onAttachLogToReport}: BaseShareLogListProps) {
+ const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState('');
const {isOffline} = useNetwork();
const {translate} = useLocalize();
- const styles = useThemeStyles();
- const isMounted = useRef(false);
+ const betas = useBetas();
const {options, areOptionsInitialized} = useOptionsList();
- const updateOptions = useCallback(() => {
+
+ const searchOptions = useMemo(() => {
+ if (!areOptionsInitialized) {
+ return {
+ recentReports: [],
+ personalDetails: [],
+ userToInvite: undefined,
+ headerMessage: '',
+ };
+ }
const {
recentReports: localRecentReports,
personalDetails: localPersonalDetails,
userToInvite: localUserToInvite,
- } = OptionsListUtils.getShareLogOptions(options, searchValue.trim(), betas ?? []);
+ } = OptionsListUtils.getShareLogOptions(options, debouncedSearchValue.trim(), betas ?? []);
- setSearchOptions({
+ const header = OptionsListUtils.getHeaderMessage((localRecentReports?.length || 0) + (localPersonalDetails?.length || 0) !== 0, Boolean(localUserToInvite), debouncedSearchValue);
+
+ return {
recentReports: localRecentReports,
personalDetails: localPersonalDetails,
userToInvite: localUserToInvite,
- });
- }, [betas, options, searchValue]);
-
- useEffect(() => {
- if (!areOptionsInitialized) {
- return;
- }
-
- updateOptions();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [options, areOptionsInitialized]);
-
- useEffect(() => {
- if (!isMounted.current) {
- isMounted.current = true;
- return;
- }
-
- updateOptions();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [searchValue]);
+ headerMessage: header,
+ };
+ }, [areOptionsInitialized, options, debouncedSearchValue, betas]);
const sections = useMemo(() => {
const sectionsList = [];
@@ -84,17 +71,7 @@ function BaseShareLogList({betas, onAttachLogToReport}: BaseShareLogListProps) {
}
return sectionsList;
- }, [searchOptions.personalDetails, searchOptions.recentReports, searchOptions.userToInvite, translate]);
-
- const headerMessage = OptionsListUtils.getHeaderMessage(
- searchOptions.recentReports.length + searchOptions.personalDetails.length !== 0,
- Boolean(searchOptions.userToInvite),
- searchValue,
- );
-
- const onChangeText = (value = '') => {
- setSearchValue(value);
- };
+ }, [searchOptions?.personalDetails, searchOptions?.recentReports, searchOptions?.userToInvite, translate]);
const attachLogToReport = (option: Report) => {
if (!option.reportID) {
@@ -110,28 +87,23 @@ function BaseShareLogList({betas, onAttachLogToReport}: BaseShareLogListProps) {
testID={BaseShareLogList.displayName}
includeSafeAreaPaddingBottom={false}
>
- {({safeAreaPaddingBottomStyle}) => (
+ {({didScreenTransitionEnd}) => (
<>
Navigation.goBack(ROUTES.SETTINGS_CONSOLE)}
/>
-
-
-
+
>
)}
@@ -140,12 +112,4 @@ function BaseShareLogList({betas, onAttachLogToReport}: BaseShareLogListProps) {
BaseShareLogList.displayName = 'ShareLogPage';
-export default withOnyx({
- reports: {
- key: ONYXKEYS.COLLECTION.REPORT,
- },
- betas: {
- key: ONYXKEYS.BETAS,
- initialValue: [],
- },
-})(BaseShareLogList);
+export default BaseShareLogList;
diff --git a/src/pages/settings/AboutPage/ShareLogList/types.ts b/src/pages/settings/AboutPage/ShareLogList/types.ts
index abbdbfb88e0b..500641a3da42 100644
--- a/src/pages/settings/AboutPage/ShareLogList/types.ts
+++ b/src/pages/settings/AboutPage/ShareLogList/types.ts
@@ -1,21 +1,10 @@
-import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
-import type {Beta, Report} from '@src/types/onyx';
-
-type BaseShareLogListOnyxProps = {
- /** Beta features list */
- betas: OnyxEntry;
-
- /** All reports shared with the user */
- reports: OnyxCollection;
-};
-
type ShareLogListProps = {
/** The source of the log file to share */
logSource: string;
};
-type BaseShareLogListProps = BaseShareLogListOnyxProps & {
+type BaseShareLogListProps = {
onAttachLogToReport: (reportID: string, filename: string) => void;
};
-export type {BaseShareLogListOnyxProps, BaseShareLogListProps, ShareLogListProps};
+export type {BaseShareLogListProps, ShareLogListProps};
diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
index a3a0da328a09..5c2dbe6de352 100644
--- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
+++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
@@ -24,6 +24,7 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import {deleteWorkspaceCategories, setWorkspaceCategoryEnabled} from '@libs/actions/Policy';
+import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import localeCompare from '@libs/LocaleCompare';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
@@ -294,6 +295,7 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat
sections={[{data: categoryList, isDisabled: false}]}
onCheckboxPress={toggleCategory}
onSelectRow={navigateToCategorySettings}
+ shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
onSelectAll={toggleAllCategories}
showScrollIndicator
ListItem={TableListItem}
diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx
index b3ddaf43a618..37c827ebdf31 100644
--- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx
+++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx
@@ -22,6 +22,7 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as CurrencyUtils from '@libs/CurrencyUtils';
+import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import Navigation from '@libs/Navigation/Navigation';
import type {WorkspacesCentralPaneNavigatorParamList} from '@navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
@@ -298,6 +299,7 @@ function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps)
onDismissError={dismissError}
showScrollIndicator
ListItem={TableListItem}
+ shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
customListHeader={getCustomListHeader()}
listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]}
/>
diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx
index cd6b3bcc1b66..eed0015a9aa4 100644
--- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx
+++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx
@@ -24,6 +24,7 @@ import useNetwork from '@hooks/useNetwork';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
+import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import localeCompare from '@libs/LocaleCompare';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
@@ -305,6 +306,7 @@ function WorkspaceTagsPage({policyTags, route}: WorkspaceTagsPageProps) {
showScrollIndicator
ListItem={TableListItem}
customListHeader={getCustomListHeader()}
+ shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]}
onDismissError={(item) => Policy.clearPolicyTagErrors(route.params.policyID, item.value)}
/>
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
index bb94dfbc1e19..7628bf25a1b8 100644
--- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
+++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
@@ -22,6 +22,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import {openPolicyTaxesPage} from '@libs/actions/Policy';
import {clearTaxRateError, deletePolicyTaxes, setPolicyTaxesEnabled} from '@libs/actions/TaxRate';
+import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import * as ErrorUtils from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
@@ -268,6 +269,7 @@ function WorkspaceTaxesPage({
showScrollIndicator
ListItem={TableListItem}
customListHeader={getCustomListHeader()}
+ shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]}
onDismissError={(item) => (item.keyForList ? clearTaxRateError(policyID, item.keyForList, item.pendingAction) : undefined)}
/>
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency.tsx b/src/pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency.tsx
index dbe94ba802ef..3081df55fe69 100644
--- a/src/pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency.tsx
+++ b/src/pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency.tsx
@@ -3,13 +3,13 @@ import React from 'react';
import {View} from 'react-native';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
-import type {ListItem} from '@components/SelectionList/types';
import TaxPicker from '@components/TaxPicker';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import {setForeignCurrencyDefault} from '@libs/actions/Policy';
import Navigation from '@libs/Navigation/Navigation';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import type * as OptionsListUtils from '@libs/OptionsListUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
@@ -22,7 +22,6 @@ import type SCREENS from '@src/SCREENS';
type WorkspaceTaxesSettingsForeignCurrencyProps = WithPolicyAndFullscreenLoadingProps &
StackScreenProps;
-
function WorkspaceTaxesSettingsForeignCurrency({
route: {
params: {policyID},
@@ -32,10 +31,15 @@ function WorkspaceTaxesSettingsForeignCurrency({
const {translate} = useLocalize();
const styles = useThemeStyles();
- const selectedTaxRate = TransactionUtils.getTaxName(policy?.taxRates?.taxes ?? {}, policy?.taxRates?.foreignTaxDefault ?? '');
+ const taxRates = policy?.taxRates;
+ const foreignTaxDefault = taxRates?.foreignTaxDefault ?? '';
+ const defaultExternalID = taxRates?.defaultExternalID ?? '';
+
+ const selectedTaxRate =
+ foreignTaxDefault === defaultExternalID ? taxRates && TransactionUtils.getDefaultTaxName(taxRates) : TransactionUtils.getTaxName(taxRates?.taxes ?? {}, foreignTaxDefault);
- const submit = ({keyForList}: ListItem) => {
- setForeignCurrencyDefault(policyID, keyForList ?? '');
+ const submit = (taxes: OptionsListUtils.TaxRatesOption) => {
+ setForeignCurrencyDefault(policyID, taxes.data.code ?? '');
Navigation.goBack(ROUTES.WORKSPACE_TAXES_SETTINGS.getRoute(policyID));
};
@@ -59,6 +63,7 @@ function WorkspaceTaxesSettingsForeignCurrency({
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesSettingsWorkspaceCurrency.tsx b/src/pages/workspace/taxes/WorkspaceTaxesSettingsWorkspaceCurrency.tsx
index c6de23069837..630560f864b4 100644
--- a/src/pages/workspace/taxes/WorkspaceTaxesSettingsWorkspaceCurrency.tsx
+++ b/src/pages/workspace/taxes/WorkspaceTaxesSettingsWorkspaceCurrency.tsx
@@ -3,13 +3,13 @@ import React from 'react';
import {View} from 'react-native';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
-import type {ListItem} from '@components/SelectionList/types';
import TaxPicker from '@components/TaxPicker';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import {setWorkspaceCurrencyDefault} from '@libs/actions/Policy';
import Navigation from '@libs/Navigation/Navigation';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import type * as OptionsListUtils from '@libs/OptionsListUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
@@ -32,9 +32,10 @@ function WorkspaceTaxesSettingsWorkspaceCurrency({
const {translate} = useLocalize();
const styles = useThemeStyles();
- const selectedTaxRate = TransactionUtils.getTaxName(policy?.taxRates?.taxes ?? {}, policy?.taxRates?.foreignTaxDefault ?? '');
- const submit = ({keyForList}: ListItem) => {
- setWorkspaceCurrencyDefault(policyID, keyForList ?? '');
+ const selectedTaxRate = policy?.taxRates && TransactionUtils.getDefaultTaxName(policy?.taxRates);
+
+ const submit = (taxes: OptionsListUtils.TaxRatesOption) => {
+ setWorkspaceCurrencyDefault(policyID, taxes.data.code ?? '');
Navigation.goBack(ROUTES.WORKSPACE_TAXES_SETTINGS.getRoute(policyID));
};
@@ -58,6 +59,7 @@ function WorkspaceTaxesSettingsWorkspaceCurrency({
diff --git a/src/types/onyx/Card.ts b/src/types/onyx/Card.ts
index 53415690705a..11cc8dc26b4c 100644
--- a/src/types/onyx/Card.ts
+++ b/src/types/onyx/Card.ts
@@ -8,18 +8,22 @@ type Card = {
bank: string;
availableSpend: number;
domainName: string;
- maskedPan?: string; // do not reference, removing as part of Expensify/App#27943
lastFourPAN?: string;
- cardName: string;
- cardTitle: string; // used only for virtual limit cards
- limitType: ValueOf;
- isAdminIssuedVirtualCard: boolean;
- isVirtual: boolean;
+ isVirtual: boolean; // Deprecating, use nameValuePairs.isVirtual
fraud: ValueOf;
- cardholderFirstName: string;
- cardholderLastName: string;
errors?: OnyxCommon.Errors;
isLoading?: boolean;
+ nameValuePairs?: {
+ limitType?: ValueOf;
+ cardTitle?: string; // Used only for admin-issued virtual cards
+ issuedBy?: number;
+ hasCustomUnapprovedExpenseLimit?: boolean;
+ unapprovedExpenseLimit?: number;
+ feedCountry?: string;
+ isVirtual?: boolean;
+ previousState?: number;
+ expirationDate?: string;
+ };
};
type TCardDetails = {
diff --git a/tests/e2e/compare/output/console.ts b/tests/e2e/compare/output/console.ts
index de8e5d913893..77170e43f4a6 100644
--- a/tests/e2e/compare/output/console.ts
+++ b/tests/e2e/compare/output/console.ts
@@ -13,6 +13,8 @@ type Entry = {
type Data = {
significance: Entry[];
meaningless: Entry[];
+ errors?: string[];
+ warnings?: string[];
};
const printRegularLine = (entry: Entry) => {
@@ -36,4 +38,4 @@ export default (data: Data) => {
console.debug('');
};
-export type {Entry};
+export type {Data, Entry};
diff --git a/tests/e2e/compare/output/markdown.js b/tests/e2e/compare/output/markdown.ts
similarity index 57%
rename from tests/e2e/compare/output/markdown.js
rename to tests/e2e/compare/output/markdown.ts
index 119830a5bb2c..34bc3251c422 100644
--- a/tests/e2e/compare/output/markdown.js
+++ b/tests/e2e/compare/output/markdown.ts
@@ -1,80 +1,85 @@
// From: https://raw.githubusercontent.com/callstack/reassure/main/packages/reassure-compare/src/output/markdown.ts
import fs from 'node:fs/promises';
import path from 'path';
-import _ from 'underscore';
+import type {Stats} from 'tests/e2e/measure/math';
import * as Logger from '../../utils/logger';
+import type {Data, Entry} from './console';
import * as format from './format';
import markdownTable from './markdownTable';
const tableHeader = ['Name', 'Duration'];
-const collapsibleSection = (title, content) => `\n${title}
\n\n${content}\n \n\n`;
+const collapsibleSection = (title: string, content: string) => `\n${title}
\n\n${content}\n \n\n`;
-const buildDurationDetails = (title, entry) => {
+const buildDurationDetails = (title: string, entry: Stats) => {
const relativeStdev = entry.stdev / entry.mean;
- return _.filter(
- [
- `**${title}**`,
- `Mean: ${format.formatDuration(entry.mean)}`,
- `Stdev: ${format.formatDuration(entry.stdev)} (${format.formatPercent(relativeStdev)})`,
- entry.entries ? `Runs: ${entry.entries.join(' ')}` : '',
- ],
- Boolean,
- ).join('
');
+ return [
+ `**${title}**`,
+ `Mean: ${format.formatDuration(entry.mean)}`,
+ `Stdev: ${format.formatDuration(entry.stdev)} (${format.formatPercent(relativeStdev)})`,
+ entry.entries ? `Runs: ${entry.entries.join(' ')}` : '',
+ ]
+ .filter(Boolean)
+ .join('
');
};
-const buildDurationDetailsEntry = (entry) =>
- _.filter(['baseline' in entry ? buildDurationDetails('Baseline', entry.baseline) : '', 'current' in entry ? buildDurationDetails('Current', entry.current) : ''], Boolean).join(
- '
',
- );
+const buildDurationDetailsEntry = (entry: Entry) =>
+ ['baseline' in entry ? buildDurationDetails('Baseline', entry.baseline) : '', 'current' in entry ? buildDurationDetails('Current', entry.current) : '']
+ .filter(Boolean)
+ .join('
');
+
+const formatEntryDuration = (entry: Entry): string => {
+ let formattedDuration = '';
-const formatEntryDuration = (entry) => {
if ('baseline' in entry && 'current' in entry) {
- return format.formatDurationDiffChange(entry);
+ formattedDuration = format.formatDurationDiffChange(entry);
}
+
if ('baseline' in entry) {
- return format.formatDuration(entry.baseline.mean);
+ formattedDuration = format.formatDuration(entry.baseline.mean);
}
+
if ('current' in entry) {
- return format.formatDuration(entry.current.mean);
+ formattedDuration = format.formatDuration(entry.current.mean);
}
- return '';
+
+ return formattedDuration;
};
-const buildDetailsTable = (entries) => {
+const buildDetailsTable = (entries: Entry[]) => {
if (!entries.length) {
return '';
}
- const rows = _.map(entries, (entry) => [entry.name, buildDurationDetailsEntry(entry)]);
+ const rows = entries.map((entry) => [entry.name, buildDurationDetailsEntry(entry)]);
const content = markdownTable([tableHeader, ...rows]);
return collapsibleSection('Show details', content);
};
-const buildSummaryTable = (entries, collapse = false) => {
+const buildSummaryTable = (entries: Entry[], collapse = false) => {
if (!entries.length) {
return '_There are no entries_';
}
- const rows = _.map(entries, (entry) => [entry.name, formatEntryDuration(entry)]);
+ const rows = entries.map((entry) => [entry.name, formatEntryDuration(entry)]);
const content = markdownTable([tableHeader, ...rows]);
return collapse ? collapsibleSection('Show entries', content) : content;
};
-const buildMarkdown = (data) => {
+const buildMarkdown = (data: Data) => {
let result = '## Performance Comparison Report 📊';
- if (data.errors && data.errors.length) {
+ if (data.errors?.length) {
result += '\n\n### Errors\n';
data.errors.forEach((message) => {
result += ` 1. 🛑 ${message}\n`;
});
}
- if (data.warnings && data.warnings.length) {
+ if (data.warnings?.length) {
result += '\n\n### Warnings\n';
data.warnings.forEach((message) => {
result += ` 1. 🟡 ${message}\n`;
@@ -92,7 +97,7 @@ const buildMarkdown = (data) => {
return result;
};
-const writeToFile = (filePath, content) =>
+const writeToFile = (filePath: string, content: string) =>
fs
.writeFile(filePath, content)
.then(() => {
@@ -106,7 +111,7 @@ const writeToFile = (filePath, content) =>
throw error;
});
-const writeToMarkdown = (filePath, data) => {
+const writeToMarkdown = (filePath: string, data: Data) => {
const markdown = buildMarkdown(data);
return writeToFile(filePath, markdown).catch((error) => {
console.error(error);
diff --git a/tests/perf-test/OptionsSelector.perf-test.tsx b/tests/perf-test/OptionsSelector.perf-test.tsx
index 44dc4ac6c317..fe234dda1e19 100644
--- a/tests/perf-test/OptionsSelector.perf-test.tsx
+++ b/tests/perf-test/OptionsSelector.perf-test.tsx
@@ -5,6 +5,7 @@ import type {ComponentType} from 'react';
import {measurePerformance} from 'reassure';
import type {WithLocalizeProps} from '@components/withLocalize';
import type {WithNavigationFocusProps} from '@components/withNavigationFocus';
+import type Navigation from '@libs/Navigation/Navigation';
import OptionsSelector from '@src/components/OptionsSelector';
import variables from '@src/styles/variables';
@@ -38,6 +39,18 @@ jest.mock('@src/components/withNavigationFocus', () => (Component: ComponentType
return WithNavigationFocus;
});
+jest.mock('@react-navigation/native', () => {
+ const actualNav = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualNav,
+ useNavigation: () => ({
+ navigate: jest.fn(),
+ addListener: () => jest.fn(),
+ }),
+ useIsFocused: () => true,
+ } as typeof Navigation;
+});
+
type GenerateSectionsProps = Array<{numberOfItems: number; shouldShow?: boolean}>;
const generateSections = (sections: GenerateSectionsProps) =>
diff --git a/tests/perf-test/ReportActionCompose.perf-test.tsx b/tests/perf-test/ReportActionCompose.perf-test.tsx
index 8d6cb1ac7e57..91e4f51f1c66 100644
--- a/tests/perf-test/ReportActionCompose.perf-test.tsx
+++ b/tests/perf-test/ReportActionCompose.perf-test.tsx
@@ -38,9 +38,7 @@ jest.mock('@react-navigation/native', () => {
navigate: jest.fn(),
addListener: () => jest.fn(),
}),
- useIsFocused: () => ({
- navigate: jest.fn(),
- }),
+ useIsFocused: () => true,
} as typeof Navigation;
});
diff --git a/tests/perf-test/ReportScreen.perf-test.tsx b/tests/perf-test/ReportScreen.perf-test.tsx
index ff3d1473c662..b55f4b9ccb93 100644
--- a/tests/perf-test/ReportScreen.perf-test.tsx
+++ b/tests/perf-test/ReportScreen.perf-test.tsx
@@ -81,15 +81,12 @@ jest.mock('@src/hooks/usePermissions.ts');
jest.mock('@src/libs/Navigation/Navigation');
-const mockedNavigate = jest.fn();
jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual('@react-navigation/native');
return {
...actualNav,
useFocusEffect: jest.fn(),
- useIsFocused: () => ({
- navigate: mockedNavigate,
- }),
+ useIsFocused: () => true,
useRoute: () => jest.fn(),
useNavigation: () => ({
navigate: jest.fn(),
diff --git a/tests/perf-test/SearchPage.perf-test.tsx b/tests/perf-test/SearchPage.perf-test.tsx
index 95f5630e3fe9..b7cdee0d7417 100644
--- a/tests/perf-test/SearchPage.perf-test.tsx
+++ b/tests/perf-test/SearchPage.perf-test.tsx
@@ -45,15 +45,12 @@ jest.mock('@src/libs/API', () => ({
jest.mock('@src/libs/Navigation/Navigation');
-const mockedNavigate = jest.fn();
jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual('@react-navigation/native');
return {
...actualNav,
useFocusEffect: jest.fn(),
- useIsFocused: () => ({
- navigate: mockedNavigate,
- }),
+ useIsFocused: () => true,
useRoute: () => jest.fn(),
useNavigation: () => ({
navigate: jest.fn(),
diff --git a/tests/perf-test/SidebarLinks.perf-test.js b/tests/perf-test/SidebarLinks.perf-test.tsx
similarity index 79%
rename from tests/perf-test/SidebarLinks.perf-test.js
rename to tests/perf-test/SidebarLinks.perf-test.tsx
index 0b10718fd0c4..2848015d5c63 100644
--- a/tests/perf-test/SidebarLinks.perf-test.js
+++ b/tests/perf-test/SidebarLinks.perf-test.tsx
@@ -1,32 +1,33 @@
import {fireEvent, screen} from '@testing-library/react-native';
import Onyx from 'react-native-onyx';
import {measurePerformance} from 'reassure';
-import _ from 'underscore';
-import CONST from '../../src/CONST';
-import ONYXKEYS from '../../src/ONYXKEYS';
-import variables from '../../src/styles/variables';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
import * as LHNTestUtils from '../utils/LHNTestUtils';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates';
-jest.mock('../../src/libs/Permissions');
-jest.mock('../../src/hooks/usePermissions.ts');
-jest.mock('../../src/libs/Navigation/Navigation');
-jest.mock('../../src/components/Icon/Expensicons');
+jest.mock('@libs/Permissions');
+jest.mock('@hooks/usePermissions.ts');
+jest.mock('@libs/Navigation/Navigation');
+jest.mock('@components/Icon/Expensicons');
jest.mock('@react-navigation/native');
const getMockedReportsMap = (length = 100) => {
- const mockReports = Array.from({length}, (__, i) => {
- const reportID = i + 1;
- const participants = [1, 2];
- const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportID}`;
- const report = LHNTestUtils.getFakeReport(participants, 1, true);
-
- return {[reportKey]: report};
- });
-
- return _.assign({}, ...mockReports);
+ const mockReports = Object.fromEntries(
+ Array.from({length}, (value, index) => {
+ const reportID = index + 1;
+ const participants = [1, 2];
+ const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportID}`;
+ const report = LHNTestUtils.getFakeReport(participants, 1, true);
+
+ return [reportKey, report];
+ }),
+ );
+
+ return mockReports;
};
const mockedResponseMap = getMockedReportsMap(500);
@@ -36,11 +37,9 @@ describe('SidebarLinks', () => {
Onyx.init({
keys: ONYXKEYS,
safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
- registerStorageEventListener: () => {},
});
Onyx.multiSet({
- [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS],
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
diff --git a/tests/perf-test/SignInPage.perf-test.tsx b/tests/perf-test/SignInPage.perf-test.tsx
index e3e2c20ae72a..dc93b0d81059 100644
--- a/tests/perf-test/SignInPage.perf-test.tsx
+++ b/tests/perf-test/SignInPage.perf-test.tsx
@@ -26,15 +26,12 @@ jest.mock('../../src/libs/API', () => ({
read: jest.fn(),
}));
-const mockedNavigate = jest.fn();
jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual('@react-navigation/native');
return {
...actualNav,
useFocusEffect: jest.fn(),
- useIsFocused: () => ({
- navigate: mockedNavigate,
- }),
+ useIsFocused: () => true,
useRoute: () => jest.fn(),
useNavigation: () => ({
navigate: jest.fn(),
diff --git a/tests/unit/DateUtilsTest.js b/tests/unit/DateUtilsTest.ts
similarity index 81%
rename from tests/unit/DateUtilsTest.js
rename to tests/unit/DateUtilsTest.ts
index a752eea1a990..9df0113168e4 100644
--- a/tests/unit/DateUtilsTest.js
+++ b/tests/unit/DateUtilsTest.ts
@@ -1,9 +1,11 @@
+/* eslint-disable @typescript-eslint/naming-convention */
import {addDays, addMinutes, format, setHours, setMinutes, subDays, subHours, subMinutes, subSeconds} from 'date-fns';
import {format as tzFormat, utcToZonedTime} from 'date-fns-tz';
import Onyx from 'react-native-onyx';
-import CONST from '../../src/CONST';
-import DateUtils from '../../src/libs/DateUtils';
-import ONYXKEYS from '../../src/ONYXKEYS';
+import DateUtils from '@libs/DateUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
const LOCALE = CONST.LOCALES.EN;
@@ -13,14 +15,27 @@ describe('DateUtils', () => {
Onyx.init({
keys: ONYXKEYS,
initialKeyStates: {
- [ONYXKEYS.SESSION]: {accountID: 999},
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: {999: {timezone: {selected: UTC}}},
+ [ONYXKEYS.SESSION]: {
+ accountID: 999,
+ },
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: {
+ '999': {
+ accountID: 999,
+ timezone: {
+ // UTC is not recognized as a valid timezone but
+ // in these tests we want to use it to avoid issues
+ // because of daylight saving time
+ selected: UTC as SelectedTimezone,
+ },
+ },
+ },
},
});
return waitForBatchedUpdates();
});
afterEach(() => {
+ jest.restoreAllMocks();
jest.useRealTimers();
Onyx.clear();
});
@@ -39,7 +54,7 @@ describe('DateUtils', () => {
});
it('formatToDayOfWeek should return a weekday', () => {
- const weekDay = DateUtils.formatToDayOfWeek(datetime);
+ const weekDay = DateUtils.formatToDayOfWeek(new Date(datetime));
expect(weekDay).toBe('Monday');
});
it('formatToLocalTime should return a date in a local format', () => {
@@ -53,32 +68,35 @@ describe('DateUtils', () => {
});
it('should fallback to current date when getLocalDateFromDatetime is failing', () => {
- const localDate = DateUtils.getLocalDateFromDatetime(LOCALE, undefined, 'InvalidTimezone');
+ const localDate = DateUtils.getLocalDateFromDatetime(LOCALE, undefined, 'InvalidTimezone' as SelectedTimezone);
expect(localDate.getTime()).not.toBeNaN();
});
it('should return the date in calendar time when calling datetimeToCalendarTime', () => {
- const today = setMinutes(setHours(new Date(), 14), 32);
+ const today = setMinutes(setHours(new Date(), 14), 32).toString();
expect(DateUtils.datetimeToCalendarTime(LOCALE, today)).toBe('Today at 2:32 PM');
- const tomorrow = addDays(setMinutes(setHours(new Date(), 14), 32), 1);
+ const tomorrow = addDays(setMinutes(setHours(new Date(), 14), 32), 1).toString();
expect(DateUtils.datetimeToCalendarTime(LOCALE, tomorrow)).toBe('Tomorrow at 2:32 PM');
- const yesterday = setMinutes(setHours(subDays(new Date(), 1), 7), 43);
+ const yesterday = setMinutes(setHours(subDays(new Date(), 1), 7), 43).toString();
expect(DateUtils.datetimeToCalendarTime(LOCALE, yesterday)).toBe('Yesterday at 7:43 AM');
- const date = setMinutes(setHours(new Date('2022-11-05'), 10), 17);
+ const date = setMinutes(setHours(new Date('2022-11-05'), 10), 17).toString();
expect(DateUtils.datetimeToCalendarTime(LOCALE, date)).toBe('Nov 5, 2022 at 10:17 AM');
- const todayLowercaseDate = setMinutes(setHours(new Date(), 14), 32);
+ const todayLowercaseDate = setMinutes(setHours(new Date(), 14), 32).toString();
expect(DateUtils.datetimeToCalendarTime(LOCALE, todayLowercaseDate, false, undefined, true)).toBe('today at 2:32 PM');
});
it('should update timezone if automatic and selected timezone do not match', () => {
- Intl.DateTimeFormat = jest.fn(() => ({
- resolvedOptions: () => ({timeZone: 'America/Chicago'}),
- }));
- Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {999: {timezone: {selected: UTC, automatic: true}}}).then(() => {
+ jest.spyOn(Intl, 'DateTimeFormat').mockImplementation(
+ () =>
+ ({
+ resolvedOptions: () => ({timeZone: 'America/Chicago'}),
+ } as Intl.DateTimeFormat),
+ );
+ Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {'999': {accountID: 999, timezone: {selected: 'Europe/London', automatic: true}}}).then(() => {
const result = DateUtils.getCurrentTimezone();
expect(result).toEqual({
selected: 'America/Chicago',
@@ -88,10 +106,13 @@ describe('DateUtils', () => {
});
it('should not update timezone if automatic and selected timezone match', () => {
- Intl.DateTimeFormat = jest.fn(() => ({
- resolvedOptions: () => ({timeZone: UTC}),
- }));
- Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {999: {timezone: {selected: UTC, automatic: true}}}).then(() => {
+ jest.spyOn(Intl, 'DateTimeFormat').mockImplementation(
+ () =>
+ ({
+ resolvedOptions: () => ({timeZone: UTC}),
+ } as Intl.DateTimeFormat),
+ );
+ Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {'999': {accountID: 999, timezone: {selected: 'Europe/London', automatic: true}}}).then(() => {
const result = DateUtils.getCurrentTimezone();
expect(result).toEqual({
selected: UTC,
@@ -102,7 +123,7 @@ describe('DateUtils', () => {
it('canUpdateTimezone should return true when lastUpdatedTimezoneTime is more than 5 minutes ago', () => {
// Use fake timers to control the current time
- jest.useFakeTimers('modern');
+ jest.useFakeTimers();
jest.setSystemTime(addMinutes(new Date(), 6));
const isUpdateTimezoneAllowed = DateUtils.canUpdateTimezone();
expect(isUpdateTimezoneAllowed).toBe(true);
@@ -110,20 +131,20 @@ describe('DateUtils', () => {
it('canUpdateTimezone should return false when lastUpdatedTimezoneTime is less than 5 minutes ago', () => {
// Use fake timers to control the current time
- jest.useFakeTimers('modern');
+ jest.useFakeTimers();
jest.setSystemTime(addMinutes(new Date(), 4));
const isUpdateTimezoneAllowed = DateUtils.canUpdateTimezone();
expect(isUpdateTimezoneAllowed).toBe(false);
});
it('should return the date in calendar time when calling datetimeToRelative', () => {
- const aFewSecondsAgo = subSeconds(new Date(), 10);
+ const aFewSecondsAgo = subSeconds(new Date(), 10).toString();
expect(DateUtils.datetimeToRelative(LOCALE, aFewSecondsAgo)).toBe('less than a minute ago');
- const aMinuteAgo = subMinutes(new Date(), 1);
+ const aMinuteAgo = subMinutes(new Date(), 1).toString();
expect(DateUtils.datetimeToRelative(LOCALE, aMinuteAgo)).toBe('1 minute ago');
- const anHourAgo = subHours(new Date(), 1);
+ const anHourAgo = subHours(new Date(), 1).toString();
expect(DateUtils.datetimeToRelative(LOCALE, anHourAgo)).toBe('about 1 hour ago');
});
diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.ts
similarity index 86%
rename from tests/unit/OptionsListUtilsTest.js
rename to tests/unit/OptionsListUtilsTest.ts
index d590236e5256..9fd7b8449afd 100644
--- a/tests/unit/OptionsListUtilsTest.js
+++ b/tests/unit/OptionsListUtilsTest.ts
@@ -1,29 +1,34 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import type {OnyxCollection} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
-import _ from 'underscore';
-import CONST from '../../src/CONST';
-import * as OptionsListUtils from '../../src/libs/OptionsListUtils';
-import * as ReportUtils from '../../src/libs/ReportUtils';
-import ONYXKEYS from '../../src/ONYXKEYS';
+import type {SelectedTagOption} from '@components/TagPicker';
+import CONST from '@src/CONST';
+import * as OptionsListUtils from '@src/libs/OptionsListUtils';
+import * as ReportUtils from '@src/libs/ReportUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {PersonalDetails, Policy, PolicyCategories, Report, TaxRatesWithDefault} from '@src/types/onyx';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+type PersonalDetailsList = Record;
+
describe('OptionsListUtils', () => {
// Given a set of reports with both single participants and multiple participants some pinned and some not
- const REPORTS = {
- 1: {
+ const REPORTS: OnyxCollection = {
+ '1': {
lastReadTime: '2021-01-14 11:25:39.295',
lastVisibleActionCreated: '2022-11-22 03:26:02.015',
isPinned: false,
- reportID: 1,
+ reportID: '1',
participantAccountIDs: [2, 1],
visibleChatMemberAccountIDs: [2, 1],
reportName: 'Iron Man, Mister Fantastic',
type: CONST.REPORT.TYPE.CHAT,
},
- 2: {
+ '2': {
lastReadTime: '2021-01-14 11:25:39.296',
lastVisibleActionCreated: '2022-11-22 03:26:02.016',
isPinned: false,
- reportID: 2,
+ reportID: '2',
participantAccountIDs: [3],
visibleChatMemberAccountIDs: [3],
reportName: 'Spider-Man',
@@ -31,41 +36,41 @@ describe('OptionsListUtils', () => {
},
// This is the only report we are pinning in this test
- 3: {
+ '3': {
lastReadTime: '2021-01-14 11:25:39.297',
lastVisibleActionCreated: '2022-11-22 03:26:02.170',
isPinned: true,
- reportID: 3,
+ reportID: '3',
participantAccountIDs: [1],
visibleChatMemberAccountIDs: [1],
reportName: 'Mister Fantastic',
type: CONST.REPORT.TYPE.CHAT,
},
- 4: {
+ '4': {
lastReadTime: '2021-01-14 11:25:39.298',
lastVisibleActionCreated: '2022-11-22 03:26:02.180',
isPinned: false,
- reportID: 4,
+ reportID: '4',
participantAccountIDs: [4],
visibleChatMemberAccountIDs: [4],
reportName: 'Black Panther',
type: CONST.REPORT.TYPE.CHAT,
},
- 5: {
+ '5': {
lastReadTime: '2021-01-14 11:25:39.299',
lastVisibleActionCreated: '2022-11-22 03:26:02.019',
isPinned: false,
- reportID: 5,
+ reportID: '5',
participantAccountIDs: [5],
visibleChatMemberAccountIDs: [5],
reportName: 'Invisible Woman',
type: CONST.REPORT.TYPE.CHAT,
},
- 6: {
+ '6': {
lastReadTime: '2021-01-14 11:25:39.300',
lastVisibleActionCreated: '2022-11-22 03:26:02.020',
isPinned: false,
- reportID: 6,
+ reportID: '6',
participantAccountIDs: [6],
visibleChatMemberAccountIDs: [6],
reportName: 'Thor',
@@ -73,11 +78,11 @@ describe('OptionsListUtils', () => {
},
// Note: This report has the largest lastVisibleActionCreated
- 7: {
+ '7': {
lastReadTime: '2021-01-14 11:25:39.301',
lastVisibleActionCreated: '2022-11-22 03:26:03.999',
isPinned: false,
- reportID: 7,
+ reportID: '7',
participantAccountIDs: [7],
visibleChatMemberAccountIDs: [7],
reportName: 'Captain America',
@@ -85,11 +90,11 @@ describe('OptionsListUtils', () => {
},
// Note: This report has no lastVisibleActionCreated
- 8: {
+ '8': {
lastReadTime: '2021-01-14 11:25:39.301',
lastVisibleActionCreated: '2022-11-22 03:26:02.000',
isPinned: false,
- reportID: 8,
+ reportID: '8',
participantAccountIDs: [12],
visibleChatMemberAccountIDs: [12],
reportName: 'Silver Surfer',
@@ -97,23 +102,23 @@ describe('OptionsListUtils', () => {
},
// Note: This report has an IOU
- 9: {
+ '9': {
lastReadTime: '2021-01-14 11:25:39.302',
lastVisibleActionCreated: '2022-11-22 03:26:02.998',
isPinned: false,
- reportID: 9,
+ reportID: '9',
participantAccountIDs: [8],
visibleChatMemberAccountIDs: [8],
reportName: 'Mister Sinister',
- iouReportID: 100,
+ iouReportID: '100',
type: CONST.REPORT.TYPE.CHAT,
},
// This report is an archived room – it does not have a name and instead falls back on oldPolicyName
- 10: {
+ '10': {
lastReadTime: '2021-01-14 11:25:39.200',
lastVisibleActionCreated: '2022-11-22 03:26:02.001',
- reportID: 10,
+ reportID: '10',
isPinned: false,
participantAccountIDs: [2, 7],
visibleChatMemberAccountIDs: [2, 7],
@@ -130,71 +135,81 @@ describe('OptionsListUtils', () => {
};
// And a set of personalDetails some with existing reports and some without
- const PERSONAL_DETAILS = {
+ const PERSONAL_DETAILS: PersonalDetailsList = {
// These exist in our reports
- 1: {
+ '1': {
accountID: 1,
displayName: 'Mister Fantastic',
login: 'reedrichards@expensify.com',
isSelected: true,
+ reportID: '1',
},
- 2: {
+ '2': {
accountID: 2,
displayName: 'Iron Man',
login: 'tonystark@expensify.com',
+ reportID: '1',
},
- 3: {
+ '3': {
accountID: 3,
displayName: 'Spider-Man',
login: 'peterparker@expensify.com',
+ reportID: '1',
},
- 4: {
+ '4': {
accountID: 4,
displayName: 'Black Panther',
login: 'tchalla@expensify.com',
+ reportID: '1',
},
- 5: {
+ '5': {
accountID: 5,
displayName: 'Invisible Woman',
login: 'suestorm@expensify.com',
+ reportID: '1',
},
- 6: {
+ '6': {
accountID: 6,
displayName: 'Thor',
login: 'thor@expensify.com',
+ reportID: '1',
},
- 7: {
+ '7': {
accountID: 7,
displayName: 'Captain America',
login: 'steverogers@expensify.com',
+ reportID: '1',
},
- 8: {
+ '8': {
accountID: 8,
displayName: 'Mr Sinister',
login: 'mistersinister@marauders.com',
+ reportID: '1',
},
// These do not exist in reports at all
- 9: {
+ '9': {
accountID: 9,
displayName: 'Black Widow',
login: 'natasharomanoff@expensify.com',
+ reportID: '',
},
- 10: {
+ '10': {
accountID: 10,
displayName: 'The Incredible Hulk',
login: 'brucebanner@expensify.com',
+ reportID: '',
},
};
- const REPORTS_WITH_CONCIERGE = {
+ const REPORTS_WITH_CONCIERGE: OnyxCollection = {
...REPORTS,
- 11: {
+ '11': {
lastReadTime: '2021-01-14 11:25:39.302',
lastVisibleActionCreated: '2022-11-22 03:26:02.022',
isPinned: false,
- reportID: 11,
+ reportID: '11',
participantAccountIDs: [999],
visibleChatMemberAccountIDs: [999],
reportName: 'Concierge',
@@ -202,13 +217,13 @@ describe('OptionsListUtils', () => {
},
};
- const REPORTS_WITH_CHRONOS = {
+ const REPORTS_WITH_CHRONOS: OnyxCollection = {
...REPORTS,
- 12: {
+ '12': {
lastReadTime: '2021-01-14 11:25:39.302',
lastVisibleActionCreated: '2022-11-22 03:26:02.022',
isPinned: false,
- reportID: 12,
+ reportID: '12',
participantAccountIDs: [1000],
visibleChatMemberAccountIDs: [1000],
reportName: 'Chronos',
@@ -216,13 +231,13 @@ describe('OptionsListUtils', () => {
},
};
- const REPORTS_WITH_RECEIPTS = {
+ const REPORTS_WITH_RECEIPTS: OnyxCollection = {
...REPORTS,
- 13: {
+ '13': {
lastReadTime: '2021-01-14 11:25:39.302',
lastVisibleActionCreated: '2022-11-22 03:26:02.022',
isPinned: false,
- reportID: 13,
+ reportID: '13',
participantAccountIDs: [1001],
visibleChatMemberAccountIDs: [1001],
reportName: 'Receipts',
@@ -230,67 +245,77 @@ describe('OptionsListUtils', () => {
},
};
- const REPORTS_WITH_WORKSPACE_ROOMS = {
+ const REPORTS_WITH_WORKSPACE_ROOMS: OnyxCollection = {
...REPORTS,
- 14: {
+ '14': {
lastReadTime: '2021-01-14 11:25:39.302',
lastVisibleActionCreated: '2022-11-22 03:26:02.022',
isPinned: false,
- reportID: 14,
+ reportID: '14',
participantAccountIDs: [1, 10, 3],
visibleChatMemberAccountIDs: [1, 10, 3],
reportName: '',
oldPolicyName: 'Avengers Room',
- isArchivedRoom: false,
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS,
isOwnPolicyExpenseChat: true,
type: CONST.REPORT.TYPE.CHAT,
},
};
- const PERSONAL_DETAILS_WITH_CONCIERGE = {
+ const PERSONAL_DETAILS_WITH_CONCIERGE: PersonalDetailsList = {
...PERSONAL_DETAILS,
- 999: {
+ '999': {
accountID: 999,
displayName: 'Concierge',
login: 'concierge@expensify.com',
+ reportID: '',
},
};
- const PERSONAL_DETAILS_WITH_CHRONOS = {
+ const PERSONAL_DETAILS_WITH_CHRONOS: PersonalDetailsList = {
...PERSONAL_DETAILS,
- 1000: {
+ '1000': {
accountID: 1000,
displayName: 'Chronos',
login: 'chronos@expensify.com',
+ reportID: '',
},
};
- const PERSONAL_DETAILS_WITH_RECEIPTS = {
+ const PERSONAL_DETAILS_WITH_RECEIPTS: PersonalDetailsList = {
...PERSONAL_DETAILS,
- 1001: {
+ '1001': {
accountID: 1001,
displayName: 'Receipts',
login: 'receipts@expensify.com',
+ reportID: '',
},
};
- const PERSONAL_DETAILS_WITH_PERIODS = {
+ const PERSONAL_DETAILS_WITH_PERIODS: PersonalDetailsList = {
...PERSONAL_DETAILS,
- 1002: {
+ '1002': {
accountID: 1002,
displayName: 'The Flash',
login: 'barry.allen@expensify.com',
+ reportID: '',
},
};
- const POLICY = {
- policyID: 'ABC123',
+ const policyID = 'ABC123';
+
+ const POLICY: Policy = {
+ id: policyID,
name: 'Hero Policy',
+ role: 'user',
+ type: 'free',
+ owner: '',
+ outputCurrency: '',
+ isPolicyExpenseChatEnabled: false,
};
// Set the currently logged in user, report data, and personal details
@@ -299,22 +324,23 @@ describe('OptionsListUtils', () => {
keys: ONYXKEYS,
initialKeyStates: {
[ONYXKEYS.SESSION]: {accountID: 2, email: 'tonystark@expensify.com'},
- [`${ONYXKEYS.COLLECTION.REPORT}100`]: {
+ [`${ONYXKEYS.COLLECTION.REPORT}100` as const]: {
+ reportID: '',
ownerAccountID: 8,
- total: '1000',
+ total: 1000,
},
- [`${ONYXKEYS.COLLECTION.POLICY}${POLICY.policyID}`]: POLICY,
+ [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: POLICY,
},
});
Onyx.registerLogger(() => {});
return waitForBatchedUpdates().then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS));
});
- let OPTIONS = {};
- let OPTIONS_WITH_CONCIERGE = {};
- let OPTIONS_WITH_CHRONOS = {};
- let OPTIONS_WITH_RECEIPTS = {};
- let OPTIONS_WITH_WORKSPACES = {};
+ let OPTIONS: OptionsListUtils.OptionList;
+ let OPTIONS_WITH_CONCIERGE: OptionsListUtils.OptionList;
+ let OPTIONS_WITH_CHRONOS: OptionsListUtils.OptionList;
+ let OPTIONS_WITH_RECEIPTS: OptionsListUtils.OptionList;
+ let OPTIONS_WITH_WORKSPACES: OptionsListUtils.OptionList;
beforeEach(() => {
OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS);
@@ -331,7 +357,7 @@ describe('OptionsListUtils', () => {
expect(results.personalDetails.length).toBe(2);
// Then all of the reports should be shown including the archived rooms.
- expect(results.recentReports.length).toBe(_.size(OPTIONS.reports));
+ expect(results.recentReports.length).toBe(Object.values(OPTIONS.reports).length);
// When we filter again but provide a searchValue
results = OptionsListUtils.getSearchOptions(OPTIONS, 'spider');
@@ -372,7 +398,7 @@ describe('OptionsListUtils', () => {
// We should expect all personalDetails to be returned,
// minus the currently logged in user and recent reports count
- expect(results.personalDetails.length).toBe(_.size(OPTIONS.personalDetails) - 1 - MAX_RECENT_REPORTS);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS.personalDetails).length - 1 - MAX_RECENT_REPORTS);
// We should expect personal details sorted alphabetically
expect(results.personalDetails[0].text).toBe('Black Widow');
@@ -381,8 +407,8 @@ describe('OptionsListUtils', () => {
expect(results.personalDetails[3].text).toBe('The Incredible Hulk');
// Then the result which has an existing report should also have the reportID attached
- const personalDetailWithExistingReport = _.find(results.personalDetails, (personalDetail) => personalDetail.login === 'peterparker@expensify.com');
- expect(personalDetailWithExistingReport.reportID).toBe(2);
+ const personalDetailWithExistingReport = results.personalDetails.find((personalDetail) => personalDetail.login === 'peterparker@expensify.com');
+ expect(personalDetailWithExistingReport?.reportID).toBe('2');
// When we only pass personal details
results = OptionsListUtils.getFilteredOptions([], OPTIONS.personalDetails, [], '');
@@ -427,28 +453,28 @@ describe('OptionsListUtils', () => {
// Concierge is included in the results by default. We should expect all the personalDetails to show
// (minus the 5 that are already showing and the currently logged in user)
- expect(results.personalDetails.length).toBe(_.size(OPTIONS_WITH_CONCIERGE.personalDetails) - 1 - MAX_RECENT_REPORTS);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS_WITH_CONCIERGE.personalDetails).length - 1 - MAX_RECENT_REPORTS);
expect(results.recentReports).toEqual(expect.arrayContaining([expect.objectContaining({login: 'concierge@expensify.com'})]));
// Test by excluding Concierge from the results
results = OptionsListUtils.getFilteredOptions(OPTIONS_WITH_CONCIERGE.reports, OPTIONS_WITH_CONCIERGE.personalDetails, [], '', [], [CONST.EMAIL.CONCIERGE]);
// All the personalDetails should be returned minus the currently logged in user and Concierge
- expect(results.personalDetails.length).toBe(_.size(OPTIONS_WITH_CONCIERGE.personalDetails) - 2 - MAX_RECENT_REPORTS);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS_WITH_CONCIERGE.personalDetails).length - 2 - MAX_RECENT_REPORTS);
expect(results.personalDetails).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'concierge@expensify.com'})]));
// Test by excluding Chronos from the results
results = OptionsListUtils.getFilteredOptions(OPTIONS_WITH_CHRONOS.reports, OPTIONS_WITH_CHRONOS.personalDetails, [], '', [], [CONST.EMAIL.CHRONOS]);
// All the personalDetails should be returned minus the currently logged in user and Concierge
- expect(results.personalDetails.length).toBe(_.size(OPTIONS_WITH_CHRONOS.personalDetails) - 2 - MAX_RECENT_REPORTS);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS_WITH_CHRONOS.personalDetails).length - 2 - MAX_RECENT_REPORTS);
expect(results.personalDetails).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'chronos@expensify.com'})]));
// Test by excluding Receipts from the results
results = OptionsListUtils.getFilteredOptions(OPTIONS_WITH_RECEIPTS.reports, OPTIONS_WITH_RECEIPTS.personalDetails, [], '', [], [CONST.EMAIL.RECEIPTS]);
// All the personalDetails should be returned minus the currently logged in user and Concierge
- expect(results.personalDetails.length).toBe(_.size(OPTIONS_WITH_RECEIPTS.personalDetails) - 2 - MAX_RECENT_REPORTS);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS_WITH_RECEIPTS.personalDetails).length - 2 - MAX_RECENT_REPORTS);
expect(results.personalDetails).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'receipts@expensify.com'})]));
});
@@ -461,7 +487,7 @@ describe('OptionsListUtils', () => {
// And we should expect all the personalDetails to show (minus the 5 that are already
// showing and the currently logged in user)
- expect(results.personalDetails.length).toBe(_.size(OPTIONS.personalDetails) - 6);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS.personalDetails).length - 6);
// We should expect personal details sorted alphabetically
expect(results.personalDetails[0].text).toBe('Black Widow');
@@ -470,8 +496,8 @@ describe('OptionsListUtils', () => {
expect(results.personalDetails[3].text).toBe('The Incredible Hulk');
// And none of our personalDetails should include any of the users with recent reports
- const reportLogins = _.map(results.recentReports, (reportOption) => reportOption.login);
- const personalDetailsOverlapWithReports = _.every(results.personalDetails, (personalDetailOption) => _.contains(reportLogins, personalDetailOption.login));
+ const reportLogins = results.recentReports.map((reportOption) => reportOption.login);
+ const personalDetailsOverlapWithReports = results.personalDetails.every((personalDetailOption) => reportLogins.includes(personalDetailOption.login));
expect(personalDetailsOverlapWithReports).toBe(false);
// When we search for an option that is only in a personalDetail with no existing report
@@ -500,15 +526,15 @@ describe('OptionsListUtils', () => {
// Then one of our older report options (not in our five most recent) should appear in the personalDetails
// but not in recentReports
- expect(_.every(results.recentReports, (option) => option.login !== 'peterparker@expensify.com')).toBe(true);
- expect(_.every(results.personalDetails, (option) => option.login !== 'peterparker@expensify.com')).toBe(false);
+ expect(results.recentReports.every((option) => option.login !== 'peterparker@expensify.com')).toBe(true);
+ expect(results.personalDetails.every((option) => option.login !== 'peterparker@expensify.com')).toBe(false);
// When we provide a "selected" option to getFilteredOptions()
results = OptionsListUtils.getFilteredOptions(OPTIONS.reports, OPTIONS.personalDetails, [], '', [{login: 'peterparker@expensify.com'}]);
// Then the option should not appear anywhere in either list
- expect(_.every(results.recentReports, (option) => option.login !== 'peterparker@expensify.com')).toBe(true);
- expect(_.every(results.personalDetails, (option) => option.login !== 'peterparker@expensify.com')).toBe(true);
+ expect(results.recentReports.every((option) => option.login !== 'peterparker@expensify.com')).toBe(true);
+ expect(results.personalDetails.every((option) => option.login !== 'peterparker@expensify.com')).toBe(true);
// When we add a search term for which no options exist and the searchValue itself
// is not a potential email or phone
@@ -544,7 +570,7 @@ describe('OptionsListUtils', () => {
expect(results.recentReports.length).toBe(0);
expect(results.personalDetails.length).toBe(0);
expect(results.userToInvite).not.toBe(null);
- expect(results.userToInvite.login).toBe('+15005550006');
+ expect(results.userToInvite?.login).toBe('+15005550006');
// When we add a search term for which no options exist and the searchValue itself
// is a potential phone number with country code added
@@ -555,7 +581,7 @@ describe('OptionsListUtils', () => {
expect(results.recentReports.length).toBe(0);
expect(results.personalDetails.length).toBe(0);
expect(results.userToInvite).not.toBe(null);
- expect(results.userToInvite.login).toBe('+15005550006');
+ expect(results.userToInvite?.login).toBe('+15005550006');
// When we add a search term for which no options exist and the searchValue itself
// is a potential phone number with special characters added
@@ -566,7 +592,7 @@ describe('OptionsListUtils', () => {
expect(results.recentReports.length).toBe(0);
expect(results.personalDetails.length).toBe(0);
expect(results.userToInvite).not.toBe(null);
- expect(results.userToInvite.login).toBe('+18003243233');
+ expect(results.userToInvite?.login).toBe('+18003243233');
// When we use a search term for contact number that contains alphabet characters
results = OptionsListUtils.getFilteredOptions(OPTIONS.reports, OPTIONS.personalDetails, [], '998243aaaa');
@@ -581,7 +607,7 @@ describe('OptionsListUtils', () => {
// Concierge is included in the results by default. We should expect all the personalDetails to show
// (minus the 5 that are already showing and the currently logged in user)
- expect(results.personalDetails.length).toBe(_.size(OPTIONS_WITH_CONCIERGE.personalDetails) - 6);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS_WITH_CONCIERGE.personalDetails).length - 6);
expect(results.recentReports).toEqual(expect.arrayContaining([expect.objectContaining({login: 'concierge@expensify.com'})]));
// Test by excluding Concierge from the results
@@ -589,7 +615,7 @@ describe('OptionsListUtils', () => {
// We should expect all the personalDetails to show (minus the 5 that are already showing,
// the currently logged in user and Concierge)
- expect(results.personalDetails.length).toBe(_.size(OPTIONS_WITH_CONCIERGE.personalDetails) - 7);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS_WITH_CONCIERGE.personalDetails).length - 7);
expect(results.personalDetails).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'concierge@expensify.com'})]));
expect(results.recentReports).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'concierge@expensify.com'})]));
@@ -598,7 +624,7 @@ describe('OptionsListUtils', () => {
// We should expect all the personalDetails to show (minus the 5 that are already showing,
// the currently logged in user and Concierge)
- expect(results.personalDetails.length).toBe(_.size(OPTIONS_WITH_CHRONOS.personalDetails) - 7);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS_WITH_CHRONOS.personalDetails).length - 7);
expect(results.personalDetails).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'chronos@expensify.com'})]));
expect(results.recentReports).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'chronos@expensify.com'})]));
@@ -607,30 +633,26 @@ describe('OptionsListUtils', () => {
// We should expect all the personalDetails to show (minus the 5 that are already showing,
// the currently logged in user and Concierge)
- expect(results.personalDetails.length).toBe(_.size(OPTIONS_WITH_RECEIPTS.personalDetails) - 7);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS_WITH_RECEIPTS.personalDetails).length - 7);
expect(results.personalDetails).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'receipts@expensify.com'})]));
expect(results.recentReports).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'receipts@expensify.com'})]));
});
it('getShareDestinationsOptions()', () => {
// Filter current REPORTS as we do in the component, before getting share destination options
- const filteredReports = _.reduce(
- OPTIONS.reports,
- (filtered, option) => {
- const report = option.item;
- if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) {
- filtered.push(option);
- }
- return filtered;
- },
- [],
- );
+ const filteredReports = Object.values(OPTIONS.reports).reduce((filtered, option) => {
+ const report = option.item;
+ if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) {
+ filtered.push(option);
+ }
+ return filtered;
+ }, []);
// When we pass an empty search value
let results = OptionsListUtils.getShareDestinationOptions(filteredReports, OPTIONS.personalDetails, [], '');
// Then we should expect all the recent reports to show but exclude the archived rooms
- expect(results.recentReports.length).toBe(_.size(OPTIONS.reports) - 1);
+ expect(results.recentReports.length).toBe(Object.values(OPTIONS.reports).length - 1);
// When we pass a search value that doesn't match the group chat name
results = OptionsListUtils.getShareDestinationOptions(filteredReports, OPTIONS.personalDetails, [], 'mutants');
@@ -645,23 +667,20 @@ describe('OptionsListUtils', () => {
expect(results.recentReports.length).toBe(1);
// Filter current REPORTS_WITH_WORKSPACE_ROOMS as we do in the component, before getting share destination options
- const filteredReportsWithWorkspaceRooms = _.reduce(
- OPTIONS_WITH_WORKSPACES.reports,
- (filtered, option) => {
- const report = option.item;
- if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) {
- filtered.push(option);
- }
- return filtered;
- },
- [],
- );
+ const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACES.reports).reduce((filtered, option) => {
+ const report = option.item;
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) {
+ filtered.push(option);
+ }
+ return filtered;
+ }, []);
// When we also have a policy to return rooms in the results
results = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], '');
// Then we should expect the DMS, the group chats and the workspace room to show
// We should expect all the recent reports to show, excluding the archived rooms
- expect(results.recentReports.length).toBe(_.size(OPTIONS_WITH_WORKSPACES.reports) - 1);
+ expect(results.recentReports.length).toBe(Object.values(OPTIONS_WITH_WORKSPACES.reports).length - 1);
// When we search for a workspace room
results = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], 'Avengers Room');
@@ -705,31 +724,51 @@ describe('OptionsListUtils', () => {
const emptySearch = '';
const wrongSearch = 'bla bla';
const recentlyUsedCategories = ['Taxi', 'Restaurant'];
- const selectedOptions = [
+ const selectedOptions: Array> = [
{
name: 'Medical',
enabled: true,
},
];
- const smallCategoriesList = {
+ const smallCategoriesList: PolicyCategories = {
Taxi: {
enabled: false,
name: 'Taxi',
+ unencodedName: 'Taxi',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
Restaurant: {
enabled: true,
name: 'Restaurant',
+ unencodedName: 'Restaurant',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
Food: {
enabled: true,
name: 'Food',
+ unencodedName: 'Food',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Food: Meat': {
enabled: true,
name: 'Food: Meat',
+ unencodedName: 'Food: Meat',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
};
- const smallResultList = [
+ const smallResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: false,
@@ -761,7 +800,7 @@ describe('OptionsListUtils', () => {
],
},
];
- const smallSearchResultList = [
+ const smallSearchResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: true,
@@ -785,72 +824,142 @@ describe('OptionsListUtils', () => {
],
},
];
- const smallWrongSearchResultList = [
+ const smallWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: true,
data: [],
},
];
- const largeCategoriesList = {
+ const largeCategoriesList: PolicyCategories = {
Taxi: {
enabled: false,
name: 'Taxi',
+ unencodedName: 'Taxi',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
Restaurant: {
enabled: true,
name: 'Restaurant',
+ unencodedName: 'Restaurant',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
Food: {
enabled: true,
name: 'Food',
+ unencodedName: 'Food',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Food: Meat': {
enabled: true,
name: 'Food: Meat',
+ unencodedName: 'Food: Meat',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Food: Milk': {
enabled: true,
name: 'Food: Milk',
+ unencodedName: 'Food: Milk',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Food: Vegetables': {
enabled: false,
name: 'Food: Vegetables',
+ unencodedName: 'Food: Vegetables',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Cars: Audi': {
enabled: true,
name: 'Cars: Audi',
+ unencodedName: 'Cars: Audi',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Cars: BMW': {
enabled: false,
name: 'Cars: BMW',
+ unencodedName: 'Cars: BMW',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Cars: Mercedes-Benz': {
enabled: true,
name: 'Cars: Mercedes-Benz',
+ unencodedName: 'Cars: Mercedes-Benz',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
Medical: {
enabled: false,
name: 'Medical',
+ unencodedName: 'Medical',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Travel: Meals': {
enabled: true,
name: 'Travel: Meals',
+ unencodedName: 'Travel: Meals',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Travel: Meals: Breakfast': {
enabled: true,
name: 'Travel: Meals: Breakfast',
+ unencodedName: 'Travel: Meals: Breakfast',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Travel: Meals: Dinner': {
enabled: false,
name: 'Travel: Meals: Dinner',
+ unencodedName: 'Travel: Meals: Dinner',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Travel: Meals: Lunch': {
enabled: true,
name: 'Travel: Meals: Lunch',
+ unencodedName: 'Travel: Meals: Lunch',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
};
- const largeResultList = [
+ const largeResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: false,
@@ -974,7 +1083,7 @@ describe('OptionsListUtils', () => {
],
},
];
- const largeSearchResultList = [
+ const largeSearchResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: true,
@@ -1006,7 +1115,7 @@ describe('OptionsListUtils', () => {
],
},
];
- const largeWrongSearchResultList = [
+ const largeWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: true,
@@ -1014,7 +1123,7 @@ describe('OptionsListUtils', () => {
},
];
const emptyCategoriesList = {};
- const emptySelectedResultList = [
+ const emptySelectedResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: false,
@@ -1100,25 +1209,29 @@ describe('OptionsListUtils', () => {
name: 'Medical',
},
];
- const smallTagsList = {
+ const smallTagsList: Record = {
Engineering: {
enabled: false,
name: 'Engineering',
+ accountID: null,
},
Medical: {
enabled: true,
name: 'Medical',
+ accountID: null,
},
Accounting: {
enabled: true,
name: 'Accounting',
+ accountID: null,
},
HR: {
enabled: true,
name: 'HR',
+ accountID: null,
},
};
- const smallResultList = [
+ const smallResultList: OptionsListUtils.CategorySection[] = [
{
title: '',
shouldShow: false,
@@ -1148,7 +1261,7 @@ describe('OptionsListUtils', () => {
],
},
];
- const smallSearchResultList = [
+ const smallSearchResultList: OptionsListUtils.CategorySection[] = [
{
title: '',
shouldShow: true,
@@ -1163,60 +1276,71 @@ describe('OptionsListUtils', () => {
],
},
];
- const smallWrongSearchResultList = [
+ const smallWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: true,
data: [],
},
];
- const largeTagsList = {
+ const largeTagsList: Record = {
Engineering: {
enabled: false,
name: 'Engineering',
+ accountID: null,
},
Medical: {
enabled: true,
name: 'Medical',
+ accountID: null,
},
Accounting: {
enabled: true,
name: 'Accounting',
+ accountID: null,
},
HR: {
enabled: true,
name: 'HR',
+ accountID: null,
},
Food: {
enabled: true,
name: 'Food',
+ accountID: null,
},
Traveling: {
enabled: false,
name: 'Traveling',
+ accountID: null,
},
Cleaning: {
enabled: true,
name: 'Cleaning',
+ accountID: null,
},
Software: {
enabled: true,
name: 'Software',
+ accountID: null,
},
OfficeSupplies: {
enabled: false,
name: 'Office Supplies',
+ accountID: null,
},
Taxes: {
enabled: true,
name: 'Taxes',
+ accountID: null,
},
Benefits: {
enabled: true,
name: 'Benefits',
+ accountID: null,
},
};
- const largeResultList = [
+ const largeResultList: OptionsListUtils.CategorySection[] = [
{
title: '',
shouldShow: true,
@@ -1300,7 +1424,7 @@ describe('OptionsListUtils', () => {
],
},
];
- const largeSearchResultList = [
+ const largeSearchResultList: OptionsListUtils.CategorySection[] = [
{
title: '',
shouldShow: true,
@@ -1322,7 +1446,7 @@ describe('OptionsListUtils', () => {
],
},
];
- const largeWrongSearchResultList = [
+ const largeWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: true,
@@ -2076,7 +2200,7 @@ describe('OptionsListUtils', () => {
});
it('sortTags', () => {
- const createTagObjects = (names) => _.map(names, (name) => ({name, enabled: true}));
+ 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', '中国', '日本'];
@@ -2304,7 +2428,7 @@ describe('OptionsListUtils', () => {
const emptySearch = '';
const wrongSearch = 'bla bla';
- const taxRatesWithDefault = {
+ const taxRatesWithDefault: TaxRatesWithDefault = {
name: 'Tax',
defaultExternalID: 'CODE1',
defaultValue: '0%',
@@ -2313,19 +2437,25 @@ describe('OptionsListUtils', () => {
CODE2: {
name: 'Tax rate 2',
value: '3%',
+ code: 'CODE2',
+ modifiedName: 'Tax rate 2 (3%)',
},
CODE3: {
name: 'Tax option 3',
value: '5%',
+ code: 'CODE3',
+ modifiedName: 'Tax option 3 (5%)',
},
CODE1: {
name: 'Tax exempt 1',
value: '0%',
+ code: 'CODE1',
+ modifiedName: 'Tax exempt 1 (0%) • Default',
},
},
};
- const resultList = [
+ const resultList: OptionsListUtils.CategorySection[] = [
{
title: '',
shouldShow: false,
@@ -2377,7 +2507,7 @@ describe('OptionsListUtils', () => {
},
];
- const searchResultList = [
+ const searchResultList: OptionsListUtils.CategorySection[] = [
{
title: '',
shouldShow: true,
@@ -2400,7 +2530,7 @@ describe('OptionsListUtils', () => {
},
];
- const wrongSearchResultList = [
+ const wrongSearchResultList: OptionsListUtils.CategorySection[] = [
{
title: '',
shouldShow: true,
@@ -2408,19 +2538,19 @@ describe('OptionsListUtils', () => {
},
];
- const result = OptionsListUtils.getFilteredOptions({}, {}, [], emptySearch, [], [], false, false, false, {}, [], false, {}, [], false, false, true, taxRatesWithDefault);
+ const result = OptionsListUtils.getFilteredOptions([], [], [], emptySearch, [], [], false, false, false, {}, [], false, {}, [], false, false, true, taxRatesWithDefault);
expect(result.taxRatesOptions).toStrictEqual(resultList);
- const searchResult = OptionsListUtils.getFilteredOptions({}, {}, [], search, [], [], false, false, false, {}, [], false, {}, [], false, false, true, taxRatesWithDefault);
+ const searchResult = OptionsListUtils.getFilteredOptions([], [], [], search, [], [], false, false, false, {}, [], false, {}, [], false, false, true, taxRatesWithDefault);
expect(searchResult.taxRatesOptions).toStrictEqual(searchResultList);
- const wrongSearchResult = OptionsListUtils.getFilteredOptions({}, {}, [], wrongSearch, [], [], false, false, false, {}, [], false, {}, [], false, false, true, taxRatesWithDefault);
+ const wrongSearchResult = OptionsListUtils.getFilteredOptions([], [], [], wrongSearch, [], [], false, false, false, {}, [], false, {}, [], false, false, true, taxRatesWithDefault);
expect(wrongSearchResult.taxRatesOptions).toStrictEqual(wrongSearchResultList);
});
it('formatMemberForList()', () => {
- const formattedMembers = _.map(PERSONAL_DETAILS, (personalDetail) => OptionsListUtils.formatMemberForList(personalDetail));
+ const formattedMembers = Object.values(PERSONAL_DETAILS).map((personalDetail) => OptionsListUtils.formatMemberForList(personalDetail));
// We're only formatting items inside the array, so the order should be the same as the original PERSONAL_DETAILS array
expect(formattedMembers[0].text).toBe('Mister Fantastic');
@@ -2431,9 +2561,9 @@ describe('OptionsListUtils', () => {
expect(formattedMembers[0].isSelected).toBe(true);
// And all the others to be unselected
- expect(_.every(formattedMembers.slice(1), (personalDetail) => !personalDetail.isSelected)).toBe(true);
+ expect(formattedMembers.slice(1).every((personalDetail) => !personalDetail.isSelected)).toBe(true);
// `isDisabled` is always false
- expect(_.every(formattedMembers, (personalDetail) => !personalDetail.isDisabled)).toBe(true);
+ expect(formattedMembers.every((personalDetail) => !personalDetail.isDisabled)).toBe(true);
});
});
diff --git a/tests/unit/ReportUtilsTest.js b/tests/unit/ReportUtilsTest.ts
similarity index 83%
rename from tests/unit/ReportUtilsTest.js
rename to tests/unit/ReportUtilsTest.ts
index ffd5c9147dc0..f2571cd60e0b 100644
--- a/tests/unit/ReportUtilsTest.js
+++ b/tests/unit/ReportUtilsTest.ts
@@ -1,42 +1,45 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import type {OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
-import _ from 'underscore';
-import CONST from '../../src/CONST';
+import * as ReportUtils from '@libs/ReportUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {PersonalDetailsList, Policy, Report, ReportAction} from '@src/types/onyx';
+import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet';
import * as NumberUtils from '../../src/libs/NumberUtils';
-import * as ReportUtils from '../../src/libs/ReportUtils';
-import ONYXKEYS from '../../src/ONYXKEYS';
import * as LHNTestUtils from '../utils/LHNTestUtils';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
// Be sure to include the mocked permissions library or else the beta tests won't work
-jest.mock('../../src/libs/Permissions');
+jest.mock('@libs/Permissions');
const currentUserEmail = 'bjorn@vikings.net';
const currentUserAccountID = 5;
-const participantsPersonalDetails = {
- 1: {
+const participantsPersonalDetails: PersonalDetailsList = {
+ '1': {
accountID: 1,
displayName: 'Ragnar Lothbrok',
firstName: 'Ragnar',
login: 'ragnar@vikings.net',
},
- 2: {
+ '2': {
accountID: 2,
login: 'floki@vikings.net',
displayName: 'floki@vikings.net',
},
- 3: {
+ '3': {
accountID: 3,
displayName: 'Lagertha Lothbrok',
firstName: 'Lagertha',
login: 'lagertha@vikings.net',
pronouns: 'She/her',
},
- 4: {
+ '4': {
accountID: 4,
login: '+18332403627@expensify.sms',
displayName: '(833) 240-3627',
},
- 5: {
+ '5': {
accountID: 5,
displayName: 'Lagertha Lothbrok',
firstName: 'Lagertha',
@@ -44,20 +47,27 @@ const participantsPersonalDetails = {
pronouns: 'She/her',
},
};
-const policy = {
- policyID: 1,
+
+const policy: Policy = {
+ id: '1',
name: 'Vikings Policy',
+ role: 'user',
+ type: 'free',
+ owner: '',
+ outputCurrency: '',
+ isPolicyExpenseChatEnabled: false,
};
Onyx.init({keys: ONYXKEYS});
describe('ReportUtils', () => {
beforeAll(() => {
+ const policyCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.POLICY, [policy], (current) => current.id);
Onyx.multiSet({
[ONYXKEYS.PERSONAL_DETAILS_LIST]: participantsPersonalDetails,
[ONYXKEYS.SESSION]: {email: currentUserEmail, accountID: currentUserAccountID},
[ONYXKEYS.COUNTRY_CODE]: 1,
- [`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy,
+ ...policyCollectionDataSet,
});
return waitForBatchedUpdates();
});
@@ -107,6 +117,7 @@ describe('ReportUtils', () => {
test('with displayName', () => {
expect(
ReportUtils.getReportName({
+ reportID: '',
participantAccountIDs: [currentUserAccountID, 1],
}),
).toBe('Ragnar Lothbrok');
@@ -115,6 +126,7 @@ describe('ReportUtils', () => {
test('no displayName', () => {
expect(
ReportUtils.getReportName({
+ reportID: '',
participantAccountIDs: [currentUserAccountID, 2],
}),
).toBe('floki@vikings.net');
@@ -123,6 +135,7 @@ describe('ReportUtils', () => {
test('SMS', () => {
expect(
ReportUtils.getReportName({
+ reportID: '',
participantAccountIDs: [currentUserAccountID, 4],
}),
).toBe('(833) 240-3627');
@@ -132,6 +145,7 @@ describe('ReportUtils', () => {
test('Group DM', () => {
expect(
ReportUtils.getReportName({
+ reportID: '',
participantAccountIDs: [currentUserAccountID, 1, 2, 3, 4],
}),
).toBe('Ragnar, floki@vikings.net, Lagertha, (833) 240-3627');
@@ -139,6 +153,7 @@ describe('ReportUtils', () => {
describe('Default Policy Room', () => {
const baseAdminsRoom = {
+ reportID: '',
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS,
reportName: '#admins',
};
@@ -162,6 +177,7 @@ describe('ReportUtils', () => {
describe('User-Created Policy Room', () => {
const baseUserCreatedRoom = {
+ reportID: '',
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
reportName: '#VikingsChat',
};
@@ -188,8 +204,9 @@ describe('ReportUtils', () => {
test('as member', () => {
expect(
ReportUtils.getReportName({
+ reportID: '',
chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
- policyID: policy.policyID,
+ policyID: policy.id,
isOwnPolicyExpenseChat: true,
ownerAccountID: 1,
}),
@@ -199,8 +216,9 @@ describe('ReportUtils', () => {
test('as admin', () => {
expect(
ReportUtils.getReportName({
+ reportID: '',
chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
- policyID: policy.policyID,
+ policyID: policy.id,
isOwnPolicyExpenseChat: false,
ownerAccountID: 1,
}),
@@ -210,9 +228,10 @@ describe('ReportUtils', () => {
describe('Archived', () => {
const baseArchivedPolicyExpenseChat = {
+ reportID: '',
chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
ownerAccountID: 1,
- policyID: policy.policyID,
+ policyID: policy.id,
oldPolicyName: policy.name,
statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
stateNum: CONST.REPORT.STATE_NUM.APPROVED,
@@ -249,7 +268,7 @@ describe('ReportUtils', () => {
describe('requiresAttentionFromCurrentUser', () => {
it('returns false when there is no report', () => {
- expect(ReportUtils.requiresAttentionFromCurrentUser()).toBe(false);
+ expect(ReportUtils.requiresAttentionFromCurrentUser(null)).toBe(false);
});
it('returns false when the matched IOU report does not have an owner accountID', () => {
const report = {
@@ -324,7 +343,7 @@ describe('ReportUtils', () => {
});
describe('getMoneyRequestOptions', () => {
- const participantsAccountIDs = _.keys(participantsPersonalDetails);
+ const participantsAccountIDs = Object.keys(participantsPersonalDetails).map(Number);
beforeAll(() => {
Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {
@@ -339,8 +358,8 @@ describe('ReportUtils', () => {
describe('return empty iou options if', () => {
it('participants aray contains excluded expensify iou emails', () => {
- const allEmpty = _.every(CONST.EXPENSIFY_ACCOUNT_IDS, (accountID) => {
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions({}, {}, [currentUserAccountID, accountID]);
+ const allEmpty = CONST.EXPENSIFY_ACCOUNT_IDS.every((accountID) => {
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(null, null, [currentUserAccountID, accountID]);
return moneyRequestOptions.length === 0;
});
expect(allEmpty).toBe(true);
@@ -351,7 +370,7 @@ describe('ReportUtils', () => {
...LHNTestUtils.getFakeReport(),
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID]);
expect(moneyRequestOptions.length).toBe(0);
});
@@ -361,7 +380,7 @@ describe('ReportUtils', () => {
chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
isOwnPolicyExpenseChat: false,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID]);
expect(moneyRequestOptions.length).toBe(0);
});
@@ -371,7 +390,7 @@ describe('ReportUtils', () => {
type: CONST.REPORT.TYPE.IOU,
statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID]);
expect(moneyRequestOptions.length).toBe(0);
});
@@ -382,7 +401,7 @@ describe('ReportUtils', () => {
stateNum: CONST.REPORT.STATE_NUM.APPROVED,
statusNum: CONST.REPORT.STATUS_NUM.APPROVED,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID]);
expect(moneyRequestOptions.length).toBe(0);
});
@@ -392,7 +411,7 @@ describe('ReportUtils', () => {
type: CONST.REPORT.TYPE.EXPENSE,
statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID]);
expect(moneyRequestOptions.length).toBe(0);
});
@@ -406,15 +425,20 @@ describe('ReportUtils', () => {
parentReportID: '100',
type: CONST.REPORT.TYPE.EXPENSE,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID]);
expect(moneyRequestOptions.length).toBe(0);
});
});
it("it is a submitted report tied to user's own policy expense chat and the policy does not have Instant Submit frequency", () => {
- const paidPolicy = {
+ const paidPolicy: Policy = {
id: '3f54cca8',
type: CONST.POLICY.TYPE.TEAM,
+ name: '',
+ role: 'user',
+ owner: '',
+ outputCurrency: '',
+ isPolicyExpenseChatEnabled: false,
};
Promise.all([
Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${paidPolicy.id}`, paidPolicy),
@@ -440,17 +464,19 @@ describe('ReportUtils', () => {
describe('return only iou split option if', () => {
it('it is a chat room with more than one participant', () => {
- const onlyHaveSplitOption = _.every(
- [CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, CONST.REPORT.CHAT_TYPE.POLICY_ROOM],
- (chatType) => {
- const report = {
- ...LHNTestUtils.getFakeReport(),
- chatType,
- };
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID, participantsAccountIDs[0]]);
- return moneyRequestOptions.length === 1 && moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT);
- },
- );
+ const onlyHaveSplitOption = [
+ CONST.REPORT.CHAT_TYPE.POLICY_ADMINS,
+ CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE,
+ CONST.REPORT.CHAT_TYPE.DOMAIN_ALL,
+ CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
+ ].every((chatType) => {
+ const report = {
+ ...LHNTestUtils.getFakeReport(),
+ chatType,
+ };
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID, participantsAccountIDs[0]]);
+ return moneyRequestOptions.length === 1 && moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT);
+ });
expect(onlyHaveSplitOption).toBe(true);
});
@@ -459,7 +485,7 @@ describe('ReportUtils', () => {
...LHNTestUtils.getFakeReport(),
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID, ...participantsAccountIDs]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID, ...participantsAccountIDs]);
expect(moneyRequestOptions.length).toBe(1);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT)).toBe(true);
});
@@ -469,7 +495,7 @@ describe('ReportUtils', () => {
...LHNTestUtils.getFakeReport(),
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID, ...participantsAccountIDs]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID, ...participantsAccountIDs]);
expect(moneyRequestOptions.length).toBe(1);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT)).toBe(true);
});
@@ -480,7 +506,7 @@ describe('ReportUtils', () => {
type: CONST.REPORT.TYPE.CHAT,
participantsAccountIDs: [currentUserAccountID, ...participantsAccountIDs],
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID, ...participantsAccountIDs]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID, ...participantsAccountIDs.map(Number)]);
expect(moneyRequestOptions.length).toBe(1);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT)).toBe(true);
});
@@ -498,7 +524,7 @@ describe('ReportUtils', () => {
parentReportID: '102',
type: CONST.REPORT.TYPE.EXPENSE,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID]);
expect(moneyRequestOptions.length).toBe(1);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST)).toBe(true);
});
@@ -519,8 +545,14 @@ describe('ReportUtils', () => {
};
const paidPolicy = {
type: CONST.POLICY.TYPE.TEAM,
- };
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, paidPolicy, [currentUserAccountID, participantsAccountIDs[0]], true);
+ id: '',
+ name: '',
+ role: 'user',
+ owner: '',
+ outputCurrency: '',
+ isPolicyExpenseChatEnabled: false,
+ } as const;
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, paidPolicy, [currentUserAccountID, participantsAccountIDs[0]]);
expect(moneyRequestOptions.length).toBe(1);
});
});
@@ -532,7 +564,7 @@ describe('ReportUtils', () => {
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID, participantsAccountIDs[0]]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID, participantsAccountIDs[0]]);
expect(moneyRequestOptions.length).toBe(1);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST)).toBe(true);
});
@@ -544,17 +576,22 @@ describe('ReportUtils', () => {
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID, participantsAccountIDs[0]]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID, participantsAccountIDs[0]]);
expect(moneyRequestOptions.length).toBe(1);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST)).toBe(true);
});
it("it is a submitted expense report in user's own policyExpenseChat and the policy has Instant Submit frequency", () => {
- const paidPolicy = {
+ const paidPolicy: Policy = {
id: 'ef72dfeb',
type: CONST.POLICY.TYPE.TEAM,
autoReporting: true,
autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT,
+ name: '',
+ role: 'user',
+ owner: '',
+ outputCurrency: '',
+ isPolicyExpenseChatEnabled: false,
};
Promise.all([
Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${paidPolicy.id}`, paidPolicy),
@@ -585,7 +622,7 @@ describe('ReportUtils', () => {
chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
isOwnPolicyExpenseChat: true,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID, ...participantsAccountIDs]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID, ...participantsAccountIDs]);
expect(moneyRequestOptions.length).toBe(2);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST)).toBe(true);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT)).toBe(true);
@@ -596,8 +633,9 @@ describe('ReportUtils', () => {
...LHNTestUtils.getFakeReport(),
type: CONST.REPORT.TYPE.CHAT,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID, participantsAccountIDs[0]]);
- expect(moneyRequestOptions.length).toBe(2);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID, participantsAccountIDs[0]]);
+ expect(moneyRequestOptions.length).toBe(3);
+ expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT)).toBe(true);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST)).toBe(true);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SEND)).toBe(true);
});
@@ -622,21 +660,21 @@ describe('ReportUtils', () => {
describe('sortReportsByLastRead', () => {
it('should filter out report without reportID & lastReadTime and sort lastReadTime in ascending order', () => {
- const reports = [
- {reportID: 1, lastReadTime: '2023-07-08 07:15:44.030'},
- {reportID: 2, lastReadTime: null},
- {reportID: 3, lastReadTime: '2023-07-06 07:15:44.030'},
- {reportID: 4, lastReadTime: '2023-07-07 07:15:44.030', type: CONST.REPORT.TYPE.IOU},
- {lastReadTime: '2023-07-09 07:15:44.030'},
- {reportID: 6},
- {},
+ const reports: Array> = [
+ {reportID: '1', lastReadTime: '2023-07-08 07:15:44.030'},
+ {reportID: '2', lastReadTime: undefined},
+ {reportID: '3', lastReadTime: '2023-07-06 07:15:44.030'},
+ {reportID: '4', lastReadTime: '2023-07-07 07:15:44.030', type: CONST.REPORT.TYPE.IOU},
+ {lastReadTime: '2023-07-09 07:15:44.030'} as Report,
+ {reportID: '6'},
+ null,
];
- const sortedReports = [
- {reportID: 3, lastReadTime: '2023-07-06 07:15:44.030'},
- {reportID: 4, lastReadTime: '2023-07-07 07:15:44.030', type: CONST.REPORT.TYPE.IOU},
- {reportID: 1, lastReadTime: '2023-07-08 07:15:44.030'},
+ const sortedReports: Array> = [
+ {reportID: '3', lastReadTime: '2023-07-06 07:15:44.030'},
+ {reportID: '4', lastReadTime: '2023-07-07 07:15:44.030', type: CONST.REPORT.TYPE.IOU},
+ {reportID: '1', lastReadTime: '2023-07-08 07:15:44.030'},
];
- expect(ReportUtils.sortReportsByLastRead(reports)).toEqual(sortedReports);
+ expect(ReportUtils.sortReportsByLastRead(reports, null)).toEqual(sortedReports);
});
});
@@ -656,7 +694,7 @@ describe('ReportUtils', () => {
'',
[{login: 'email1@test.com'}, {login: 'email2@test.com'}],
NumberUtils.rand64(),
- );
+ ) as ReportAction;
expect(ReportUtils.shouldDisableThread(reportAction, reportID)).toBeTruthy();
});
@@ -672,7 +710,7 @@ describe('ReportUtils', () => {
},
],
childVisibleActionCount: 1,
- };
+ } as ReportAction;
expect(ReportUtils.shouldDisableThread(reportAction, reportID)).toBeFalsy();
reportAction.childVisibleActionCount = 0;
@@ -688,7 +726,7 @@ describe('ReportUtils', () => {
.then(() => {
const reportAction = {
childVisibleActionCount: 1,
- };
+ } as ReportAction;
expect(ReportUtils.shouldDisableThread(reportAction, reportID)).toBeFalsy();
reportAction.childVisibleActionCount = 0;
@@ -700,20 +738,20 @@ describe('ReportUtils', () => {
const reportAction = {
actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE,
whisperedToAccountIDs: [123456],
- };
+ } as ReportAction;
expect(ReportUtils.shouldDisableThread(reportAction, reportID)).toBeTruthy();
});
it('should disable on thread first chat', () => {
const reportAction = {
childReportID: reportID,
- };
+ } as ReportAction;
expect(ReportUtils.shouldDisableThread(reportAction, reportID)).toBeTruthy();
});
});
describe('getAllAncestorReportActions', () => {
- const reports = [
+ const reports: Report[] = [
{reportID: '1', lastReadTime: '2024-02-01 04:56:47.233', reportName: 'Report'},
{reportID: '2', lastReadTime: '2024-02-01 04:56:47.233', parentReportActionID: '1', parentReportID: '1', reportName: 'Report'},
{reportID: '3', lastReadTime: '2024-02-01 04:56:47.233', parentReportActionID: '2', parentReportID: '2', reportName: 'Report'},
@@ -721,24 +759,23 @@ describe('ReportUtils', () => {
{reportID: '5', lastReadTime: '2024-02-01 04:56:47.233', parentReportActionID: '4', parentReportID: '4', reportName: 'Report'},
];
- const reportActions = [
- {reportActionID: '1', created: '2024-02-01 04:42:22.965'},
- {reportActionID: '2', created: '2024-02-01 04:42:28.003'},
- {reportActionID: '3', created: '2024-02-01 04:42:31.742'},
- {reportActionID: '4', created: '2024-02-01 04:42:35.619'},
+ const reportActions: ReportAction[] = [
+ {reportActionID: '1', created: '2024-02-01 04:42:22.965', actionName: 'MARKEDREIMBURSED'},
+ {reportActionID: '2', created: '2024-02-01 04:42:28.003', actionName: 'MARKEDREIMBURSED'},
+ {reportActionID: '3', created: '2024-02-01 04:42:31.742', actionName: 'MARKEDREIMBURSED'},
+ {reportActionID: '4', created: '2024-02-01 04:42:35.619', actionName: 'MARKEDREIMBURSED'},
];
beforeAll(() => {
+ const reportCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.REPORT, reports, (report) => report.reportID);
+ const reportActionCollectionDataSet = toCollectionDataSet(
+ ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ reportActions.map((reportAction) => ({[reportAction.reportActionID]: reportAction})),
+ (actions) => Object.values(actions)[0].reportActionID,
+ );
Onyx.multiSet({
- [`${ONYXKEYS.COLLECTION.REPORT}${reports[0].reportID}`]: reports[0],
- [`${ONYXKEYS.COLLECTION.REPORT}${reports[1].reportID}`]: reports[1],
- [`${ONYXKEYS.COLLECTION.REPORT}${reports[2].reportID}`]: reports[2],
- [`${ONYXKEYS.COLLECTION.REPORT}${reports[3].reportID}`]: reports[3],
- [`${ONYXKEYS.COLLECTION.REPORT}${reports[4].reportID}`]: reports[4],
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reports[0].reportID}`]: {[reportActions[0].reportActionID]: reportActions[0]},
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reports[1].reportID}`]: {[reportActions[1].reportActionID]: reportActions[1]},
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reports[2].reportID}`]: {[reportActions[2].reportActionID]: reportActions[2]},
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reports[3].reportID}`]: {[reportActions[3].reportActionID]: reportActions[3]},
+ ...reportCollectionDataSet,
+ ...reportActionCollectionDataSet,
});
return waitForBatchedUpdates();
});
diff --git a/tests/utils/LHNTestUtils.tsx b/tests/utils/LHNTestUtils.tsx
index 2dfb31f7cfe4..50a495f39d51 100644
--- a/tests/utils/LHNTestUtils.tsx
+++ b/tests/utils/LHNTestUtils.tsx
@@ -1,7 +1,5 @@
/* eslint-disable @typescript-eslint/naming-convention */
-import type {NavigationProp} from '@react-navigation/core/src/types';
import type * as Navigation from '@react-navigation/native';
-import type {ParamListBase} from '@react-navigation/routers';
import {render} from '@testing-library/react-native';
import type {ReactElement} from 'react';
import React from 'react';
@@ -33,17 +31,13 @@ type MockedSidebarLinksProps = {
currentReportID?: string;
};
-// we have to mock `useIsFocused` because it's used in the SidebarLinks component
-const mockedNavigate: jest.MockedFn['navigate']> = jest.fn();
jest.mock('@react-navigation/native', (): typeof Navigation => {
const actualNav = jest.requireActual('@react-navigation/native');
return {
...actualNav,
useRoute: jest.fn(),
useFocusEffect: jest.fn(),
- useIsFocused: () => ({
- navigate: mockedNavigate,
- }),
+ useIsFocused: () => true,
useNavigation: () => ({
navigate: jest.fn(),
addListener: jest.fn(),