diff --git a/Mobile-Expensify b/Mobile-Expensify
index f370c5f31cdf..7ffe8a7f1b47 160000
--- a/Mobile-Expensify
+++ b/Mobile-Expensify
@@ -1 +1 @@
-Subproject commit f370c5f31cdfd750b0d42d75a471a9b8d30935ad
+Subproject commit 7ffe8a7f1b471c697f9823b8cd4a2c19b200fa6f
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 4c917b995331..7fd4fd9dd59d 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -110,8 +110,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1009007706
- versionName "9.0.77-6"
+ versionCode 1009007802
+ versionName "9.0.78-2"
// Supported language variants must be declared here to avoid from being removed during the compilation.
// This also helps us to not include unnecessary language variants in the APK.
resConfigs "en", "es"
diff --git a/assets/images/buildings.svg b/assets/images/buildings.svg
new file mode 100644
index 000000000000..42171d499f26
--- /dev/null
+++ b/assets/images/buildings.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/assets/images/simple-illustrations/simple-illustration__building.svg b/assets/images/simple-illustrations/simple-illustration__building.svg
new file mode 100644
index 000000000000..94a7320d8471
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__building.svg
@@ -0,0 +1,47 @@
+
+
\ No newline at end of file
diff --git a/assets/images/simple-illustrations/simple-illustration__buildings.svg b/assets/images/simple-illustrations/simple-illustration__buildings.svg
new file mode 100644
index 000000000000..cb22c3a29ce4
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__buildings.svg
@@ -0,0 +1,55 @@
+
+
\ No newline at end of file
diff --git a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md
index f94e692f5e56..1398e02a7a03 100644
--- a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md
+++ b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md
@@ -3,97 +3,156 @@ title: Troubleshooting
description: How to troubleshoot company card importing in Expensify
---
# Overview
-Whether you're encountering issues related to company cards, require assistance with company card account access, or have questions about company card import features, you've come to the right place.
+This guide helps you troubleshoot common issues with company cards in Expensify, including connection errors, missing transactions, and account setup problems.
-## How to add company cards to Expensify
-You can add company credit cards under the Domain settings in your Expensify account by navigating to *Settings* > *Domain* > _Domain Name_ > *Company Cards* and clicking *Import Card/Bank* and following the prompts.
+## Adding company cards to Expensify
+To add company credit cards:
-## To Locate Missing Card Transactions in Expensify
-1. **Wait for Posting**: Bank transactions may take up to 24 hours to import into Expensify after they have "posted" at your bank. Ensure sufficient time has passed for transactions to appear.
-2. **Update Company Cards**: Go to Settings > Domains > Company Cards. Click on the card in question and click "Update" to refresh the card feed.
-3. **Reconcile Cards**: Navigate to the Reconciliation section under Settings > Domains > Company Cards. Refer to the detailed guide on how to use the [Reconciliation Dashboard](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Reconciliation#identifying-outstanding-unapproved-expenses-using-the-reconciliation-dashboard).
-4. **Review Transactions**: Use the Reconciliation Dashboard to view all transactions within a specific timeframe. Transactions will display on the Expenses page based on their "Posted Date". If needed, uncheck the "use posted date" checkbox near the filters to view transactions based on their "Transaction Date" instead.
-5. **Address Gaps**: If there is a significant gap in transactions or if transactions are still missing, contact Expensify's Concierge or your Account Manager. They can initiate a historical data update on your card feed to ensure all transactions are properly imported.
+1. Go to **Settings** > **Domain** > _[Domain Name]_ > **Company Cards**.
+2. Click **Import Card/Bank** and follow the prompts.
-Following these steps should help you identify and resolve any issues with missing card transactions in Expensify.
+{% include info.html %}
+Only Domain Admins can connect and assign company cards in Expensify. If you're not a Domain Admin and want to connect your own credit card, follow the steps [here](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards) to connect it as a personal card.
+{% include end-info.html %}
-## Known issues importing transactions
-The first step should always be to "Update" your card, either from Settings > Your Account > Credit Card Import or Settings > Domain > [Domain Name] > Company Cards for centrally managed cards. If a "Fix" or "Fix card" option appears, follow the steps to fix the connection. If this fails to import your missing transactions, there is a known issue whereby some transactions will not import for certain API-based company card connections. So far this has been reported on American Express, Chase and Wells Fargo. This can be temporarily resolved by creating the expenses manually instead:
+## Best practices for establishing the initial card connection
+To ensure a successful initial card connection in Expensify, follow these best practices:
-- [Manually add the expenses](https://help.expensify.com/articles/expensify-classic/expenses/expenses/Add-an-expense)
-- [Upload the expenses via CSV](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import)
+- **Import in the Correct Location**: For company cards, navigate to **Settings** > **Domains** > _[Domain Name]_ > **Company Cards** > **Import Card** to establish the connection. For personal or individual card accounts, refer to the instructions [here](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards).
+- **Select the Appropriate Bank Connection**: Ensure you’re selecting the appropriate bank connection for your cards.
+- **Use Master or Parent Administrative Credentials**: For company cards, always use the master administrative credentials to import the entire set of cards.
+- **Disable Two-Factor Authentication (2FA)**: Expensify cannot bypass bank-imposed 2FA requirements. To maintain a stable connection, temporarily disable 2FA on your bank account before attempting to connect.
-# Errors connecting company cards
+By following these steps, you can avoid common issues and establish a stable card connection with Expensify.
+
+# Resolving missing card transactions
+
+Here are some common steps to resolve issues with missing imported expenses:
+
+1. **Wait for posting.** Bank transactions may take up to 24 hours to import into Expensify after they have posted at your bank. Ensure sufficient time has passed for transactions to appear.
+2. **Update company cards.** Go to **Settings** > **Domains** > _[Domain Name]_ > **Company Cards**. Click on the card in question and select **Update** to refresh the card feed.
+3. **Reconcile cards.** Navigate to the **Reconciliation** section under **Settings** > **Domains** > _[Domain Name]_ > **Company Cards**. Refer to the detailed guide on how to use the [Reconciliation Dashboard](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Reconciliation#identifying-outstanding-unapproved-expenses-using-the-reconciliation-dashboard).
+4. **Review transactions.** Use the [Reconciliation Dashboard](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Reconciliation#identifying-outstanding-unapproved-expenses-using-the-reconciliation-dashboard) to view all transactions within a specific timeframe. Transactions will display on the **Expenses** page based on their posted date. If needed, uncheck the Use Posted Date checkbox near the filters to view transactions based on their Transaction Date instead.
+5. **Address gaps.** If there is a significant gap in transactions or if transactions are still missing, contact Concierge or your Account Manager. They can initiate a historical data update on your card feed to ensure all transactions are properly imported.
+
+# General troubleshooting
+
+## Common import problems
+
+If company cards seem to be disconnected or not working as expected, troubleshoot by:
+- Clicking **Update Card** under:
+ - **Settings** > **Account** > **Credit Card Import** for personal cards, or
+ - **Settings** > **Domains** > _[Domain Name]_ > **Company Cards** for company cards.
+- If a **Fix** option appears, click on it and follow the steps to fix the connection.
+
+## Alternative workarounds
+For persistent issues with API-based connections (e.g., American Express, Chase, Wells Fargo), the alternative option is to [manually add expenses](https://help.expensify.com/articles/expensify-classic/expenses/expenses/Add-an-expense), or [upload expenses via CSV](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import).
+
+## The connection is established but there are no cards to assign
+When establishing the connection, you must assign cards during the same session. It isn't possible to create the connection, log out, and assign the cards later, as the connection will not stick and will require you to reattempt the connection.
+
+# Addressing duplicate expenses
+
+If a workspace member is experiencing duplicated expenses, this is typically due to:
+
+ - A cardholder having accidentally imported the card as a personal credit card, in addition to being assigned the company card by a Domain Admin.
+ - To troubleshoot, have the employee navigate to **Settings** > **Account** > **Credit Card Import** and confirm that their card is only listed once.
+
+ - The card was reassigned to the cardholder without the appropriate transaction start date being selected, resulting in a period of overlap.
+ - To troubleshoot, ensure expenses on the new card assignment have not been submitted. Then unassign the card and reassign it with a more appropriate start date. This action will delete all unsubmitted expenses from the new card feed.
+
+{% include info.html %}
+Deleting a duplicate card will remove all Unapproved and Open expenses linked to that card. However, transactions associated with the remaining assigned card will remain unaffected. Any receipts attached to the deleted transactions will still appear on the Expenses page and can be reattached to the corresponding imported expense on the remaining assigned card.
+{% include end-info.html %}
+
+# Tips for stable bank connections
+
+## Causes for connection breaks
+Banks frequently update their APIs to enhance the security of financial information. However, for security reasons, they may not notify third-party services like Expensify in advance of these changes. Expensify's engineering team works diligently to minimize interruptions by monitoring bank connections and collaborating with banks to address updates promptly.
+
+## Resolving connection issues
+Expensify's API-based banking connections rely on the online banking login credentials to maintain the connection. If your online banking username, password, security questions, login authentication, or card numbers change, the connection may need to be reestablished. Domain Admins can update this information in Expensify and manually reestablish the connection via **Settings** > **Domains** > _[Domain Name]_ > **Company Cards** > **Fix**. The Domain Admin will be prompted to enter the new credentials or updated information, which should reestablish the connection.
+
+# Common errors and resolutions
+
+Here are some errors that can occur when working with bank connections, and steps for resolving them:
## Error: Too many attempts
-If you've been locked out while trying to import a new card, you'll need to wait a full 24 hours before trying again. This lock happens when incorrect online banking credentials are entered multiple times, and it's there for your security — it can't be removed. To avoid this, make sure your online banking credentials are correct before attempting to import your card again.
-
-## Error: Invalid credentials/Login failed
-Verify your ability to log into your online banking portal by attempting to log into your bank account via the banking website.
-Check for any potential temporary outages on your bank's end that may affect third-party connections like Expensify.
-For specific card types:
-- *Chase Card*: Confirm your password meets their new 8-32 character requirement.
-- *Wells Fargo Card*: Ensure your password is under 14 characters. Reset it if necessary before importing your card to Expensify. If your card is already imported, update it and use the "Fix Card" option to reestablish the connection.
-- *SVB Card*: Enable Direct Connect from the SVB website and use your online banking username and Direct Connect PIN instead of your password when connecting an SVB card. If connecting via *Settings* > *Domain* > _[Domain Name]_ > *Company Cards*, contact SVB for CDF feed setup.
+If you've been locked out while trying to import a new card, you will need to wait a full 24 hours before trying again. This lock happens when incorrect online banking credentials are entered multiple times, and it cannot be bypassed. To avoid this, make sure your online banking credentials are correct before attempting to import your card again.
+
+## Error: Invalid credentials/login failed
+Verify the online banking login details by accessing your bank's website directly.
+- Some known bank-specific requirements are:
+ - **Chase**. Password must meet their 8-32 character requirement.
+ - **Wells Fargo**. Password must be under 14 characters.
+ - **SVB**. Enable Direct Connect and use the Direct Connect PIN for login.
## Error: Direct Connect not enabled
-Direct Connect will need to be enabled in your account for your bank/credit card provider before you can import your card to Expensify. Please reach out to your bank to confirm if this option is available for your account, as well as get instructions on how to get this setup.
+Direct Connect needs to be enabled on the bank account by your bank or credit card provider before it can be connected to Expensify. Please reach out to your bank to confirm if this option is available for your account and get instructions on how to enable it.
-## Error: Account Setup
-This error message typically indicates that there's something you need to do on your bank account's end. Please visit your online banking portal and check if there are any pending actions required. Once you've addressed those, you can try connecting your card again.
-For Amex cardholders with multiple card programs in your Amex US Business account: To import multiple card programs into Expensify, you'll need to contact Amex and request that they separate the multiple card programs into distinct logins. For instance, you'll want to have your _Business Platinum_ cards under *"username1/password1"* and _Business Gold_ cards under *"username2/password2."* This ensures smooth integration with Expensify.
+## Error: Account setup
+This error message indicates that there is something you need to do on your bank account's end. Please visit your online banking portal and check if there are any pending actions required before attempting to connect your card again.
-## Error: Account type not supported
-If Expensify doesn't have a direct connection to your bank/credit card provider, we can still support the connection via spreadsheet import, which you can learn more about [here](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import). If the cards you're trying to import are company cards, it’s possible that you might be able to obtain a commercial feed directly from your bank. Please find more information on this [here](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Commercial-Card-Feeds).
+# Troubleshooting American Express connections
-## Error: Username/Password/Questions out of date
-Your company card connection is broken because we're missing some answers to some security questions. Please head to *Settings* > *Domain* > _[Domain Name]_ > *Company Cards* and click _Fix Card_.
-This will require you to answer your bank's security questions. You will need to do this for each security question you have with your bank; so if you have 3 security questions, you will need to do this 3 times.
+## Account roles and permissions
+When connecting American Express cards to Expensify, you must use the Amex login credentials of the Primary/Basic account holder. Using other credentials, such as Supplemental Cardmember or Authorized Account Manager, will fail to load card data or may result in an error.
-## Error: Account not found/Card number changed
-This error message appears when you have been issued a new card, or if there's been a significant change to the account in some other way (password and/or card number change).
-When your online bank/card account password has been changed, you may need to update the details on the Expensify end as well. To do this, navigate to *Settings* > *Domain* > _[Domain Name]_ > *Company Cards* and click _Fix Card_.
-If there’s been a recent change to the card number, you’ll have to remove the card with the previous number and re-import the card using the new number. A Domain Admin will have to re-assign the card via *Settings* > *Domain* > _Domain Name_ > *Company Cards*. Before removing the card, please ensure *all Open reports have been submitted*, as removing the card will remove all imported transactions from the account that are associated with that card.
+{% include info.html %}
+In American Express, the Primary/Basic Account Holder is typically the person who applied for the American Express Business card, owns the account, manages its finances, and controls card issuance and account management. They can see all charges made by other cardmembers on their account.
-## Error: General connection error
-This error message states that your bank or credit card provider is under maintenance and is unavailable at this time. Try waiting a few hours before trying to import your credit card again. Check out our [status page](https://status.expensify.com/) for updates on bank/credit card connections, or you can also choose to subscribe to updates for your specific account type.
+By contrast, a Supplemental Cardmember or Employee Cardmember is typically an employee on American Express accounts with access to their own card and payments. An Authorized Account Manager (AAM) has management privileges allowing them to manage the account and Supplemental Cardmembers' accounts. These roles do not have sufficient permissions in American Express to authorize the connection to Expensify, and therefore only the Primary/Basic Account Holder credentials can be used.
+{% include end-info.html %}
-## Error: Not seeing cards listed after a successful login
-The card will only appear in the drop-down list for assignment once it’s activated and there are transactions that have been incurred and posted on the card. If not, the card won't be available to assign to the card holder until then.
+## Importing multiple card programs
+If you have multiple American Express card programs, contact Amex and request that they separate the multiple card programs into distinct logins. For example, you can have your _Business Platinum_ cards under *"username1/password1"* and _Business Gold_ cards under *"username2/password2"*. This ensures smooth integration with Expensify.
-# Troubleshooting issues assigning company cards
+## Connecting multiple company card programs under the same credentials
+If you have multiple company card programs using the same credentials, you can import all programs together, which will display them under a single dropdown. Be sure to select all relevant cards each time you add cards from any program.
-## Why do bank connections break?
-Banks often make changes to safeguard your confidential information, and when they do, we need to update the connection between Expensify and the bank. We have a team of engineers who work closely with banks to monitor this and update our software accordingly when this happens.
-The first step is to check if there have been any changes to your bank information. Have you recently changed your banking password without updating it in Expensify? Has your banking username or card number been updated? Did you update your security questions for your bank?
-If you've answered "yes" to any of these questions, a Domain Admins need to update this information in Expensify and manually re-establish the connection by heading to *Settings* > *Domains* > _Domain Name_ > *Company Cards* > *Fix*. The Domain Admin will be prompted to enter the new credentials/updated information and this should reestablish the connection.
+If you prefer to manage card programs separately, you can import them one at a time, ensuring you select all cards within the specific program during each import. After authorizing the account, you will be guided back to Expensify to assign the cards as needed.
-## How do I resolve errors while I’m trying to import my card?*
-Make sure you're importing your card in the correct spot in Expensify and selecting the right bank connection. For company cards, use the master administrative credentials to import your set of cards at *Settings* > *Domains* > _Domain Name_ > *Company Cards* > *Import Card*.
-Please note there are some things that cannot be bypassed within Expensify, including two-factor authentication being enabled within your bank account. This will prevent the connection from remaining stable and will need to be turned off on the bank side.
+*Important Reminder*: Whenever you need to access the connection to assign a new card, you must still choose all card programs. For example, if you have a new employee with a card under your Business Gold Rewards Card program, you will still need to authorize all the cards in that program or all the programs if you have only one dropdown menu.
-## Why Can’t I See the Transactions Before a Certain Date?
-When importing a card into Expensify, the platform typically retrieves 30-90 days of historical transactions, depending on the card or account type. For commercial feeds, transactions cannot be imported before the bank starts sending data. If needed, banks can send backdated files, and Expensify can run a historical update upon request.
+## Adding cards under different programs with different logins
+If you have multiple card programs with different credentials, you will need another Domain Admin account to add each card program from their own account. Once all Domain Admins have connected and assigned the cards they are the Primary account holder for, all cards will be listed under one *American Express (New and Upgraded)* list on the Domain Company Card page.
-Additionally, Expensify does not import transactions dated before the "start date" you specify when assigning the card. Unless transitioning from an old card to a new one to avoid duplicates, it's advisable to set the start date to "earliest possible" or leave it blank.
+## Amex error: Username, password, or security questions out of date
+Your company card connection is broken because Expensify is missing answers to your security questions. Go to **Settings** > **Domain** > _[Domain Name]_ > **Company Cards** and click **Fix**. Answer your bank's security questions to restore the connection. Repeat this process for each security question your bank requires.
-For historical expenses that cannot be imported automatically, consider using Expensify's [company card](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/CSV-Import) or [personal card](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards#importing-expenses-via-a-spreadsheet) spreadsheet import method. This allows you to manually input missing transactions into the system.
+## Amex error: Account not found or card number changed
+This error occurs when you have been issued a new card or if there has been a significant change to the account, such as a password or card number update.
-## Why Am I / Why Is My Employee Seeing Duplicates?
-If an employee is seeing duplicate expenses, they may have accidentally imported the card as a personal credit card as well as having the Domain Admin assign them a company card.
+To update the connection:
+1. Go to **Settings** > **Domain** > _[Domain Name]_ > **Company Cards** and click **Fix**.
+2. If there has been a card number change, remove the card with the previous number and re-import the card with the new number.
+3. Before removing the card, ensure all open reports have been submitted. Removing the card will delete all imported transactions associated with that card. A Domain Admin will need to re-assign the card after re-importing it.
-To troubleshoot:
-- Have the employee navigate to their Settings > Your Account > Credit Card Import and confirm that their card is only listed once.
-- If the card is listed twice, delete the entry without the "padlock" icon.
+## Amex error: General connection error
+This error indicates that your bank or credit card provider is under maintenance and unavailable. Wait a few hours before trying to import your credit card again. Check Expensify's [status page](https://status.expensify.com/) for updates on bank or credit card connections, or subscribe to updates for your account type.
+
+## Amex error: Session has expired
+If you see an error stating "Your session has expired. Please return to Expensify and try again," this means you are using incorrect Amex credentials. Use the Primary/Basic account holder credentials. If you are unsure which credentials to use, contact American Express for guidance.
+
+## Amex error: Card isn't eligible
+This error occurs when the account is not a business account or the credentials used are not for the Primary account holder. Verify the account type and credentials before attempting to connect again.
-**Important:** Deleting a duplicate card will delete all unapproved expenses from that transaction feed. Transactions associated with the remaining card will not be affected. If receipts were attached to those transactions, they will still be on the Expenses page, and the employee can click to SmartScan them again.
+# Troubleshooting Chase connections
-Duplicate expenses might also occur if you recently unassigned and reassigned a company card with an overlapping start date. If this is the case and expenses on the “new” copy have not been submitted, you can unassign the card again and reassign it with a more appropriate start date. This action will delete all unsubmitted expenses from the new card feed.
+## Resetting Chase access to Expensify
+If you are experiencing issues with your Chase connection in Expensify, resetting access can often resolve the problem. Follow these steps to troubleshoot:
-## What are the most reliable bank connections in Expensify?*
-All bank connections listed below are extremely reliable, but we recommend transacting with the Expensify Visa® Commercial Card. It also offers daily and monthly settlement, unapproved expense limits, realtime compliance for secure and efficient spending, and cash back on all US purchases. [Click here to learn more about the Expensify Card](https://use.expensify.com/company-credit-card).
+1. Log in to your Chase account portal and visit the [Linked Apps & Websites](https://www.chase.com/digital/data-sharing) page in the Security Center.
+2. Locate Expensify in the Linked Apps & Websites list.
+3. Select **Stop sharing data** to disconnect Expensify's access to your Chase account.
+4. After resetting access, follow the instructions [here](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting#how-to-add-company-cards-to-expensify) to reestablish the connection to Chase.
-We've also teamed up with major banks worldwide to ensure a smooth import of credit card transactions into your accounts:
+{% include faq-begin.md %}
+
+## What bank connections does Expensify offer?
+Expensify offers highly reliable bank connections, but we recommend using the Expensify Visa® Commercial Card. It provides daily and monthly settlement, unapproved expense limits, real-time compliance for secure and efficient spending, and cash back on all US purchases. [Click here to learn more about the Expensify Card](https://use.expensify.com/company-credit-card).
+
+Alternatively, Expensify has partnered with major banks worldwide to ensure a smooth import of credit card transactions into your accounts, including:
- American Express
- Bank of America
- Brex
@@ -103,33 +162,13 @@ We've also teamed up with major banks worldwide to ensure a smooth import of cre
- Stripe
- Wells Fargo
-Commercial feeds for company cards are the dependable connections in Expensify. If you have a corporate or commercial card account, you might have access to a daily transaction feed where expenses from Visa, Mastercard, and American Express are automatically sent to Expensify. Reach out to your banking relationship manager to check if your card program qualifies for this feature.
+## What are the most stable bank connections?
+Commercial feeds for company cards are the most dependable connections in Expensify and are considered more stable than API-based connections. If you have a corporate or commercial card account, you might have access to a daily transaction feed where expenses from Visa, Mastercard, and American Express are automatically sent to Expensify. Contact your banking relationship manager to check if your card program qualifies for this feature.
-# Troubleshooting American Express Business
+## Why can’t I see the transactions before a certain date?
+When importing a card into Expensify, the bank typically provides 30-90 days of historical transactions, depending on the card or account type. For commercial feeds, transactions cannot be imported before the bank starts sending data, however banks can send backdated files if historical transactions are needed.
-## Amex account roles
-American Express provides three different roles for accessing accounts on their website. When connecting Amex cards to Expensify, it's crucial to use the credentials of the Primary/Basic account holder. Here's what each role means:
-- *Primary/Basic Account Holder*: The person who applied for the American Express Business card, owns the account, manages its finances, and controls card issuance and account management. They can view all charges by other cardmembers on their account. They can see all charges made by other cardmembers on their account.
-- *Supplemental Cardmember (Employee Cardmember)*: Chosen by the Primary Card Member (typically an employee on business accounts), they can access their own card info and make payments but can't see other account details.
-- *Authorized Account Manager (AAM)*: Chosen by the Primary Card Member, AAMs can manage the account online or by phone, but they can't link cards to services like Expensify. They have admin rights, including adding cards, making payments, canceling cards, and setting limits. To connect cards to Expensify, use the Primary Card Holder's credentials for full access.
-
-## The connection is established but there are no cards to assign
+Additionally, Expensify does not import transactions dated before the "start date" you specify when assigning the card. Unless transitioning from an old card to a new one to avoid duplicates, it is advisable to set the start date to "earliest possible" or leave it blank. For historical expenses that cannot be imported automatically, consider using Expensify's [company card](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/CSV-Import) or [personal card](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards#importing-expenses-via-a-spreadsheet) spreadsheet import method to manually input missing transactions into the system.
-When establishing the connection, you must assign cards during the same session. It isn't possible to create the connection, log out, and assign the cards later, as the connection will not stick, and require you to reattempt the connection again.
+{% include faq-end.md %}
-## Amex error: Card isn't eligible
-This error comes directly from American Express and is typically related to an account that is not a business account or using credentials that are not the primary account holder credentials.
-
-## Amex error: Session has expired
-If you get an error stating an American Express Business Card “Your session has expired. Please return to Expensify and try again, this always means that you are using the incorrect credentials. Remember, you need to use primary/basic cardholder credentials. If you are not sure which credentials you should use, reach out to American Express for guidance.
-
-## Connect multiple company card programs under the same credentials
-If you have multiple company card programs with the same credentials, you can select ALL programs at once. With this, all programs will be under one dropdown. Make sure to select all cards each time you are adding any cards from any program.
-If you would like your card programs listed under separate dropdowns, you can select only that group making sure to select all cards from that group each time you are adding a new card.
-Once you have authorized the account, you’ll be guided back to Expensify where you’ll assign all necessary cards across all programs.
-This will store all cards under the same American Express Business connection dropdown and allow all cards to be added to Expensify for you to assign to users.
-*Important Reminder*: Whenever you need to access the connection to assign a new card, you must still choose "ALL card programs." For instance, if you have a new employee with a card under your Business Gold Rewards Card program, you'll still need to authorize all the cards in that program or all the programs if you have only one dropdown menu!
-
-## Add cards under different programs with different logins
-If you have multiple card programs with different credentials, you will need to have another Domain Admin account add each card program from their own account.
-Once all Domain Admins have connected and assigned the cards that they are the Primary account holder for, all cards will be listed under one *American Express (New and Upgraded)* list in the Domain Company Card page.
diff --git a/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md b/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md
index 68bca5228913..ec3d45b3ac08 100644
--- a/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md
+++ b/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md
@@ -39,14 +39,14 @@ The three options for the date your report will export with are:
## Accounting Method
This dictates when reimbursable expenses will export, according to your preferred accounting method:
-- Accrual: Out of pocket expenses will export immediately when the report is final approved
-- Cash: Out of pocket expenses will export when paid via Expensify or marked as Reimbursed
+- Accrual: Out-of-pocket expenses will export immediately when the report is final approved
+- Cash: Out-of-pocket expenses will export when paid via Expensify or marked as Reimbursed
## Export Settings for Reimbursable Expenses
**Expense Reports:** Expensify transactions will export reimbursable expenses as expense reports by default, which will be posted to the payables account designated in NetSuite.
-**Vendor Bills:** Expensify transactions export as vendor bills in NetSuite and will be mapped to the subsidiary associated with the corresponding workspace. Each report will be posted as payable to the vendor associated with the employee who submitted the report. You can also set an approval level in NetSuite for vendor bills.
+**Vendor Bills:** Expensify transactions export as vendor bills in NetSuite and are mapped to the subsidiary associated with the corresponding workspace. Each report is posted as payable to the vendor associated with the employee who submitted it. You can also set an approval level in NetSuite for vendor bills.
**Journal Entries:** Expensify transactions that are set to export as journal entries in NetSuite will be mapped to the subsidiary associated with this workspace. All the transactions will be posted to the payable account specified in the workspace. You can also set an approval level in NetSuite for the journal entries.
@@ -63,7 +63,7 @@ This dictates when reimbursable expenses will export, according to your preferre
- Journal entry forms do not contain a customer column, so it is not possible to export customers or projects with this export option
- The credit line and header level classifications are pulled from the employee record
-**Expense Reports:** To use the expense report option for your corporate card expenses, you will need to set up your default corporate cards in NetSuite.
+**Expense Reports:** To use the expense report option for your corporate card expenses, you must set up your default corporate cards in NetSuite.
To use a default corporate card for non-reimbursable expenses, you must select the correct card on the employee records (for individual accounts) or the subsidiary record (If you use a non-one world account, the default is found in your accounting preferences).
@@ -87,6 +87,8 @@ When selecting the option to export non-reimbursable expenses as vendor bills, t
The Coding tab is where NetSuite information is configured in Expensify, which allows employees to code expenses and reports accurately. There are several coding options in NetSuite. Let’s go over each of those below.
+![Insert alt text for accessibility here]({{site.url}}/assets/images/NetSuite_Configure_08.png){:width="100%"}
+
## Expense Categories
Expensify's integration with NetSuite automatically imports NetSuite Expense Categories as Categories in Expensify.
@@ -225,6 +227,8 @@ From there, you should see the values for the Custom Lists under the Tag or Repo
The NetSuite integration’s advanced configuration settings are accessed under **Settings > Workspaces > Group > _[Workspace Name]_ > Connections > NetSuite > Configure > Advanced tab**.
+![Insert alt text for accessibility here]({{site.url}}/assets/images/NetSuite_Configure_09.png){:width="100%"}
+
Let’s review the different advanced settings and how they interact with the integration.
## Auto Sync
diff --git a/docs/articles/expensify-classic/domains/SAML-SSO.md b/docs/articles/expensify-classic/domains/SAML-SSO.md
index da4bd5639120..df73cf5d54c0 100644
--- a/docs/articles/expensify-classic/domains/SAML-SSO.md
+++ b/docs/articles/expensify-classic/domains/SAML-SSO.md
@@ -17,7 +17,7 @@ Once the domain is verified, you can access the SSO settings by navigating to Se
**Below are instructions for setting up Expensify for specific SSO providers:**
- [Amazon Web Services (AWS SSO)](https://static.global.sso.amazonaws.com/app-202a715cb67cddd9/instructions/index.htm)
- [Google SAML](https://support.google.com/a/answer/7371682) (for GSuite, not Google SSO)
-- [Microsoft Azure Active Directory](https://azure.microsoft.com/en-us/documentation/articles/active-directory-saas-expensify-tutorial/)
+- [Microsoft Entra ID (formerly Azure Active Directory)](https://learn.microsoft.com/en-us/entra/identity/saas-apps/expensify-tutorial)
- [Okta](https://saml-doc.okta.com/SAML_Docs/How-to-Configure-SAML-2.0-for-Expensify.html)
- [OneLogin](https://onelogin.service-now.com/support?id=kb_article&sys_id=e44c9e52db187410fe39dde7489619ba)
- [Oracle Identity Cloud Service](https://docs.oracle.com/en/cloud/paas/identity-cloud/idcsc/expensify.html#Expensify)
@@ -39,13 +39,13 @@ The entityID for Expensify is https://expensify.com. Remember not to copy and pa
## Can you have multiple domains with only one entity ID?
Yes. Please send a message to the Concierge or your account manager, and we will enable the use of the same entity ID with multiple domains.
-## How can I update the Microsoft Azure SSO Certificate?
+## How can I update the Microsoft Entra ID SSO Certificate?
Expensify's SAML configuration doesn't support multiple active certificates. This means that if you create the new certification ahead of time without first removing the old one, the respective IDP will include two unique x509 certificates instead of one, and the connection will break. Should you need to access Expensify, switching back to the old certificate will continue to allow access while that certificate is still valid.
-**To transfer from one Microsoft Azure certificate to another, please follow the below steps:**
-1. In Azure Directory, create your new certificate.
-2. In Azure Director, remove the old, expiring certificate.
-3. In Azure Directory, activate the remaining certificate and get a new IDP for Expensify from it.
+**To transfer from one Microsoft Entra certificate to another, please follow the below steps:**
+1. In Microsoft Entra, create your new certificate.
+2. In Microsoft Entra, remove the old, expiring certificate.
+3. In Microsoft Entra, activate the remaining certificate and get a new IDP for Expensify from it.
4. In Expensify, replace the previous IDP with the new IDP.
5. Log in via SSO. If login continues to fail, write to Concierge for assistance.
diff --git a/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md b/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md
index 1b1702c6fcc7..2157e05aa377 100644
--- a/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md
+++ b/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md
@@ -51,10 +51,10 @@ When an expense is submitted to a workspace, your approver will receive an email
{% include end-selector.html %}
-![Click Global Create]({{site.url}}/assets/images/ExpensifyHelp-CreateExpense-1.png){:width="100%"}
-![Click Submit expense]({{site.url}}/assets/images/ExpensifyHelp-CreateExpense-2.png){:width="100%"}
-![Click Scan]({{site.url}}/assets/images/ExpensifyHelp-CreateExpense-3.png){:width="100%"}
-![Enter workspace or individual's name]({{site.url}}/assets/images/ExpensifyHelp-CreateExpense-4.png){:width="100%"}
+![Click Global Create]({{site.url}}/assets/images/ExpensifyHelp-CreateExpenseUpdate-1.png){:width="100%"}
+![Click Create Expense]({{site.url}}/assets/images/ExpensifyHelp-CreateExpenseUpdate-2.png){:width="100%"}
+![Click Scan]({{site.url}}/assets/images/ExpensifyHelp-CreateExpenseUpdate-3.png){:width="100%"}
+![Enter workspace or individual's name]({{site.url}}/assets/images/ExpensifyHelp-CreateExpenseUpdate-4.png){:width="100%"}
{% include info.html %}
You can also forward receipts to receipts@expensify.com using your primary or secondary email address. SmartScan will automatically extract all the details from the receipt and add them to your expenses.
diff --git a/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-1.png b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-1.png
new file mode 100644
index 000000000000..18318f782466
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-1.png differ
diff --git a/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-2.png b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-2.png
new file mode 100644
index 000000000000..641c32a6a6b6
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-2.png differ
diff --git a/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-3.png b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-3.png
new file mode 100644
index 000000000000..48c6f12fb75c
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-3.png differ
diff --git a/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-4.png b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-4.png
new file mode 100644
index 000000000000..5f8af1e46ac4
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-4.png differ
diff --git a/docs/assets/images/OldDot - Create & Pay Bills 1.png b/docs/assets/images/OldDot - Create & Pay Bills 1.png
new file mode 100644
index 000000000000..a880e012408a
Binary files /dev/null and b/docs/assets/images/OldDot - Create & Pay Bills 1.png differ
diff --git a/docs/assets/images/OldDot - Create & Pay Bills 2.png b/docs/assets/images/OldDot - Create & Pay Bills 2.png
new file mode 100644
index 000000000000..ce022a95c6a1
Binary files /dev/null and b/docs/assets/images/OldDot - Create & Pay Bills 2.png differ
diff --git a/docs/assets/images/OldDot - Create & Pay Bills 3.png b/docs/assets/images/OldDot - Create & Pay Bills 3.png
new file mode 100644
index 000000000000..071bcc997934
Binary files /dev/null and b/docs/assets/images/OldDot - Create & Pay Bills 3.png differ
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 1e81fdedcaee..3374f9c36b3f 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 9.0.77
+ 9.0.78
CFBundleSignature
????
CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 9.0.77.6
+ 9.0.78.2
FullStory
OrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 2291b6e19e37..6f72c68b009d 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 9.0.77
+ 9.0.78
CFBundleSignature
????
CFBundleVersion
- 9.0.77.6
+ 9.0.78.2
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index f94a9a34f558..328278e16cf3 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName
$(PRODUCT_NAME)
CFBundleShortVersionString
- 9.0.77
+ 9.0.78
CFBundleVersion
- 9.0.77.6
+ 9.0.78.2
NSExtension
NSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index 51773c06935e..ae031453f883 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.0.77-6",
+ "version": "9.0.78-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.77-6",
+ "version": "9.0.78-2",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index c3f6d8e730d8..3b5a25abb224 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.77-6",
+ "version": "9.0.78-2",
"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 52904e0a06c4..cc824b78fa4c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -18,6 +18,7 @@ import KeyboardProvider from './components/KeyboardProvider';
import {LocaleContextProvider} from './components/LocaleContextProvider';
import OnyxProvider from './components/OnyxProvider';
import PopoverContextProvider from './components/PopoverProvider';
+import {ProductTrainingContextProvider} from './components/ProductTrainingContext';
import SafeArea from './components/SafeArea';
import ScrollOffsetContextProvider from './components/ScrollOffsetContextProvider';
import {SearchRouterContextProvider} from './components/Search/SearchRouter/SearchRouterContext';
@@ -95,6 +96,7 @@ function App({url}: AppProps) {
VideoPopoverMenuContextProvider,
KeyboardProvider,
SearchRouterContextProvider,
+ ProductTrainingContextProvider,
]}
>
diff --git a/src/CONST.ts b/src/CONST.ts
index e317c19d96d2..cf9e5d8a2886 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -1152,6 +1152,7 @@ const CONST = {
UPDATE_TIME_RATE: 'POLICYCHANGELOG_UPDATE_TIME_RATE',
LEAVE_POLICY: 'POLICYCHANGELOG_LEAVE_POLICY',
CORPORATE_UPGRADE: 'POLICYCHANGELOG_CORPORATE_UPGRADE',
+ TEAM_DOWNGRADE: 'POLICYCHANGELOG_TEAM_DOWNGRADE',
},
ROOM_CHANGE_LOG: {
INVITE_TO_ROOM: 'INVITETOROOM',
@@ -1335,6 +1336,9 @@ const CONST = {
SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300,
RESIZE_DEBOUNCE_TIME: 100,
UNREAD_UPDATE_DEBOUNCE_TIME: 300,
+ SEARCH_CONVERT_SEARCH_VALUES: 'search_convert_search_values',
+ SEARCH_MAKE_TREE: 'search_make_tree',
+ SEARCH_BUILD_TREE: 'search_build_tree',
SEARCH_FILTER_OPTIONS: 'search_filter_options',
USE_DEBOUNCED_STATE_DELAY: 300,
LIST_SCROLLING_DEBOUNCE_TIME: 200,
@@ -6440,6 +6444,17 @@ const CONST = {
},
MIGRATED_USER_WELCOME_MODAL: 'migratedUserWelcomeModal',
+
+ PRODUCT_TRAINING_TOOLTIP_NAMES: {
+ CONCEIRGE_LHN_GBR: 'conciergeLHNGBR',
+ RENAME_SAVED_SEARCH: 'renameSavedSearch',
+ QUICK_ACTION_BUTTON: 'quickActionButton',
+ WORKSAPCE_CHAT_CREATE: 'workspaceChatCreate',
+ SEARCH_FILTER_BUTTON_TOOLTIP: 'filterButtonTooltip',
+ BOTTOM_NAV_INBOX_TOOLTIP: 'bottomNavInboxTooltip',
+ LHN_WORKSPACE_CHAT_TOOLTIP: 'workspaceChatLHNTooltip',
+ GLOBAL_CREATE_TOOLTIP: 'globalCreateTooltip',
+ },
} as const;
type Country = keyof typeof CONST.ALL_COUNTRIES;
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index a43f1622ec9a..026ab2310622 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -117,9 +117,6 @@ const ONYXKEYS = {
/** NVP keys */
- /** Boolean flag only true when first set */
- NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER: 'nvp_isFirstTimeNewExpensifyUser',
-
/** This NVP contains list of at most 5 recent attendees */
NVP_RECENT_ATTENDEES: 'nvp_expensify_recentAttendees',
@@ -222,18 +219,9 @@ const ONYXKEYS = {
/** The end date (epoch timestamp) of the workspace owner’s grace period after the free trial ends. */
NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END: 'nvp_private_billingGracePeriodEnd',
- /** The NVP containing all information related to educational tooltip in workspace chat */
- NVP_WORKSPACE_TOOLTIP: 'workspaceTooltip',
-
/** The NVP containing the target url to navigate to when deleting a transaction */
NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL: 'nvp_deleteTransactionNavigateBackURL',
- /** Whether to show save search rename tooltip */
- SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP: 'shouldShowSavedSearchRenameTooltip',
-
- /** Whether to hide gbr tooltip */
- NVP_SHOULD_HIDE_GBR_TOOLTIP: 'nvp_should_hide_gbr_tooltip',
-
/** Does this user have push notifications enabled for this device? */
PUSH_NOTIFICATIONS_ENABLED: 'pushNotificationsEnabled',
@@ -888,10 +876,8 @@ type OnyxCollectionValuesMapping = {
type OnyxValuesMapping = {
[ONYXKEYS.ACCOUNT]: OnyxTypes.Account;
[ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID]: string;
- [ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER]: boolean;
- // NVP_ONBOARDING is an array for old users.
- [ONYXKEYS.NVP_ONBOARDING]: Onboarding | [];
+ [ONYXKEYS.NVP_ONBOARDING]: Onboarding;
// ONYXKEYS.NVP_TRYNEWDOT is HybridApp onboarding data
[ONYXKEYS.NVP_TRYNEWDOT]: OnyxTypes.TryNewDot;
@@ -1031,9 +1017,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.NVP_BILLING_FUND_ID]: number;
[ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: number;
[ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: number;
- [ONYXKEYS.NVP_WORKSPACE_TOOLTIP]: OnyxTypes.WorkspaceTooltip;
[ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL]: string | undefined;
- [ONYXKEYS.NVP_SHOULD_HIDE_GBR_TOOLTIP]: boolean;
[ONYXKEYS.NVP_PRIVATE_CANCELLATION_DETAILS]: OnyxTypes.CancellationDetails[];
[ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE]: string;
[ONYXKEYS.APPROVAL_WORKFLOW]: OnyxTypes.ApprovalWorkflowOnyx;
@@ -1041,7 +1025,6 @@ type OnyxValuesMapping = {
[ONYXKEYS.LAST_ROUTE]: string;
[ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY]: boolean | undefined;
[ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean;
- [ONYXKEYS.SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP]: boolean;
[ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record;
[ONYXKEYS.CONCIERGE_REPORT_ID]: string;
[ONYXKEYS.PRESERVED_USER_SESSION]: OnyxTypes.Session;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 58d28a46a7b8..909f847fd75d 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -702,11 +702,11 @@ const ROUTES = {
},
WORKSPACE_INVITE: {
route: 'settings/workspaces/:policyID/invite',
- getRoute: (policyID: string) => `settings/workspaces/${policyID}/invite` as const,
+ getRoute: (policyID: string, backTo?: string) => `${getUrlWithBackToParam(`settings/workspaces/${policyID}/invite`, backTo)}` as const,
},
WORKSPACE_INVITE_MESSAGE: {
route: 'settings/workspaces/:policyID/invite-message',
- getRoute: (policyID: string) => `settings/workspaces/${policyID}/invite-message` as const,
+ getRoute: (policyID: string, backTo?: string) => `${getUrlWithBackToParam(`settings/workspaces/${policyID}/invite-message`, backTo)}` as const,
},
WORKSPACE_PROFILE: {
route: 'settings/workspaces/:policyID/profile',
diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx
index 0cddb32f5aeb..a02767d24c87 100644
--- a/src/components/Composer/implementation/index.native.tsx
+++ b/src/components/Composer/implementation/index.native.tsx
@@ -1,7 +1,7 @@
import type {MarkdownStyle} from '@expensify/react-native-live-markdown';
import mimeDb from 'mime-db';
import type {ForwardedRef} from 'react';
-import React, {useCallback, useEffect, useMemo, useRef} from 'react';
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import type {NativeSyntheticEvent, TextInput, TextInputChangeEventData, TextInputPasteEventData} from 'react-native';
import {StyleSheet} from 'react-native';
import type {FileObject} from '@components/AttachmentModal';
@@ -9,6 +9,7 @@ import type {ComposerProps} from '@components/Composer/types';
import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput';
import RNMarkdownTextInput from '@components/RNMarkdownTextInput';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
+import useKeyboardState from '@hooks/useKeyboardState';
import useMarkdownStyle from '@hooks/useMarkdownStyle';
import useResetComposerFocus from '@hooks/useResetComposerFocus';
import useStyleUtils from '@hooks/useStyleUtils';
@@ -37,6 +38,7 @@ function Composer(
selection,
value,
isGroupPolicyReport = false,
+ showSoftInputOnFocus = true,
...props
}: ComposerProps,
ref: ForwardedRef,
@@ -49,7 +51,11 @@ function Composer(
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
+ const [contextMenuHidden, setContextMenuHidden] = useState(true);
+
const {inputCallbackRef, inputRef: autoFocusInputRef} = useAutoFocusInput();
+ const keyboardState = useKeyboardState();
+ const isKeyboardShown = keyboardState?.isKeyboardShown ?? false;
useEffect(() => {
if (autoFocus === !!autoFocusInputRef.current) {
@@ -58,6 +64,13 @@ function Composer(
inputCallbackRef(autoFocus ? textInput.current : null);
}, [autoFocus, inputCallbackRef, autoFocusInputRef]);
+ useEffect(() => {
+ if (!showSoftInputOnFocus || !isKeyboardShown) {
+ return;
+ }
+ setContextMenuHidden(false);
+ }, [showSoftInputOnFocus, isKeyboardShown]);
+
useEffect(() => {
if (!textInput.current || !textInput.current.setSelection || !selection || isComposerFullSize) {
return;
@@ -158,6 +171,8 @@ function Composer(
props?.onBlur?.(e);
}}
onClear={onClear}
+ showSoftInputOnFocus={showSoftInputOnFocus}
+ contextMenuHidden={contextMenuHidden}
/>
);
}
diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx
index 5af76a2406b5..9171132964f6 100755
--- a/src/components/Composer/implementation/index.tsx
+++ b/src/components/Composer/implementation/index.tsx
@@ -5,7 +5,7 @@ import type {BaseSyntheticEvent, ForwardedRef} from 'react';
import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
// eslint-disable-next-line no-restricted-imports
import type {NativeSyntheticEvent, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData} from 'react-native';
-import {DeviceEventEmitter, StyleSheet} from 'react-native';
+import {DeviceEventEmitter, InteractionManager, StyleSheet} from 'react-native';
import type {ComposerProps} from '@components/Composer/types';
import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput';
import RNMarkdownTextInput from '@components/RNMarkdownTextInput';
@@ -50,6 +50,7 @@ function Composer(
isComposerFullSize = false,
shouldContainScroll = true,
isGroupPolicyReport = false,
+ showSoftInputOnFocus = true,
...props
}: ComposerProps,
ref: ForwardedRef,
@@ -74,6 +75,11 @@ function Composer(
});
const [hasMultipleLines, setHasMultipleLines] = useState(false);
const [isRendered, setIsRendered] = useState(false);
+
+ // On mobile safari, the cursor will move from right to left with inputMode set to none during report transition
+ // To avoid that we should hide the cursor util the transition is finished
+ const [shouldTransparentCursor, setShouldTransparentCursor] = useState(!showSoftInputOnFocus && Browser.isMobileSafari());
+
const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? '');
const [prevScroll, setPrevScroll] = useState();
const [prevHeight, setPrevHeight] = useState();
@@ -260,6 +266,15 @@ function Composer(
setIsRendered(true);
}, []);
+ useEffect(() => {
+ if (!shouldTransparentCursor) {
+ return;
+ }
+ InteractionManager.runAfterInteractions(() => {
+ setShouldTransparentCursor(false);
+ });
+ }, [shouldTransparentCursor]);
+
const clear = useCallback(() => {
if (!textInput.current) {
return;
@@ -347,11 +362,12 @@ function Composer(
placeholderTextColor={theme.placeholderText}
ref={(el) => (textInput.current = el)}
selection={selection}
- style={[inputStyleMemo]}
+ style={[inputStyleMemo, shouldTransparentCursor ? {caretColor: 'transparent'} : undefined]}
markdownStyle={markdownStyle}
value={value}
defaultValue={defaultValue}
autoFocus={autoFocus}
+ inputMode={showSoftInputOnFocus ? 'text' : 'none'}
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...props}
onSelectionChange={addCursorPositionToSelectionChange}
diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts
index 3df5508f1dd7..6ea3bdb2f824 100644
--- a/src/components/Composer/types.ts
+++ b/src/components/Composer/types.ts
@@ -68,6 +68,9 @@ type ComposerProps = Omit & {
/** Indicates whether the composer is in a group policy report. Used for disabling report mentioning style in markdown input */
isGroupPolicyReport?: boolean;
+
+ /** Whether to show the keyboard on focus */
+ showSoftInputOnFocus?: boolean;
};
export type {TextSelection, ComposerProps, CustomSelectionChangeEvent};
diff --git a/src/components/ConnectToNetSuiteFlow/index.tsx b/src/components/ConnectToNetSuiteFlow/index.tsx
index 1d33eb07df4f..7957896d4006 100644
--- a/src/components/ConnectToNetSuiteFlow/index.tsx
+++ b/src/components/ConnectToNetSuiteFlow/index.tsx
@@ -59,10 +59,11 @@ function ConnectToNetSuiteFlow({policyID}: ConnectToNetSuiteFlowProps) {
if (threeDotsMenuContainerRef) {
if (!shouldUseNarrowLayout) {
threeDotsMenuContainerRef.current?.measureInWindow((x, y, width, height) => {
- setReuseConnectionPopoverPosition({
- horizontal: x + width,
- vertical: y + height,
- });
+ const horizontal = x + width;
+ const vertical = y + height;
+ if (reuseConnectionPopoverPosition.horizontal !== horizontal || reuseConnectionPopoverPosition.vertical !== vertical) {
+ setReuseConnectionPopoverPosition({horizontal, vertical});
+ }
});
}
diff --git a/src/components/ConnectToSageIntacctFlow/index.tsx b/src/components/ConnectToSageIntacctFlow/index.tsx
index f93fce9c668a..807082365042 100644
--- a/src/components/ConnectToSageIntacctFlow/index.tsx
+++ b/src/components/ConnectToSageIntacctFlow/index.tsx
@@ -64,10 +64,11 @@ function ConnectToSageIntacctFlow({policyID}: ConnectToSageIntacctFlowProps) {
if (threeDotsMenuContainerRef) {
if (!shouldUseNarrowLayout) {
threeDotsMenuContainerRef.current?.measureInWindow((x, y, width, height) => {
- setReuseConnectionPopoverPosition({
- horizontal: x + width,
- vertical: y + height,
- });
+ const horizontal = x + width;
+ const vertical = y + height;
+ if (reuseConnectionPopoverPosition.horizontal !== horizontal || reuseConnectionPopoverPosition.vertical !== vertical) {
+ setReuseConnectionPopoverPosition({horizontal, vertical});
+ }
});
}
diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx
index 3c831301db8b..8ba640956bf3 100644
--- a/src/components/FloatingActionButton.tsx
+++ b/src/components/FloatingActionButton.tsx
@@ -3,12 +3,20 @@ import React, {forwardRef, useEffect, useRef} from 'react';
// eslint-disable-next-line no-restricted-imports
import type {GestureResponderEvent, Role, Text, View} from 'react-native';
import {Platform} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
import Animated, {createAnimatedPropAdapter, Easing, interpolateColor, processColor, useAnimatedProps, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import Svg, {Path} from 'react-native-svg';
+import useBottomTabIsFocused from '@hooks/useBottomTabIsFocused';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import getPlatform from '@libs/getPlatform';
import variables from '@styles/variables';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
import {PressableWithoutFeedback} from './Pressable';
+import {useProductTrainingContext} from './ProductTrainingContext';
+import EducationalTooltip from './Tooltip/EducationalTooltip';
const AnimatedPath = Animated.createAnimatedComponent(Path);
AnimatedPath.displayName = 'AnimatedPath';
@@ -56,6 +64,15 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: Flo
const styles = useThemeStyles();
const borderRadius = styles.floatingActionButton.borderRadius;
const fabPressable = useRef(null);
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const platform = getPlatform();
+ const isNarrowScreenOnWeb = shouldUseNarrowLayout && platform === CONST.PLATFORM.WEB;
+ const isFocused = useBottomTabIsFocused();
+ const [isSidebarLoaded] = useOnyx(ONYXKEYS.IS_SIDEBAR_LOADED, {initialValue: false});
+ const {renderProductTrainingTooltip, shouldShowProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext(
+ CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.GLOBAL_CREATE_TOOLTIP,
+ isFocused && isSidebarLoaded,
+ );
const sharedValue = useSharedValue(isActive ? 1 : 0);
const buttonRef = ref;
@@ -97,32 +114,45 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: Flo
};
return (
- {
- fabPressable.current = el ?? null;
- if (buttonRef && 'current' in buttonRef) {
- buttonRef.current = el ?? null;
- }
+ {}}
- role={role}
- shouldUseHapticsOnLongPress={false}
+ shouldUseOverlay
+ shiftHorizontal={isNarrowScreenOnWeb ? 0 : variables.fabTooltipShiftHorizontal}
+ renderTooltipContent={renderProductTrainingTooltip}
+ wrapperStyle={styles.productTrainingTooltipWrapper}
+ onHideTooltip={hideProductTrainingTooltip}
>
-
-
-
-
+ {
+ fabPressable.current = el ?? null;
+ if (buttonRef && 'current' in buttonRef) {
+ buttonRef.current = el ?? null;
+ }
+ }}
+ style={[styles.h100, styles.bottomTabBarItem]}
+ accessibilityLabel={accessibilityLabel}
+ onPress={toggleFabAction}
+ onLongPress={() => {}}
+ role={role}
+ shouldUseHapticsOnLongPress={false}
+ >
+
+
+
+
+
);
}
diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx
index b4d097e90994..363fe238e9f4 100755
--- a/src/components/HeaderWithBackButton/index.tsx
+++ b/src/components/HeaderWithBackButton/index.tsx
@@ -182,7 +182,7 @@ function HeaderWithBackButton({
width={iconWidth ?? variables.iconHeader}
height={iconHeight ?? variables.iconHeader}
additionalStyles={[styles.mr2, iconStyles]}
- fill={iconFill ?? theme.icon}
+ fill={iconFill}
/>
)}
{!!policyAvatar && (
diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts
index 4093b44743fe..02a6843dc11f 100644
--- a/src/components/Icon/Expensicons.ts
+++ b/src/components/Icon/Expensicons.ts
@@ -32,6 +32,7 @@ import Box from '@assets/images/box.svg';
import Briefcase from '@assets/images/briefcase.svg';
import Bug from '@assets/images/bug.svg';
import Building from '@assets/images/building.svg';
+import Buildings from '@assets/images/buildings.svg';
import CalendarSolid from '@assets/images/calendar-solid.svg';
import Calendar from '@assets/images/calendar.svg';
import Camera from '@assets/images/camera.svg';
@@ -235,6 +236,7 @@ export {
Briefcase,
Bug,
Building,
+ Buildings,
Calendar,
Camera,
Car,
diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts
index 4379142619ff..0debd4585e7b 100644
--- a/src/components/Icon/Illustrations.ts
+++ b/src/components/Icon/Illustrations.ts
@@ -74,6 +74,8 @@ import BankArrow from '@assets/images/simple-illustrations/simple-illustration__
import BigRocket from '@assets/images/simple-illustrations/simple-illustration__bigrocket.svg';
import PinkBill from '@assets/images/simple-illustrations/simple-illustration__bill.svg';
import Binoculars from '@assets/images/simple-illustrations/simple-illustration__binoculars.svg';
+import Building from '@assets/images/simple-illustrations/simple-illustration__building.svg';
+import Buildings from '@assets/images/simple-illustrations/simple-illustration__buildings.svg';
import CarIce from '@assets/images/simple-illustrations/simple-illustration__car-ice.svg';
import Car from '@assets/images/simple-illustrations/simple-illustration__car.svg';
import ChatBubbles from '@assets/images/simple-illustrations/simple-illustration__chatbubbles.svg';
@@ -228,6 +230,8 @@ export {
PendingBank,
ThreeLeggedLaptopWoman,
House,
+ Building,
+ Buildings,
Alert,
TeachersUnite,
Abacus,
diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx
index c423d3101d92..efdd9659c845 100644
--- a/src/components/LHNOptionsList/OptionRowLHN.tsx
+++ b/src/components/LHNOptionsList/OptionRowLHN.tsx
@@ -1,5 +1,5 @@
import {useFocusEffect} from '@react-navigation/native';
-import React, {useCallback, useRef, useState} from 'react';
+import React, {useCallback, useMemo, useRef, useState} from 'react';
import type {GestureResponderEvent, ViewStyle} from 'react-native';
import {StyleSheet, View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
@@ -11,6 +11,7 @@ import MultipleAvatars from '@components/MultipleAvatars';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import {useSession} from '@components/OnyxProvider';
import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction';
+import {useProductTrainingContext} from '@components/ProductTrainingContext';
import SubscriptAvatar from '@components/SubscriptAvatar';
import Text from '@components/Text';
import Tooltip from '@components/Tooltip';
@@ -22,7 +23,6 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import DateUtils from '@libs/DateUtils';
import DomUtils from '@libs/DomUtils';
-import {hasCompletedGuidedSetupFlowSelector} from '@libs/onboardingSelectors';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import Parser from '@libs/Parser';
import Performance from '@libs/Performance';
@@ -32,7 +32,6 @@ import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportA
import FreeTrial from '@pages/settings/Subscription/FreeTrial';
import variables from '@styles/variables';
import Timing from '@userActions/Timing';
-import * as User from '@userActions/User';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
@@ -48,18 +47,21 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${optionItem?.reportID || -1}`);
- const [isFirstTimeNewExpensifyUser] = useOnyx(ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER);
- const [isOnboardingCompleted = true] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
- selector: hasCompletedGuidedSetupFlowSelector,
- });
+ const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
+ const isActiveWorkspaceChat = ReportUtils.isPolicyExpenseChat(report) && report?.isOwnPolicyExpenseChat && activePolicyID === report?.policyID;
const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
const session = useSession();
-
- // Guides are assigned for the MANAGE_TEAM onboarding action, except for emails that have a '+'.
const isOnboardingGuideAssigned = introSelected?.choice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM && !session?.email?.includes('+');
- const shouldShowToooltipOnThisReport = isOnboardingGuideAssigned ? ReportUtils.isAdminRoom(report) : ReportUtils.isConciergeChatReport(report);
- const [shouldHideGBRTooltip] = useOnyx(ONYXKEYS.NVP_SHOULD_HIDE_GBR_TOOLTIP, {initialValue: true});
+ const shouldShowGetStartedTooltip = isOnboardingGuideAssigned ? ReportUtils.isAdminRoom(report) : ReportUtils.isConciergeChatReport(report);
+
+ const {tooltipToRender, shouldShowTooltip} = useMemo(() => {
+ const tooltip = shouldShowGetStartedTooltip ? CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONCEIRGE_LHN_GBR : CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.LHN_WORKSPACE_CHAT_TOOLTIP;
+
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ return {tooltipToRender: tooltip, shouldShowTooltip: shouldUseNarrowLayout ? isScreenFocused : true};
+ }, [shouldShowGetStartedTooltip, isScreenFocused, shouldUseNarrowLayout]);
+ const {shouldShowProductTrainingTooltip, renderProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext(tooltipToRender, shouldShowTooltip);
const {translate} = useLocalize();
const [isContextMenuActive, setIsContextMenuActive] = useState(false);
@@ -72,30 +74,6 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti
}, []),
);
- const renderGBRTooltip = useCallback(
- () => (
-
-
- {translate('sidebarScreen.tooltip')}
-
- ),
- [
- styles.alignItemsCenter,
- styles.flexRow,
- styles.justifyContentCenter,
- styles.flexWrap,
- styles.textAlignCenter,
- styles.gap1,
- styles.quickActionTooltipSubtitle,
- theme.tooltipHighlightText,
- translate,
- ],
- );
-
const isInFocusMode = viewMode === CONST.OPTION_MODE.COMPACT;
const sidebarInnerRowStyle = StyleSheet.flatten(
isInFocusMode
@@ -180,17 +158,18 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti
needsOffscreenAlphaCompositing
>
diff --git a/src/components/ProductTrainingContext/TOOLTIPS.ts b/src/components/ProductTrainingContext/TOOLTIPS.ts
new file mode 100644
index 000000000000..dc2a761a4903
--- /dev/null
+++ b/src/components/ProductTrainingContext/TOOLTIPS.ts
@@ -0,0 +1,118 @@
+import type {ValueOf} from 'type-fest';
+import {dismissProductTraining} from '@libs/actions/Welcome';
+import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
+
+const {
+ CONCEIRGE_LHN_GBR,
+ RENAME_SAVED_SEARCH,
+ WORKSAPCE_CHAT_CREATE,
+ QUICK_ACTION_BUTTON,
+ SEARCH_FILTER_BUTTON_TOOLTIP,
+ BOTTOM_NAV_INBOX_TOOLTIP,
+ LHN_WORKSPACE_CHAT_TOOLTIP,
+ GLOBAL_CREATE_TOOLTIP,
+} = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES;
+
+type ProductTrainingTooltipName = ValueOf;
+
+type ShouldShowConditionProps = {
+ shouldUseNarrowLayout?: boolean;
+};
+
+type TooltipData = {
+ content: Array<{text: TranslationPaths; isBold: boolean}>;
+ onHideTooltip: () => void;
+ name: ProductTrainingTooltipName;
+ priority: number;
+ shouldShow: (props: ShouldShowConditionProps) => boolean;
+};
+
+const TOOLTIPS: Record = {
+ [CONCEIRGE_LHN_GBR]: {
+ content: [
+ {text: 'productTrainingTooltip.conciergeLHNGBR.part1', isBold: false},
+ {text: 'productTrainingTooltip.conciergeLHNGBR.part2', isBold: true},
+ ],
+ onHideTooltip: () => dismissProductTraining(CONCEIRGE_LHN_GBR),
+ name: CONCEIRGE_LHN_GBR,
+ priority: 1300,
+ shouldShow: ({shouldUseNarrowLayout}) => !!shouldUseNarrowLayout,
+ },
+ [RENAME_SAVED_SEARCH]: {
+ content: [
+ {text: 'productTrainingTooltip.saveSearchTooltip.part1', isBold: true},
+ {text: 'productTrainingTooltip.saveSearchTooltip.part2', isBold: false},
+ ],
+ onHideTooltip: () => dismissProductTraining(RENAME_SAVED_SEARCH),
+ name: RENAME_SAVED_SEARCH,
+ priority: 1250,
+ shouldShow: ({shouldUseNarrowLayout}) => !shouldUseNarrowLayout,
+ },
+ [GLOBAL_CREATE_TOOLTIP]: {
+ content: [
+ {text: 'productTrainingTooltip.globalCreateTooltip.part1', isBold: true},
+ {text: 'productTrainingTooltip.globalCreateTooltip.part2', isBold: false},
+ {text: 'productTrainingTooltip.globalCreateTooltip.part3', isBold: false},
+ ],
+ onHideTooltip: () => dismissProductTraining(GLOBAL_CREATE_TOOLTIP),
+ name: GLOBAL_CREATE_TOOLTIP,
+ priority: 1200,
+ shouldShow: () => true,
+ },
+ [QUICK_ACTION_BUTTON]: {
+ content: [
+ {text: 'productTrainingTooltip.quickActionButton.part1', isBold: true},
+ {text: 'productTrainingTooltip.quickActionButton.part2', isBold: false},
+ ],
+ onHideTooltip: () => dismissProductTraining(QUICK_ACTION_BUTTON),
+ name: QUICK_ACTION_BUTTON,
+ priority: 1150,
+ shouldShow: () => true,
+ },
+ [WORKSAPCE_CHAT_CREATE]: {
+ content: [
+ {text: 'productTrainingTooltip.workspaceChatCreate.part1', isBold: true},
+ {text: 'productTrainingTooltip.workspaceChatCreate.part2', isBold: false},
+ ],
+ onHideTooltip: () => dismissProductTraining(WORKSAPCE_CHAT_CREATE),
+ name: WORKSAPCE_CHAT_CREATE,
+ priority: 1100,
+ shouldShow: () => true,
+ },
+ [SEARCH_FILTER_BUTTON_TOOLTIP]: {
+ content: [
+ {text: 'productTrainingTooltip.searchFilterButtonTooltip.part1', isBold: true},
+ {text: 'productTrainingTooltip.searchFilterButtonTooltip.part2', isBold: false},
+ ],
+ onHideTooltip: () => dismissProductTraining(SEARCH_FILTER_BUTTON_TOOLTIP),
+ name: SEARCH_FILTER_BUTTON_TOOLTIP,
+ priority: 1000,
+ shouldShow: () => true,
+ },
+ [BOTTOM_NAV_INBOX_TOOLTIP]: {
+ content: [
+ {text: 'productTrainingTooltip.bottomNavInboxTooltip.part1', isBold: true},
+ {text: 'productTrainingTooltip.bottomNavInboxTooltip.part2', isBold: false},
+ {text: 'productTrainingTooltip.bottomNavInboxTooltip.part3', isBold: false},
+ ],
+ onHideTooltip: () => dismissProductTraining(BOTTOM_NAV_INBOX_TOOLTIP),
+ name: BOTTOM_NAV_INBOX_TOOLTIP,
+ priority: 900,
+ shouldShow: () => true,
+ },
+ [LHN_WORKSPACE_CHAT_TOOLTIP]: {
+ content: [
+ {text: 'productTrainingTooltip.workspaceChatTooltip.part1', isBold: true},
+ {text: 'productTrainingTooltip.workspaceChatTooltip.part2', isBold: false},
+ {text: 'productTrainingTooltip.workspaceChatTooltip.part3', isBold: false},
+ ],
+ onHideTooltip: () => dismissProductTraining(LHN_WORKSPACE_CHAT_TOOLTIP),
+ name: LHN_WORKSPACE_CHAT_TOOLTIP,
+ priority: 800,
+ shouldShow: () => true,
+ },
+};
+
+export default TOOLTIPS;
+export type {ProductTrainingTooltipName};
diff --git a/src/components/ProductTrainingContext/index.tsx b/src/components/ProductTrainingContext/index.tsx
new file mode 100644
index 000000000000..7cfcf4d3bfa7
--- /dev/null
+++ b/src/components/ProductTrainingContext/index.tsx
@@ -0,0 +1,224 @@
+import React, {createContext, useCallback, useContext, useEffect, useMemo, useState} from 'react';
+import {View} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
+import Icon from '@components/Icon';
+import * as Expensicons from '@components/Icon/Expensicons';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {hasCompletedGuidedSetupFlowSelector} from '@libs/onboardingSelectors';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type ChildrenProps from '@src/types/utils/ChildrenProps';
+import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
+import type {ProductTrainingTooltipName} from './TOOLTIPS';
+import TOOLTIPS from './TOOLTIPS';
+
+type ProductTrainingContextType = {
+ shouldRenderTooltip: (tooltipName: ProductTrainingTooltipName) => boolean;
+ registerTooltip: (tooltipName: ProductTrainingTooltipName) => void;
+ unregisterTooltip: (tooltipName: ProductTrainingTooltipName) => void;
+};
+
+const ProductTrainingContext = createContext({
+ shouldRenderTooltip: () => false,
+ registerTooltip: () => {},
+ unregisterTooltip: () => {},
+});
+
+function ProductTrainingContextProvider({children}: ChildrenProps) {
+ const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRYNEWDOT);
+ const hasBeenAddedToNudgeMigration = !!tryNewDot?.nudgeMigration?.timestamp;
+ const [isOnboardingCompleted = true, isOnboardingCompletedMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
+ selector: hasCompletedGuidedSetupFlowSelector,
+ });
+ const [dismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING);
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
+
+ const [activeTooltips, setActiveTooltips] = useState>(new Set());
+
+ const unregisterTooltip = useCallback(
+ (tooltipName: ProductTrainingTooltipName) => {
+ setActiveTooltips((prev) => {
+ const next = new Set(prev);
+ next.delete(tooltipName);
+ return next;
+ });
+ },
+ [setActiveTooltips],
+ );
+
+ const determineVisibleTooltip = useCallback(() => {
+ if (activeTooltips.size === 0) {
+ return null;
+ }
+
+ const sortedTooltips = Array.from(activeTooltips)
+ .map((name) => ({
+ name,
+ priority: TOOLTIPS[name]?.priority ?? 0,
+ }))
+ .sort((a, b) => b.priority - a.priority);
+
+ const highestPriorityTooltip = sortedTooltips.at(0);
+
+ if (!highestPriorityTooltip) {
+ return null;
+ }
+
+ return highestPriorityTooltip.name;
+ }, [activeTooltips]);
+
+ const shouldTooltipBeVisible = useCallback(
+ (tooltipName: ProductTrainingTooltipName) => {
+ if (isLoadingOnyxValue(isOnboardingCompletedMetadata)) {
+ return false;
+ }
+
+ const isDismissed = !!dismissedProductTraining?.[tooltipName];
+
+ if (isDismissed) {
+ return false;
+ }
+ const tooltipConfig = TOOLTIPS[tooltipName];
+
+ // if hasBeenAddedToNudgeMigration is true, and welcome modal is not dismissed, don't show tooltip
+ if (hasBeenAddedToNudgeMigration && !dismissedProductTraining?.[CONST.MIGRATED_USER_WELCOME_MODAL]) {
+ return false;
+ }
+ if (isOnboardingCompleted === false) {
+ return false;
+ }
+
+ return tooltipConfig.shouldShow({
+ shouldUseNarrowLayout,
+ });
+ },
+ [dismissedProductTraining, hasBeenAddedToNudgeMigration, isOnboardingCompleted, isOnboardingCompletedMetadata, shouldUseNarrowLayout],
+ );
+
+ const registerTooltip = useCallback(
+ (tooltipName: ProductTrainingTooltipName) => {
+ const shouldRegister = shouldTooltipBeVisible(tooltipName);
+ if (!shouldRegister) {
+ return;
+ }
+ setActiveTooltips((prev) => new Set([...prev, tooltipName]));
+ },
+ [shouldTooltipBeVisible],
+ );
+
+ const shouldRenderTooltip = useCallback(
+ (tooltipName: ProductTrainingTooltipName) => {
+ // First check base conditions
+ const shouldShow = shouldTooltipBeVisible(tooltipName);
+ if (!shouldShow) {
+ return false;
+ }
+ const visibleTooltip = determineVisibleTooltip();
+
+ // If this is the highest priority visible tooltip, show it
+ if (tooltipName === visibleTooltip) {
+ return true;
+ }
+
+ return false;
+ },
+ [shouldTooltipBeVisible, determineVisibleTooltip],
+ );
+
+ const contextValue = useMemo(
+ () => ({
+ shouldRenderTooltip,
+ registerTooltip,
+ unregisterTooltip,
+ }),
+ [shouldRenderTooltip, registerTooltip, unregisterTooltip],
+ );
+
+ return {children};
+}
+
+const useProductTrainingContext = (tooltipName: ProductTrainingTooltipName, shouldShow = true) => {
+ const context = useContext(ProductTrainingContext);
+ const styles = useThemeStyles();
+ const theme = useTheme();
+ const {translate} = useLocalize();
+
+ if (!context) {
+ throw new Error('useProductTourContext must be used within a ProductTourProvider');
+ }
+
+ const {shouldRenderTooltip, registerTooltip, unregisterTooltip} = context;
+
+ useEffect(() => {
+ if (shouldShow) {
+ registerTooltip(tooltipName);
+ return () => {
+ unregisterTooltip(tooltipName);
+ };
+ }
+ return () => {};
+ }, [tooltipName, registerTooltip, unregisterTooltip, shouldShow]);
+
+ const renderProductTrainingTooltip = useCallback(() => {
+ const tooltip = TOOLTIPS[tooltipName];
+ return (
+
+
+
+ {tooltip.content.map(({text, isBold}) => {
+ const translatedText = translate(text);
+ return (
+
+ {translatedText}
+
+ );
+ })}
+
+
+ );
+ }, [
+ styles.alignItemsCenter,
+ styles.flexRow,
+ styles.flexWrap,
+ styles.gap3,
+ styles.justifyContentCenter,
+ styles.mw100,
+ styles.p2,
+ styles.productTrainingTooltipText,
+ styles.textAlignCenter,
+ styles.textBold,
+ styles.textWrap,
+ theme.tooltipHighlightText,
+ tooltipName,
+ translate,
+ ]);
+
+ const shouldShowProductTrainingTooltip = useMemo(() => {
+ return shouldRenderTooltip(tooltipName);
+ }, [shouldRenderTooltip, tooltipName]);
+
+ const hideProductTrainingTooltip = useCallback(() => {
+ const tooltip = TOOLTIPS[tooltipName];
+ tooltip.onHideTooltip();
+ unregisterTooltip(tooltipName);
+ }, [tooltipName, unregisterTooltip]);
+
+ return {
+ renderProductTrainingTooltip,
+ hideProductTrainingTooltip,
+ shouldShowProductTrainingTooltip,
+ };
+};
+
+export {ProductTrainingContextProvider, useProductTrainingContext};
diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
index ba0cda25d59e..e3f2eb7966e3 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
+++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
@@ -74,20 +74,20 @@ function MoneyRequestPreviewContent({
const route = useRoute>();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
- const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID || '-1'}`);
+ const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`);
const [session] = useOnyx(ONYXKEYS.SESSION);
- const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID || '-1'}`);
+ const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`);
const policy = PolicyUtils.getPolicy(iouReport?.policyID);
const isMoneyRequestAction = ReportActionsUtils.isMoneyRequestAction(action);
- const transactionID = isMoneyRequestAction ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID : '-1';
+ const transactionID = isMoneyRequestAction ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID : undefined;
const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`);
const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS);
const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
const sessionAccountID = session?.accountID;
- const managerID = iouReport?.managerID ?? -1;
- const ownerAccountID = iouReport?.ownerAccountID ?? -1;
+ const managerID = iouReport?.managerID ?? CONST.DEFAULT_NUMBER_ID;
+ const ownerAccountID = iouReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID;
const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport);
const participantAccountIDs =
@@ -117,9 +117,9 @@ function MoneyRequestPreviewContent({
const isOnHold = TransactionUtils.isOnHold(transaction);
const isSettlementOrApprovalPartial = !!iouReport?.pendingFields?.partial;
const isPartialHold = isSettlementOrApprovalPartial && isOnHold;
- const hasViolations = TransactionUtils.hasViolation(transaction?.transactionID ?? '-1', transactionViolations, true);
- const hasNoticeTypeViolations = TransactionUtils.hasNoticeTypeViolation(transaction?.transactionID ?? '-1', transactionViolations, true) && ReportUtils.isPaidGroupPolicy(iouReport);
- const hasWarningTypeViolations = TransactionUtils.hasWarningTypeViolation(transaction?.transactionID ?? '-1', transactionViolations, true);
+ const hasViolations = TransactionUtils.hasViolation(transaction?.transactionID, transactionViolations, true);
+ const hasNoticeTypeViolations = TransactionUtils.hasNoticeTypeViolation(transaction?.transactionID, transactionViolations, true) && ReportUtils.isPaidGroupPolicy(iouReport);
+ const hasWarningTypeViolations = TransactionUtils.hasWarningTypeViolation(transaction?.transactionID, transactionViolations, true);
const hasFieldErrors = TransactionUtils.hasMissingSmartscanFields(transaction);
const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction);
const isFetchingWaypointsFromServer = TransactionUtils.isFetchingWaypointsFromServer(transaction);
@@ -155,8 +155,8 @@ function MoneyRequestPreviewContent({
const shouldShowHoldMessage = !(isSettled && !isSettlementOrApprovalPartial) && !!transaction?.comment?.hold;
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params?.threadReportID}`);
- const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '', report?.parentReportActionID ?? '');
- const reviewingTransactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID ?? '-1' : '-1';
+ const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID, report?.parentReportActionID);
+ const reviewingTransactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID : undefined;
/*
Show the merchant for IOUs and expenses only if:
@@ -253,10 +253,10 @@ function MoneyRequestPreviewContent({
if (TransactionUtils.isPending(transaction)) {
return {shouldShow: true, messageIcon: Expensicons.CreditCardHourglass, messageDescription: translate('iou.transactionPending')};
}
- if (TransactionUtils.shouldShowBrokenConnectionViolation(transaction?.transactionID ?? '-1', iouReport, policy)) {
+ if (TransactionUtils.shouldShowBrokenConnectionViolation(transaction?.transactionID, iouReport, policy)) {
return {shouldShow: true, messageIcon: Expensicons.Hourglass, messageDescription: translate('violations.brokenConnection530Error')};
}
- if (TransactionUtils.hasPendingUI(transaction, TransactionUtils.getTransactionViolations(transaction?.transactionID ?? '-1', transactionViolations))) {
+ if (TransactionUtils.hasPendingUI(transaction, TransactionUtils.getTransactionViolations(transaction?.transactionID, transactionViolations))) {
return {shouldShow: true, messageIcon: Expensicons.Hourglass, messageDescription: translate('iou.pendingMatchWithCreditCard')};
}
return {shouldShow: false};
@@ -301,12 +301,8 @@ function MoneyRequestPreviewContent({
// Clear the draft before selecting a different expense to prevent merging fields from the previous expense
// (e.g., category, tag, tax) that may be not enabled/available in the new expense's policy.
Transaction.abandonReviewDuplicateTransactions();
- const comparisonResult = TransactionUtils.compareDuplicateTransactionFields(
- reviewingTransactionID,
- transaction?.reportID ?? '',
- transaction?.transactionID ?? reviewingTransactionID,
- );
- Transaction.setReviewDuplicatesKey({...comparisonResult.keep, duplicates, transactionID: transaction?.transactionID ?? '', reportID: transaction?.reportID});
+ const comparisonResult = TransactionUtils.compareDuplicateTransactionFields(reviewingTransactionID, transaction?.reportID, transaction?.transactionID ?? reviewingTransactionID);
+ Transaction.setReviewDuplicatesKey({...comparisonResult.keep, duplicates, transactionID: transaction?.transactionID, reportID: transaction?.reportID});
if ('merchant' in comparisonResult.change) {
Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE.getRoute(route.params?.threadReportID, backTo));
@@ -349,11 +345,13 @@ function MoneyRequestPreviewContent({
!onPreviewPressed ? [styles.moneyRequestPreviewBox, containerStyles] : {},
]}
>
-
+ {!isDeleted && (
+
+ )}
{isEmptyObject(transaction) && !ReportActionsUtils.isMessageDeleted(action) && action.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? (
) : (
diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx
index 79497e5fab88..a4ade8d77aa8 100644
--- a/src/components/ReportActionItem/ReportPreview.tsx
+++ b/src/components/ReportActionItem/ReportPreview.tsx
@@ -103,7 +103,7 @@ function ReportPreview({
const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET);
const [invoiceReceiverPolicy] = useOnyx(
- `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : -1}`,
+ `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : CONST.DEFAULT_NUMBER_ID}`,
);
const theme = useTheme();
const styles = useThemeStyles();
@@ -144,10 +144,10 @@ function ReportPreview({
const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(iouReport);
const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = ReportUtils.getNonHeldAndFullAmount(iouReport, shouldShowPayButton);
- const hasOnlyHeldExpenses = ReportUtils.hasOnlyHeldExpenses(iouReport?.reportID ?? '');
- const hasHeldExpenses = ReportUtils.hasHeldExpenses(iouReport?.reportID ?? '');
+ const hasOnlyHeldExpenses = ReportUtils.hasOnlyHeldExpenses(iouReport?.reportID);
+ const hasHeldExpenses = ReportUtils.hasHeldExpenses(iouReport?.reportID);
- const managerID = iouReport?.managerID ?? action.childManagerAccountID ?? 0;
+ const managerID = iouReport?.managerID ?? action.childManagerAccountID ?? CONST.DEFAULT_NUMBER_ID;
const {totalDisplaySpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(iouReport);
const iouSettled = ReportUtils.isSettled(iouReportID) || action?.childStatusNum === CONST.REPORT.STATUS_NUM.REIMBURSED;
@@ -189,9 +189,8 @@ function ReportPreview({
const lastThreeReceipts = lastThreeTransactions.map((transaction) => ({...ReceiptUtils.getThumbnailAndImageURIs(transaction), transaction}));
const showRTERViolationMessage =
numberOfRequests === 1 &&
- TransactionUtils.hasPendingUI(allTransactions.at(0), TransactionUtils.getTransactionViolations(allTransactions.at(0)?.transactionID ?? '-1', transactionViolations));
- const shouldShowBrokenConnectionViolation =
- numberOfRequests === 1 && TransactionUtils.shouldShowBrokenConnectionViolation(allTransactions.at(0)?.transactionID ?? '-1', iouReport, policy);
+ TransactionUtils.hasPendingUI(allTransactions.at(0), TransactionUtils.getTransactionViolations(allTransactions.at(0)?.transactionID, transactionViolations));
+ const shouldShowBrokenConnectionViolation = numberOfRequests === 1 && TransactionUtils.shouldShowBrokenConnectionViolation(allTransactions.at(0)?.transactionID, iouReport, policy);
let formattedMerchant = numberOfRequests === 1 ? TransactionUtils.getMerchant(allTransactions.at(0)) : null;
const formattedDescription = numberOfRequests === 1 ? TransactionUtils.getDescription(allTransactions.at(0)) : null;
@@ -500,11 +499,13 @@ function ReportPreview({
accessibilityLabel={translate('iou.viewDetails')}
>
-
+ {lastThreeReceipts.length > 0 && (
+
+ )}
diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx
index a78845f126d2..21a5832052c0 100644
--- a/src/components/Search/SearchPageHeader.tsx
+++ b/src/components/Search/SearchPageHeader.tsx
@@ -1,3 +1,4 @@
+import {useIsFocused} from '@react-navigation/native';
import React, {useMemo, useState} from 'react';
import {InteractionManager, View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
@@ -8,6 +9,8 @@ import ConfirmModal from '@components/ConfirmModal';
import DecisionModal from '@components/DecisionModal';
import * as Expensicons from '@components/Icon/Expensicons';
import {usePersonalDetails} from '@components/OnyxProvider';
+import {useProductTrainingContext} from '@components/ProductTrainingContext';
+import EducationalTooltip from '@components/Tooltip/EducationalTooltip';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
@@ -55,6 +58,11 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) {
const [isDeleteExpensesConfirmModalVisible, setIsDeleteExpensesConfirmModalVisible] = useState(false);
const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false);
const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false);
+ const isFocused = useIsFocused();
+ const {renderProductTrainingTooltip, shouldShowProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext(
+ CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.SEARCH_FILTER_BUTTON_TOOLTIP,
+ isFocused,
+ );
const {status, hash} = queryJSON;
@@ -348,12 +356,25 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) {
shouldUseStyleUtilityForAnchorPosition
/>
) : (
-
+
+
+
)}
{
if (autocompleteQueryValue.trim() === '') {
return searchOptions.recentReports.slice(0, 20);
}
Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS);
- const filteredOptions = OptionsListUtils.filterAndOrderOptions(searchOptions, autocompleteQueryValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true});
+ const filteredOptions = filterOptions(autocompleteQueryValue);
+ const orderedOptions = OptionsListUtils.combineOrderingOfReportsAndPersonalDetails(filteredOptions, autocompleteQueryValue, {
+ sortByReportTypeInSearch: true,
+ preferChatroomsOverThreads: true,
+ });
Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS);
- const reportOptions: OptionData[] = [...filteredOptions.recentReports, ...filteredOptions.personalDetails];
+ const reportOptions: OptionData[] = [...orderedOptions.recentReports, ...orderedOptions.personalDetails];
if (filteredOptions.userToInvite) {
reportOptions.push(filteredOptions.userToInvite);
}
return reportOptions.slice(0, 20);
- }, [autocompleteQueryValue, searchOptions]);
+ }, [autocompleteQueryValue, filterOptions, searchOptions]);
useEffect(() => {
ReportUserActions.searchInServer(autocompleteQueryValue.trim());
diff --git a/src/components/TabSelector/TabSelector.tsx b/src/components/TabSelector/TabSelector.tsx
index b05e633842b1..16c29c7f51c9 100644
--- a/src/components/TabSelector/TabSelector.tsx
+++ b/src/components/TabSelector/TabSelector.tsx
@@ -28,16 +28,6 @@ type IconAndTitle = {
function getIconAndTitle(route: string, translate: LocaleContextProps['translate']): IconAndTitle {
switch (route) {
- case CONST.DEBUG.DETAILS:
- return {icon: Expensicons.Info, title: translate('debug.details')};
- case CONST.DEBUG.JSON:
- return {icon: Expensicons.Eye, title: translate('debug.JSON')};
- case CONST.DEBUG.REPORT_ACTIONS:
- return {icon: Expensicons.Document, title: translate('debug.reportActions')};
- case CONST.DEBUG.REPORT_ACTION_PREVIEW:
- return {icon: Expensicons.Document, title: translate('debug.reportActionPreview')};
- case CONST.DEBUG.TRANSACTION_VIOLATIONS:
- return {icon: Expensicons.Exclamation, title: translate('debug.violations')};
case CONST.TAB_REQUEST.MANUAL:
return {icon: Expensicons.Pencil, title: translate('tabSelector.manual')};
case CONST.TAB_REQUEST.SCAN:
diff --git a/src/components/TabSelector/getBackground/index.native.ts b/src/components/TabSelector/getBackground/index.native.ts
index 09a9b3f347e6..2fd2a2ef6dd3 100644
--- a/src/components/TabSelector/getBackground/index.native.ts
+++ b/src/components/TabSelector/getBackground/index.native.ts
@@ -1,15 +1,20 @@
import type {Animated} from 'react-native';
import type GetBackgroudColor from './types';
-const getBackgroundColor: GetBackgroudColor = ({routesLength, tabIndex, affectedTabs, theme, position}) => {
+const getBackgroundColor: GetBackgroudColor = ({routesLength, tabIndex, affectedTabs, theme, position, isActive}) => {
if (routesLength > 1) {
const inputRange = Array.from({length: routesLength}, (v, i) => i);
- return position?.interpolate({
- inputRange,
- outputRange: inputRange.map((i) => {
- return affectedTabs.includes(tabIndex) && i === tabIndex ? theme.border : theme.appBG;
- }),
- }) as unknown as Animated.AnimatedInterpolation;
+
+ if (position) {
+ return position.interpolate({
+ inputRange,
+ outputRange: inputRange.map((i) => {
+ return affectedTabs.includes(tabIndex) && i === tabIndex ? theme.border : theme.appBG;
+ }),
+ }) as unknown as Animated.AnimatedInterpolation;
+ }
+
+ return affectedTabs.includes(tabIndex) && isActive ? theme.border : theme.appBG;
}
return theme.border;
};
diff --git a/src/components/TabSelector/getBackground/types.ts b/src/components/TabSelector/getBackground/types.ts
index f66ee37e9b73..a207c3bab35e 100644
--- a/src/components/TabSelector/getBackground/types.ts
+++ b/src/components/TabSelector/getBackground/types.ts
@@ -28,7 +28,7 @@ type GetBackgroudColorConfig = {
/**
* The animated position interpolation.
*/
- position: Animated.AnimatedInterpolation;
+ position: Animated.AnimatedInterpolation | undefined;
/**
* Whether the tab is active.
diff --git a/src/components/TabSelector/getOpacity/index.native.ts b/src/components/TabSelector/getOpacity/index.native.ts
index a59d32c2db6e..fcdb1d0fc31e 100644
--- a/src/components/TabSelector/getOpacity/index.native.ts
+++ b/src/components/TabSelector/getOpacity/index.native.ts
@@ -1,16 +1,20 @@
import type GetOpacity from './types';
-const getOpacity: GetOpacity = ({routesLength, tabIndex, active, affectedTabs, position}) => {
+const getOpacity: GetOpacity = ({routesLength, tabIndex, active, affectedTabs, position, isActive}) => {
const activeValue = active ? 1 : 0;
const inactiveValue = active ? 0 : 1;
if (routesLength > 1) {
const inputRange = Array.from({length: routesLength}, (v, i) => i);
- return position?.interpolate({
- inputRange,
- outputRange: inputRange.map((i) => (affectedTabs.includes(tabIndex) && i === tabIndex ? activeValue : inactiveValue)),
- });
+ if (position) {
+ return position.interpolate({
+ inputRange,
+ outputRange: inputRange.map((i) => (affectedTabs.includes(tabIndex) && i === tabIndex ? activeValue : inactiveValue)),
+ });
+ }
+
+ return affectedTabs.includes(tabIndex) && isActive ? activeValue : inactiveValue;
}
return activeValue;
};
diff --git a/src/components/TabSelector/getOpacity/types.ts b/src/components/TabSelector/getOpacity/types.ts
index 46e4568b2783..a15eacf0d8cc 100644
--- a/src/components/TabSelector/getOpacity/types.ts
+++ b/src/components/TabSelector/getOpacity/types.ts
@@ -27,7 +27,7 @@ type GetOpacityConfig = {
/**
* Scene's position, value which we would like to interpolate.
*/
- position: Animated.AnimatedInterpolation;
+ position: Animated.AnimatedInterpolation | undefined;
/**
* Whether the tab is active.
diff --git a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx
index b6e313cab45d..9feb086e3ab2 100644
--- a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx
+++ b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx
@@ -11,7 +11,7 @@ type LayoutChangeEventWithTarget = NativeSyntheticEvent<{layout: LayoutRectangle
* A component used to wrap an element intended for displaying a tooltip.
* This tooltip would show immediately without user's interaction and hide after 5 seconds.
*/
-function BaseEducationalTooltip({children, onHideTooltip, shouldRender = false, shouldAutoDismiss = false, ...props}: EducationalTooltipProps) {
+function BaseEducationalTooltip({children, onHideTooltip: onHideTooltipProp, shouldRender = false, shouldAutoDismiss = false, ...props}: EducationalTooltipProps) {
const hideTooltipRef = useRef<() => void>();
const [shouldMeasure, setShouldMeasure] = useState(false);
@@ -21,6 +21,13 @@ function BaseEducationalTooltip({children, onHideTooltip, shouldRender = false,
const didShow = useRef(false);
+ const onHideTooltip = useCallback(() => {
+ if (!shouldRender) {
+ return;
+ }
+ onHideTooltipProp?.();
+ }, [onHideTooltipProp, shouldRender]);
+
const closeTooltip = useCallback(() => {
if (!didShow.current) {
return;
diff --git a/src/hooks/useBottomTabIsFocused.ts b/src/hooks/useBottomTabIsFocused.ts
new file mode 100644
index 000000000000..60817f194628
--- /dev/null
+++ b/src/hooks/useBottomTabIsFocused.ts
@@ -0,0 +1,26 @@
+import {useIsFocused, useNavigationState} from '@react-navigation/native';
+import CENTRAL_PANE_SCREENS from '@libs/Navigation/AppNavigator/CENTRAL_PANE_SCREENS';
+import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute';
+import getTopmostFullScreenRoute from '@libs/Navigation/getTopmostFullScreenRoute';
+import type {CentralPaneName, FullScreenName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types';
+import SCREENS from '@src/SCREENS';
+import useResponsiveLayout from './useResponsiveLayout';
+
+const useBottomTabIsFocused = () => {
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const isFocused = useIsFocused();
+ const topmostFullScreenName = useNavigationState | undefined>(getTopmostFullScreenRoute);
+ const topmostCentralPane = useNavigationState | undefined>(getTopmostCentralPaneRoute);
+ // If there is a full screen view such as Workspace Settings or Not Found screen, the bottom tab should not be considered focused
+ if (topmostFullScreenName) {
+ return false;
+ }
+ // On the Search screen, isFocused returns false, but it is actually focused
+ if (shouldUseNarrowLayout) {
+ return isFocused || topmostCentralPane?.name === SCREENS.SEARCH.CENTRAL_PANE;
+ }
+ // On desktop screen sizes, isFocused always returns false, so we cannot rely on it alone to determine if the bottom tab is focused
+ return isFocused || Object.keys(CENTRAL_PANE_SCREENS).includes(topmostCentralPane?.name ?? '');
+};
+
+export default useBottomTabIsFocused;
diff --git a/src/hooks/useFastSearchFromOptions.ts b/src/hooks/useFastSearchFromOptions.ts
new file mode 100644
index 000000000000..7856eed479bd
--- /dev/null
+++ b/src/hooks/useFastSearchFromOptions.ts
@@ -0,0 +1,113 @@
+import {useMemo} from 'react';
+import FastSearch from '@libs/FastSearch';
+import * as OptionsListUtils from '@libs/OptionsListUtils';
+
+type AllOrSelectiveOptions = OptionsListUtils.ReportAndPersonalDetailOptions | OptionsListUtils.Options;
+
+type Options = {
+ includeUserToInvite: boolean;
+};
+
+const emptyResult = {
+ personalDetails: [],
+ recentReports: [],
+};
+
+// You can either use this to search within report and personal details options
+function useFastSearchFromOptions(
+ options: OptionsListUtils.ReportAndPersonalDetailOptions,
+ config?: {includeUserToInvite: false},
+): (searchInput: string) => OptionsListUtils.ReportAndPersonalDetailOptions;
+// Or you can use this to include the user invite option. This will require passing all options
+function useFastSearchFromOptions(options: OptionsListUtils.Options, config?: {includeUserToInvite: true}): (searchInput: string) => OptionsListUtils.Options;
+
+/**
+ * Hook for making options from OptionsListUtils searchable with FastSearch.
+ * Builds a suffix tree and returns a function to search in it.
+ *
+ * @example
+ * ```
+ * const options = OptionsListUtils.getSearchOptions(...);
+ * const filterOptions = useFastSearchFromOptions(options);
+ */
+function useFastSearchFromOptions(
+ options: OptionsListUtils.ReportAndPersonalDetailOptions | OptionsListUtils.Options,
+ {includeUserToInvite}: Options = {includeUserToInvite: false},
+): (searchInput: string) => AllOrSelectiveOptions {
+ const findInSearchTree = useMemo(() => {
+ const fastSearch = FastSearch.createFastSearch([
+ {
+ data: options.personalDetails,
+ toSearchableString: (option) => {
+ const displayName = option.participantsList?.[0]?.displayName ?? '';
+ return [option.login ?? '', option.login !== displayName ? displayName : ''].join();
+ },
+ uniqueId: (option) => option.login,
+ },
+ {
+ data: options.recentReports,
+ toSearchableString: (option) => {
+ const searchStringForTree = [option.text ?? '', option.login ?? ''];
+
+ if (option.isThread) {
+ if (option.alternateText) {
+ searchStringForTree.push(option.alternateText);
+ }
+ } else if (!!option.isChatRoom || !!option.isPolicyExpenseChat) {
+ if (option.subtitle) {
+ searchStringForTree.push(option.subtitle);
+ }
+ }
+
+ return searchStringForTree.join();
+ },
+ },
+ ]);
+
+ function search(searchInput: string): AllOrSelectiveOptions {
+ const searchWords = searchInput.split(' ').sort(); // asc sorted
+ const longestSearchWord = searchWords.at(searchWords.length - 1); // longest word is the last element
+ if (!longestSearchWord) {
+ return emptyResult;
+ }
+
+ // The user might separated words with spaces to do a search such as: "jo d" -> "john doe"
+ // With the suffix search tree you can only search for one word at a time. Its most efficient to search for the longest word,
+ // (as this will limit the results the most) and then afterwards run a quick filter on the results to see if the other words are present.
+ let [personalDetails, recentReports] = fastSearch.search(longestSearchWord);
+
+ if (searchWords.length > 1) {
+ personalDetails = personalDetails.filter((pd) => OptionsListUtils.isSearchStringMatch(searchInput, pd.text));
+ recentReports = recentReports.filter((rr) => OptionsListUtils.isSearchStringMatch(searchInput, rr.text));
+ }
+
+ if (includeUserToInvite && 'currentUserOption' in options) {
+ const userToInvite = OptionsListUtils.filterUserToInvite(
+ {
+ ...options,
+ personalDetails,
+ recentReports,
+ },
+ searchInput,
+ );
+ return {
+ personalDetails,
+ recentReports,
+ userToInvite,
+ currentUserOption: options.currentUserOption,
+ };
+ }
+
+ return {
+ personalDetails,
+ recentReports,
+ };
+ }
+
+ return search;
+ }, [includeUserToInvite, options]);
+
+ return findInSearchTree;
+}
+
+export default useFastSearchFromOptions;
diff --git a/src/hooks/useOnboardingFlow.ts b/src/hooks/useOnboardingFlow.ts
index d322a4d52703..e1e659fd4eb1 100644
--- a/src/hooks/useOnboardingFlow.ts
+++ b/src/hooks/useOnboardingFlow.ts
@@ -3,7 +3,6 @@ import {InteractionManager, NativeModules} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import Navigation from '@libs/Navigation/Navigation';
import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector} from '@libs/onboardingSelectors';
-import Permissions from '@libs/Permissions';
import * as SearchQueryUtils from '@libs/SearchQueryUtils';
import * as Session from '@userActions/Session';
import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow';
@@ -30,11 +29,10 @@ function useOnboardingFlowRouter() {
const isPrivateDomain = Session.isUserOnPrivateDomain();
const [isSingleNewDotEntry, isSingleNewDotEntryMetadata] = useOnyx(ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY);
- const [allBetas, allBetasMetadata] = useOnyx(ONYXKEYS.BETAS);
useEffect(() => {
// This should delay opening the onboarding modal so it does not interfere with the ongoing ReportScreen params changes
InteractionManager.runAfterInteractions(() => {
- if (isLoadingOnyxValue(isOnboardingCompletedMetadata, tryNewDotdMetadata, dismissedProductTrainingMetadata, allBetasMetadata)) {
+ if (isLoadingOnyxValue(isOnboardingCompletedMetadata, tryNewDotdMetadata, dismissedProductTrainingMetadata)) {
return;
}
@@ -42,7 +40,7 @@ function useOnboardingFlowRouter() {
return;
}
- if (hasBeenAddedToNudgeMigration && !dismissedProductTraining?.migratedUserWelcomeModal && Permissions.shouldShowProductTrainingElements(allBetas)) {
+ if (hasBeenAddedToNudgeMigration && !dismissedProductTraining?.migratedUserWelcomeModal) {
const defaultCannedQuery = SearchQueryUtils.buildCannedSearchQuery();
const query = defaultCannedQuery;
Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query}));
@@ -85,8 +83,6 @@ function useOnboardingFlowRouter() {
dismissedProductTraining?.migratedUserWelcomeModal,
dismissedProductTraining,
isPrivateDomain,
- allBetas,
- allBetasMetadata,
]);
return {isOnboardingCompleted, isHybridAppOnboardingCompleted};
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 77e3b882b892..3d35fed3e0a4 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -455,6 +455,7 @@ const translations = {
drafts: 'Drafts',
finished: 'Finished',
upgrade: 'Upgrade',
+ downgradeWorkspace: 'Downgrade workspace',
companyID: 'Company ID',
userID: 'User ID',
disable: 'Disable',
@@ -656,10 +657,6 @@ const translations = {
emoji: 'Emoji',
collapse: 'Collapse',
expand: 'Expand',
- tooltip: {
- title: 'Get started!',
- subtitle: ' Submit your first expense',
- },
},
reportActionContextMenu: {
copyToClipboard: 'Copy to clipboard',
@@ -846,10 +843,6 @@ const translations = {
trackDistance: 'Track distance',
noLongerHaveReportAccess: 'You no longer have access to your previous quick action destination. Pick a new one below.',
updateDestination: 'Update destination',
- tooltip: {
- title: 'Quick action! ',
- subtitle: 'Just a tap away.',
- },
},
iou: {
amount: 'Amount',
@@ -2536,7 +2529,7 @@ const translations = {
rules: 'Rules',
displayedAs: 'Displayed as',
plan: 'Plan',
- profile: 'Profile',
+ profile: 'Workspace profile',
perDiem: 'Per diem',
bankAccount: 'Bank account',
connectBankAccount: 'Connect bank account',
@@ -4376,6 +4369,27 @@ const translations = {
},
},
},
+ downgrade: {
+ commonFeatures: {
+ title: 'Downgrade to the Collect plan',
+ note: 'If you downgrade, you’ll lose access to these features and more:',
+ benefits: {
+ note: 'For a full comparison of our plans, check out our',
+ pricingPage: 'pricing page',
+ confirm: 'Are you sure you want to downgrade and remove your configurations?',
+ warning: 'This cannot be undone.',
+ benefit1: 'Accounting connections (except QuickBooks Online and Xero)',
+ benefit2: 'Smart expense rules',
+ benefit3: 'Multi-level approval workflows',
+ benefit4: 'Enhanced security controls',
+ },
+ },
+ completed: {
+ headline: 'Your workspace has been downgraded',
+ description: 'You have other workspace on the Control plan. To be billed at the Collect rate, you must downgrade all workspaces.',
+ gotIt: 'Got it, thanks',
+ },
+ },
restrictedAction: {
restricted: 'Restricted',
actionsAreCurrentlyRestricted: ({workspaceName}: ActionsAreCurrentlyRestricted) => `Actions on the ${workspaceName} workspace are currently restricted`,
@@ -4651,7 +4665,6 @@ const translations = {
},
},
saveSearch: 'Save search',
- saveSearchTooltipText: 'You can rename your saved search',
deleteSavedSearch: 'Delete saved search',
deleteSavedSearchConfirm: 'Are you sure you want to delete this search?',
searchName: 'Search name',
@@ -5553,6 +5566,44 @@ const translations = {
crossPlatform: 'Do everything from your phone or browser',
},
},
+ productTrainingTooltip: {
+ conciergeLHNGBR: {
+ part1: 'Get started',
+ part2: ' here!',
+ },
+ saveSearchTooltip: {
+ part1: 'Rename your saved searches',
+ part2: ' here!',
+ },
+ quickActionButton: {
+ part1: 'Quick action!',
+ part2: ' Just a tap away',
+ },
+ workspaceChatCreate: {
+ part1: 'Submit your',
+ part2: ' expenses',
+ part3: ' here!',
+ },
+ searchFilterButtonTooltip: {
+ part1: 'Customize your search',
+ part2: ' here!',
+ },
+ bottomNavInboxTooltip: {
+ part1: 'Your to-do list',
+ part2: '\n🟢 = ready for you',
+ part3: ' 🔴 = needs review',
+ },
+ workspaceChatTooltip: {
+ part1: 'Submit expenses',
+ part2: ' and chat with',
+ part3: '\napprovers here!',
+ },
+ globalCreateTooltip: {
+ part1: 'Create expenses',
+ part2: ', start chatting,',
+ part3: '\nand more!',
+ },
+ },
};
export default translations satisfies TranslationDeepObject;
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 09a0394ea6a0..cb7f53424958 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -448,6 +448,7 @@ const translations = {
drafts: 'Borradores',
finished: 'Finalizados',
upgrade: 'Mejora',
+ downgradeWorkspace: 'Desmejora tu espacio de trabajo',
companyID: 'Empresa ID',
userID: 'Usuario ID',
disable: 'Deshabilitar',
@@ -648,10 +649,6 @@ const translations = {
emoji: 'Emoji',
collapse: 'Colapsar',
expand: 'Expandir',
- tooltip: {
- title: '¡Empecemos!',
- subtitle: ' Presenta tu primer gasto',
- },
},
reportActionContextMenu: {
copyToClipboard: 'Copiar al portapapeles',
@@ -841,10 +838,6 @@ const translations = {
trackDistance: 'Crear gasto por desplazamiento',
noLongerHaveReportAccess: 'Ya no tienes acceso al destino previo de esta acción rápida. Escoge uno nuevo a continuación.',
updateDestination: 'Actualiza el destino',
- tooltip: {
- title: '¡Acción rápida! ',
- subtitle: 'A un click.',
- },
},
iou: {
amount: 'Importe',
@@ -2558,7 +2551,7 @@ const translations = {
accounting: 'Contabilidad',
rules: 'Reglas',
plan: 'Plan',
- profile: 'Perfil',
+ profile: 'Perfil del espacio de trabajo',
perDiem: 'Per diem',
bankAccount: 'Cuenta bancaria',
displayedAs: 'Mostrado como',
@@ -4331,11 +4324,11 @@ const translations = {
planTypePage: {
planTypes: {
team: {
- label: 'Collect',
+ label: 'Recopilar',
description: 'Para equipos que buscan automatizar sus procesos.',
},
corporate: {
- label: 'Recolectar',
+ label: 'Controlar',
description: 'Para organizaciones con requisitos avanzados.',
},
},
@@ -4442,6 +4435,27 @@ const translations = {
},
},
},
+ downgrade: {
+ commonFeatures: {
+ title: 'Desmejorar al plan Recopilar',
+ note: 'Si desmejoras, perderás acceso a estas funciones y más:',
+ benefits: {
+ note: 'Para una comparación completa de nuestros planes, consulta nuestra',
+ pricingPage: 'página de precios',
+ confirm: '¿Estás seguro de que deseas desmejorar y eliminar tus configuraciones?',
+ warning: 'Esto no se puede deshacer.',
+ benefit1: 'Conexiones de contabilidad (excepto QuickBooks Online y Xero)',
+ benefit2: 'Reglas inteligentes de gastos',
+ benefit3: 'Flujos de aprobación de varios niveles',
+ benefit4: 'Controles de seguridad mejorados',
+ },
+ },
+ completed: {
+ headline: 'Tu espacio de trabajo ha sido bajado de categoría',
+ description: 'Tienes otro espacio de trabajo en el plan Controlar. Para facturarte con la tasa del plan Recopilar, debes bajar de categoría todos los espacios de trabajo.',
+ gotIt: 'Entendido, gracias.',
+ },
+ },
restrictedAction: {
restricted: 'Restringido',
actionsAreCurrentlyRestricted: ({workspaceName}: ActionsAreCurrentlyRestricted) => `Las acciones en el espacio de trabajo ${workspaceName} están actualmente restringidas`,
@@ -4699,7 +4713,6 @@ const translations = {
},
},
saveSearch: 'Guardar búsqueda',
- saveSearchTooltipText: 'Puedes cambiar el nombre de tu búsqueda guardada',
savedSearchesMenuItemTitle: 'Guardadas',
searchName: 'Nombre de la búsqueda',
deleteSavedSearch: 'Eliminar búsqueda guardada',
@@ -6072,6 +6085,44 @@ const translations = {
crossPlatform: 'Haz todo desde tu teléfono o navegador',
},
},
+ productTrainingTooltip: {
+ conciergeLHNGBR: {
+ part1: '¡Comienza',
+ part2: ' aquí!',
+ },
+ saveSearchTooltip: {
+ part1: 'Renombra tus búsquedas guardadas',
+ part2: ' aquí',
+ },
+ quickActionButton: {
+ part1: '¡Acción rápida!',
+ part2: ' A solo un toque',
+ },
+ workspaceChatCreate: {
+ part1: 'Envía tus',
+ part2: ' gastos',
+ part3: ' aquí',
+ },
+ searchFilterButtonTooltip: {
+ part1: 'Personaliza tu búsqueda',
+ part2: ' aquí!',
+ },
+ bottomNavInboxTooltip: {
+ part1: 'Tu lista de tareas',
+ part2: '\n🟢 = listo para ti',
+ part3: ' 🔴 = necesita revisión',
+ },
+ workspaceChatTooltip: {
+ part1: 'Envía gastos',
+ part2: ' y chatea con',
+ part3: '\naprobadores aquí!',
+ },
+ globalCreateTooltip: {
+ part1: 'Crea gastos',
+ part2: ', comienza a chatear,',
+ part3: '\ny mucho más!',
+ },
+ },
};
export default translations satisfies TranslationDeepObject;
diff --git a/src/libs/API/parameters/DowngradeToTeamParams.ts b/src/libs/API/parameters/DowngradeToTeamParams.ts
new file mode 100644
index 000000000000..0d3c6f8fde66
--- /dev/null
+++ b/src/libs/API/parameters/DowngradeToTeamParams.ts
@@ -0,0 +1,5 @@
+type DowngradeToTeamParams = {
+ policyID: string;
+};
+
+export default DowngradeToTeamParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index f31e53de07e3..7e8b8cec520b 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -254,6 +254,7 @@ export type {default as UpdateSubscriptionSizeParams} from './UpdateSubscription
export type {default as ReportExportParams} from './ReportExportParams';
export type {default as MarkAsExportedParams} from './MarkAsExportedParams';
export type {default as UpgradeToCorporateParams} from './UpgradeToCorporateParams';
+export type {default as DowngradeToTeamParams} from './DowngradeToTeamParams';
export type {default as DeleteMoneyRequestOnSearchParams} from './DeleteMoneyRequestOnSearchParams';
export type {default as HoldMoneyRequestOnSearchParams} from './HoldMoneyRequestOnSearchParams';
export type {default as ApproveMoneyRequestOnSearchParams} from './ApproveMoneyRequestOnSearchParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 96ff46bf8fbb..aa9831ca4053 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -315,6 +315,7 @@ const WRITE_COMMANDS = {
REPORT_EXPORT: 'Report_Export',
MARK_AS_EXPORTED: 'MarkAsExported',
UPGRADE_TO_CORPORATE: 'UpgradeToCorporate',
+ DOWNGRADE_TO_TEAM: 'Policy_DowngradeToTeam',
DELETE_MONEY_REQUEST_ON_SEARCH: 'DeleteMoneyRequestOnSearch',
HOLD_MONEY_REQUEST_ON_SEARCH: 'HoldMoneyRequestOnSearch',
APPROVE_MONEY_REQUEST_ON_SEARCH: 'ApproveMoneyRequestOnSearch',
@@ -792,6 +793,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.UPDATE_SAGE_INTACCT_SYNC_REIMBURSEMENT_ACCOUNT_ID]: Parameters.UpdateSageIntacctGenericTypeParams<'vendorID', string>;
[WRITE_COMMANDS.UPGRADE_TO_CORPORATE]: Parameters.UpgradeToCorporateParams;
+ [WRITE_COMMANDS.DOWNGRADE_TO_TEAM]: Parameters.DowngradeToTeamParams;
// Netsuite parameters
[WRITE_COMMANDS.UPDATE_NETSUITE_SUBSIDIARY]: Parameters.UpdateNetSuiteSubsidiaryParams;
diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts
index cfc56559ed35..79271bdc03c7 100644
--- a/src/libs/DebugUtils.ts
+++ b/src/libs/DebugUtils.ts
@@ -460,7 +460,6 @@ function validateReportDraftProperty(key: keyof Report, value: string) {
case 'reportID':
case 'chatReportID':
case 'type':
- case 'lastMessageTranslationKey':
case 'parentReportID':
case 'parentReportActionID':
case 'lastVisibleActionLastModified':
@@ -530,12 +529,6 @@ function validateReportDraftProperty(key: keyof Report, value: string) {
},
'number',
);
- case 'pendingChatMembers':
- return validateArray>(value, {
- accountID: 'string',
- pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION,
- errors: 'object',
- });
case 'fieldList':
return validateObject>(
value,
@@ -600,7 +593,6 @@ function validateReportDraftProperty(key: keyof Report, value: string) {
writeCapability: CONST.RED_BRICK_ROAD_PENDING_ACTION,
visibility: CONST.RED_BRICK_ROAD_PENDING_ACTION,
invoiceReceiver: CONST.RED_BRICK_ROAD_PENDING_ACTION,
- lastMessageTranslationKey: CONST.RED_BRICK_ROAD_PENDING_ACTION,
parentReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
parentReportActionID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
@@ -618,7 +610,6 @@ function validateReportDraftProperty(key: keyof Report, value: string) {
iouReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
preexistingReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
nonReimbursableTotal: CONST.RED_BRICK_ROAD_PENDING_ACTION,
- pendingChatMembers: CONST.RED_BRICK_ROAD_PENDING_ACTION,
fieldList: CONST.RED_BRICK_ROAD_PENDING_ACTION,
permissions: CONST.RED_BRICK_ROAD_PENDING_ACTION,
tripData: CONST.RED_BRICK_ROAD_PENDING_ACTION,
@@ -1369,7 +1360,7 @@ function getReasonAndReportActionForRBRInLHNRow(report: Report, reportActions: O
}
function getTransactionID(report: OnyxEntry, reportActions: OnyxEntry) {
- const transactionID = TransactionUtils.getTransactionID(report?.reportID ?? '-1');
+ const transactionID = TransactionUtils.getTransactionID(report?.reportID);
return Number(transactionID) > 0
? transactionID
diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts
index c41b33873a8a..94167b382d49 100644
--- a/src/libs/DistanceRequestUtils.ts
+++ b/src/libs/DistanceRequestUtils.ts
@@ -289,11 +289,15 @@ function convertToDistanceInMeters(distance: number, unit: Unit): number {
/**
* Returns custom unit rate ID for the distance transaction
*/
-function getCustomUnitRateID(reportID: string) {
+function getCustomUnitRateID(reportID?: string) {
+ let customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID;
+
+ if (!reportID) {
+ return customUnitRateID;
+ }
const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`];
const policy = PolicyUtils.getPolicy(report?.policyID ?? parentReport?.policyID);
- let customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID;
if (isEmptyObject(policy)) {
return customUnitRateID;
diff --git a/src/libs/FastSearch.ts b/src/libs/FastSearch.ts
new file mode 100644
index 000000000000..a947867f596c
--- /dev/null
+++ b/src/libs/FastSearch.ts
@@ -0,0 +1,167 @@
+/* eslint-disable rulesdir/prefer-at */
+import CONST from '@src/CONST';
+import Timing from './actions/Timing';
+import SuffixUkkonenTree from './SuffixUkkonenTree';
+
+type SearchableData = {
+ /**
+ * The data that should be searchable
+ */
+ data: T[];
+ /**
+ * A function that generates a string from a data entry. The string's value is used for searching.
+ * If you have multiple fields that should be searchable, simply concat them to the string and return it.
+ */
+ toSearchableString: (data: T) => string;
+
+ /**
+ * Gives the possibility to identify data by a unique attribute. Assume you have two search results with the same text they might be valid
+ * and represent different data. In this case, you can provide a function that returns a unique identifier for the data.
+ * If multiple items with the same identifier are found, only the first one will be returned.
+ * This fixes: https://github.com/Expensify/App/issues/53579
+ */
+ uniqueId?: (data: T) => string | undefined;
+};
+
+// There are certain characters appear very often in our search data (email addresses), which we don't need to search for.
+const charSetToSkip = new Set(['@', '.', '#', '$', '%', '&', '*', '+', '-', '/', ':', ';', '<', '=', '>', '?', '_', '~', '!', ' ', ',', '(', ')']);
+
+/**
+ * Creates a new "FastSearch" instance. "FastSearch" uses a suffix tree to search for substrings in a list of strings.
+ * You can provide multiple datasets. The search results will be returned for each dataset.
+ *
+ * Note: Creating a FastSearch instance with a lot of data is computationally expensive. You should create an instance once and reuse it.
+ * Searches will be very fast though, even with a lot of data.
+ */
+function createFastSearch(dataSets: Array>) {
+ Timing.start(CONST.TIMING.SEARCH_CONVERT_SEARCH_VALUES);
+ const maxNumericListSize = 400_000;
+ // The user might provide multiple data sets, but internally, the search values will be stored in this one list:
+ let concatenatedNumericList = new Uint8Array(maxNumericListSize);
+ // Here we store the index of the data item in the original data list, so we can map the found occurrences back to the original data:
+ const occurrenceToIndex = new Uint32Array(maxNumericListSize * 4);
+ // As we are working with ArrayBuffers, we need to keep track of the current offset:
+ const offset = {value: 1};
+ // We store the last offset for a dataSet, so we can map the found occurrences to the correct dataSet:
+ const listOffsets: number[] = [];
+
+ for (const {data, toSearchableString} of dataSets) {
+ // Performance critical: the array parameters are passed by reference, so we don't have to create new arrays every time:
+ dataToNumericRepresentation(concatenatedNumericList, occurrenceToIndex, offset, {data, toSearchableString});
+ listOffsets.push(offset.value);
+ }
+ concatenatedNumericList[offset.value++] = SuffixUkkonenTree.END_CHAR_CODE;
+ listOffsets[listOffsets.length - 1] = offset.value;
+ Timing.end(CONST.TIMING.SEARCH_CONVERT_SEARCH_VALUES);
+
+ // The list might be larger than necessary, so we clamp it to the actual size:
+ concatenatedNumericList = concatenatedNumericList.slice(0, offset.value);
+
+ // Create & build the suffix tree:
+ Timing.start(CONST.TIMING.SEARCH_MAKE_TREE);
+ const tree = SuffixUkkonenTree.makeTree(concatenatedNumericList);
+ Timing.end(CONST.TIMING.SEARCH_MAKE_TREE);
+
+ Timing.start(CONST.TIMING.SEARCH_BUILD_TREE);
+ tree.build();
+ Timing.end(CONST.TIMING.SEARCH_BUILD_TREE);
+
+ /**
+ * Searches for the given input and returns results for each dataset.
+ */
+ function search(searchInput: string): T[][] {
+ const cleanedSearchString = cleanString(searchInput);
+ const {numeric} = SuffixUkkonenTree.stringToNumeric(cleanedSearchString, {
+ charSetToSkip,
+ // stringToNumeric might return a list that is larger than necessary, so we clamp it to the actual size
+ // (otherwise the search could fail as we include in our search empty array values):
+ clamp: true,
+ });
+ const result = tree.findSubstring(Array.from(numeric));
+
+ const resultsByDataSet = Array.from({length: dataSets.length}, () => new Set());
+ const uniqueMap: Record> = {};
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of
+ for (let i = 0; i < result.length; i++) {
+ const occurrenceIndex = result[i];
+ const itemIndexInDataSet = occurrenceToIndex[occurrenceIndex];
+ const dataSetIndex = listOffsets.findIndex((listOffset) => occurrenceIndex < listOffset);
+
+ if (dataSetIndex === -1) {
+ throw new Error(`[FastSearch] The occurrence index ${occurrenceIndex} is not in any dataset`);
+ }
+ const item = dataSets[dataSetIndex].data[itemIndexInDataSet];
+ if (!item) {
+ throw new Error(`[FastSearch] The item with index ${itemIndexInDataSet} in dataset ${dataSetIndex} is not defined`);
+ }
+
+ // Check for uniqueness eventually
+ const getUniqueId = dataSets[dataSetIndex].uniqueId;
+ if (getUniqueId) {
+ const uniqueId = getUniqueId(item);
+ if (uniqueId) {
+ const hasId = uniqueMap[dataSetIndex]?.[uniqueId];
+ if (hasId) {
+ // eslint-disable-next-line no-continue
+ continue;
+ }
+ if (!uniqueMap[dataSetIndex]) {
+ uniqueMap[dataSetIndex] = {};
+ }
+ uniqueMap[dataSetIndex][uniqueId] = item;
+ }
+ }
+
+ resultsByDataSet[dataSetIndex].add(item);
+ }
+
+ return resultsByDataSet.map((set) => Array.from(set));
+ }
+
+ return {
+ search,
+ };
+}
+
+/**
+ * The suffix tree can only store string like values, and internally stores those as numbers.
+ * This function converts the user data (which are most likely objects) to a numeric representation.
+ * Additionally a list of the original data and their index position in the numeric list is created, which is used to map the found occurrences back to the original data.
+ */
+function dataToNumericRepresentation(concatenatedNumericList: Uint8Array, occurrenceToIndex: Uint32Array, offset: {value: number}, {data, toSearchableString}: SearchableData): void {
+ data.forEach((option, index) => {
+ const searchStringForTree = toSearchableString(option);
+ const cleanedSearchStringForTree = cleanString(searchStringForTree);
+
+ if (cleanedSearchStringForTree.length === 0) {
+ return;
+ }
+
+ SuffixUkkonenTree.stringToNumeric(cleanedSearchStringForTree, {
+ charSetToSkip,
+ out: {
+ outArray: concatenatedNumericList,
+ offset,
+ outOccurrenceToIndex: occurrenceToIndex,
+ index,
+ },
+ });
+ // eslint-disable-next-line no-param-reassign
+ occurrenceToIndex[offset.value] = index;
+ // eslint-disable-next-line no-param-reassign
+ concatenatedNumericList[offset.value++] = SuffixUkkonenTree.DELIMITER_CHAR_CODE;
+ });
+}
+
+/**
+ * Everything in the tree is treated as lowercase.
+ */
+function cleanString(input: string) {
+ return input.toLowerCase();
+}
+
+const FastSearch = {
+ createFastSearch,
+};
+
+export default FastSearch;
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index 9622aca72c39..7e5ac879cf60 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -276,6 +276,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/categories/ImportCategoriesPage').default,
[SCREENS.WORKSPACE.CATEGORIES_IMPORTED]: () => require('../../../../pages/workspace/categories/ImportedCategoriesPage').default,
[SCREENS.WORKSPACE.UPGRADE]: () => require('../../../../pages/workspace/upgrade/WorkspaceUpgradePage').default,
+ [SCREENS.WORKSPACE.DOWNGRADE]: () => require('../../../../pages/workspace/downgrade/WorkspaceDowngradePage').default,
[SCREENS.WORKSPACE.MEMBER_DETAILS]: () => require('../../../../pages/workspace/members/WorkspaceMemberDetailsPage').default,
[SCREENS.WORKSPACE.MEMBER_NEW_CARD]: () => require('../../../../pages/workspace/members/WorkspaceMemberNewCardPage').default,
[SCREENS.WORKSPACE.OWNER_CHANGE_CHECK]: () => require('@pages/workspace/members/WorkspaceOwnerChangeWrapperPage').default,
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
index b478f09c2e01..5647c31c6604 100644
--- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
@@ -4,13 +4,17 @@ import {useOnyx} from 'react-native-onyx';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import {PressableWithFeedback} from '@components/Pressable';
+import {useProductTrainingContext} from '@components/ProductTrainingContext';
import type {SearchQueryString} from '@components/Search/types';
import Text from '@components/Text';
+import EducationalTooltip from '@components/Tooltip/EducationalTooltip';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
+import useBottomTabIsFocused from '@hooks/useBottomTabIsFocused';
import useCurrentReportID from '@hooks/useCurrentReportID';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import getPlatform from '@libs/getPlatform';
import interceptAnonymousUser from '@libs/interceptAnonymousUser';
import Navigation from '@libs/Navigation/Navigation';
import type {AuthScreensParamList, RootStackParamList, State} from '@libs/Navigation/types';
@@ -77,7 +81,13 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) {
const [chatTabBrickRoad, setChatTabBrickRoad] = useState(() =>
getChatTabBrickRoad(activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations),
);
-
+ const isFocused = useBottomTabIsFocused();
+ const platform = getPlatform();
+ const isWebOrDesktop = platform === CONST.PLATFORM.WEB || platform === CONST.PLATFORM.DESKTOP;
+ const {renderProductTrainingTooltip, shouldShowProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext(
+ CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.BOTTOM_NAV_INBOX_TOOLTIP,
+ selectedTab !== SCREENS.HOME && isFocused,
+ );
useEffect(() => {
setChatTabBrickRoad(getChatTabBrickRoad(activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations));
// We need to get a new brick road state when report actions are updated, otherwise we'll be showing an outdated brick road.
@@ -136,30 +146,49 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) {
/>
)}
-
-
-
- {!!chatTabBrickRoad && (
-
- )}
-
-
- {translate('common.inbox')}
-
-
+
+
+ {!!chatTabBrickRoad && (
+
+ )}
+
+
+ {translate('common.inbox')}
+
+
+
React.ReactNode;
+};
+
+type DebugTabNavigatorRoutes = DebugTabNavigatorRoute[];
+
+type DebugTabNavigatorProps = {
+ id: string;
+ routes: DebugTabNavigatorRoutes;
+};
+
+function DebugTabNavigator({id, routes}: DebugTabNavigatorProps) {
+ const styles = useThemeStyles();
+ const theme = useTheme();
+ const navigation = useNavigation>>();
+ const {translate} = useLocalize();
+ const [currentTab, setCurrentTab] = useState(routes.at(0)?.name);
+ const defaultAffectedAnimatedTabs = useMemo(() => Array.from({length: routes.length}, (v, i) => i), [routes.length]);
+ const [affectedAnimatedTabs, setAffectedAnimatedTabs] = useState(defaultAffectedAnimatedTabs);
+
+ useEffect(() => {
+ // It is required to wait transition end to reset affectedAnimatedTabs because tabs style is still animating during transition.
+ setTimeout(() => {
+ setAffectedAnimatedTabs(defaultAffectedAnimatedTabs);
+ }, CONST.ANIMATED_TRANSITION);
+ }, [defaultAffectedAnimatedTabs, currentTab]);
+
+ return (
+ <>
+
+ {routes.map((route, index) => {
+ const isActive = route.name === currentTab;
+ const activeOpacity = getOpacity({
+ routesLength: routes.length,
+ tabIndex: index,
+ active: true,
+ affectedTabs: affectedAnimatedTabs,
+ position: undefined,
+ isActive,
+ });
+ const inactiveOpacity = getOpacity({
+ routesLength: routes.length,
+ tabIndex: index,
+ active: false,
+ affectedTabs: affectedAnimatedTabs,
+ position: undefined,
+ isActive,
+ });
+ const backgroundColor = getBackgroundColor({
+ routesLength: routes.length,
+ tabIndex: index,
+ affectedTabs: affectedAnimatedTabs,
+ theme,
+ position: undefined,
+ isActive,
+ });
+ const {icon, title} = getIconAndTitle(route.name, translate);
+
+ const onPress = () => {
+ navigation.navigate(route.name);
+ setCurrentTab(route.name);
+ };
+
+ return (
+
+ );
+ })}
+
+ {
+ const event = e as unknown as EventMapCore['state'];
+ const state = event.data.state;
+ const routeNames = state.routeNames;
+ const newSelectedTab = state.routes.at(state.routes.length - 1)?.name;
+ if (currentTab === newSelectedTab || (currentTab && !routeNames.includes(currentTab))) {
+ return;
+ }
+ setCurrentTab(newSelectedTab);
+ },
+ }}
+ >
+ {routes.map((route) => (
+
+ ))}
+
+ >
+ );
+}
+
+export default DebugTabNavigator;
+
+export type {DebugTabNavigatorRoutes};
diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
index bd4497fbcc58..9a03409fb3a2 100755
--- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
@@ -12,8 +12,6 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
SCREENS.WORKSPACE.DOWNGRADE,
],
[SCREENS.WORKSPACE.MEMBERS]: [
- SCREENS.WORKSPACE.INVITE,
- SCREENS.WORKSPACE.INVITE_MESSAGE,
SCREENS.WORKSPACE.MEMBER_DETAILS,
SCREENS.WORKSPACE.MEMBER_NEW_CARD,
SCREENS.WORKSPACE.OWNER_CHANGE_CHECK,
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 71f11113e84c..9869f4e39f94 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -190,6 +190,7 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.SHARE]: undefined;
[SCREENS.WORKSPACE.INVITE]: {
policyID: string;
+ backTo?: Routes;
};
[SCREENS.WORKSPACE.MEMBERS_IMPORT]: {
policyID: string;
@@ -199,6 +200,7 @@ type SettingsNavigatorParamList = {
};
[SCREENS.WORKSPACE.INVITE_MESSAGE]: {
policyID: string;
+ backTo?: Routes;
};
[SCREENS.WORKSPACE.CATEGORY_CREATE]: {
policyID: string;
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 16d5bb03860c..b588b3dd5359 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -7,7 +7,6 @@ import type {SetNonNullable} from 'type-fest';
import {FallbackAvatar} from '@components/Icon/Expensicons';
import type {IOUAction} from '@src/CONST';
import CONST from '@src/CONST';
-import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import type {
Beta,
@@ -148,15 +147,26 @@ type FilterUserToInviteConfig = Pick;
+
/**
* OptionsListUtils is used to build a list options passed to the OptionsList component. Several different UI views can
* be configured to display different results based on the options passed to the private getOptions() method. Public
@@ -550,9 +560,8 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails
lastMessageTextFromReport = ReportUtils.getDeletedParentActionMessageForChatReport(lastReportAction);
} else if (ReportActionUtils.isPendingRemove(lastReportAction) && ReportActionUtils.isThreadParentMessage(lastReportAction, report?.reportID ?? '-1')) {
lastMessageTextFromReport = Localize.translateLocal('parentReportAction.hiddenMessage');
- } else if (ReportUtils.isReportMessageAttachment({text: report?.lastMessageText ?? '-1', html: report?.lastMessageHtml, translationKey: report?.lastMessageTranslationKey, type: ''})) {
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- lastMessageTextFromReport = `[${Localize.translateLocal((report?.lastMessageTranslationKey || 'common.attachment') as TranslationPaths)}]`;
+ } else if (ReportUtils.isReportMessageAttachment({text: report?.lastMessageText ?? '-1', html: report?.lastMessageHtml, type: ''})) {
+ lastMessageTextFromReport = `[${Localize.translateLocal('common.attachment')}]`;
} else if (ReportActionUtils.isModifiedExpenseAction(lastReportAction)) {
const properSchemaForModifiedExpenseMessage = ModifiedExpenseMessage.getForReportAction(report?.reportID, lastReportAction);
lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForModifiedExpenseMessage, true);
@@ -946,7 +955,7 @@ function orderReportOptions(options: ReportUtils.OptionData[]) {
function orderReportOptionsWithSearch(
options: ReportUtils.OptionData[],
searchValue: string,
- {preferChatroomsOverThreads = false, preferPolicyExpenseChat = false, preferRecentExpenseReports = false}: OrderOptionsConfig = {},
+ {preferChatroomsOverThreads = false, preferPolicyExpenseChat = false, preferRecentExpenseReports = false}: OrderReportOptionsConfig = {},
) {
const orderedByDate = orderReportOptions(options);
@@ -1002,11 +1011,16 @@ function sortComparatorReportOptionByDate(options: ReportUtils.OptionData) {
return options.lastVisibleActionCreated ?? '';
}
-type ReportAndPersonalDetailOptions = Pick;
-
+/**
+ * Sorts reports and personal details independently.
+ */
function orderOptions(options: ReportAndPersonalDetailOptions): ReportAndPersonalDetailOptions;
-function orderOptions(options: ReportAndPersonalDetailOptions, searchValue: string, config?: OrderOptionsConfig): ReportAndPersonalDetailOptions;
-function orderOptions(options: ReportAndPersonalDetailOptions, searchValue?: string, config?: OrderOptionsConfig) {
+
+/**
+ * Sorts reports and personal details independently, but prioritizes the search value.
+ */
+function orderOptions(options: ReportAndPersonalDetailOptions, searchValue: string, config?: OrderReportOptionsConfig): ReportAndPersonalDetailOptions;
+function orderOptions(options: ReportAndPersonalDetailOptions, searchValue?: string, config?: OrderReportOptionsConfig): ReportAndPersonalDetailOptions {
let orderedReportOptions: ReportUtils.OptionData[];
if (searchValue) {
orderedReportOptions = orderReportOptionsWithSearch(options.recentReports, searchValue, config);
@@ -1025,12 +1039,20 @@ function canCreateOptimisticPersonalDetailOption({
recentReportOptions,
personalDetailsOptions,
currentUserOption,
+ searchValue,
}: {
recentReportOptions: ReportUtils.OptionData[];
personalDetailsOptions: ReportUtils.OptionData[];
currentUserOption?: ReportUtils.OptionData | null;
+ searchValue: string;
}) {
- return recentReportOptions.length + personalDetailsOptions.length === 0 && !currentUserOption;
+ if (recentReportOptions.length + personalDetailsOptions.length > 0) {
+ return false;
+ }
+ if (!currentUserOption) {
+ return true;
+ }
+ return currentUserOption.login !== PhoneNumber.addSMSDomainIfPhoneNumber(searchValue ?? '').toLowerCase() && currentUserOption.login !== searchValue?.toLowerCase();
}
/**
@@ -1695,6 +1717,7 @@ function filterUserToInvite(options: Omit, searchValue:
recentReportOptions: options.recentReports,
personalDetailsOptions: options.personalDetails,
currentUserOption: options.currentUserOption,
+ searchValue,
});
if (!canCreateOptimisticDetail) {
@@ -1739,48 +1762,58 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt
};
}
-type FilterAndOrderConfig = FilterUserToInviteConfig & OrderOptionsConfig;
+type AllOrderConfigs = OrderReportOptionsConfig & OrderOptionsConfig;
+type FilterAndOrderConfig = FilterUserToInviteConfig & AllOrderConfigs;
+
+/**
+ * Orders the reports and personal details based on the search input value.
+ * Personal details will be filtered out if they are part of the recent reports.
+ * Additional configs can be applied.
+ */
+function combineOrderingOfReportsAndPersonalDetails(
+ options: ReportAndPersonalDetailOptions,
+ searchInputValue: string,
+ {maxRecentReportsToShow, sortByReportTypeInSearch, ...orderReportOptionsConfig}: AllOrderConfigs = {},
+): ReportAndPersonalDetailOptions {
+ // sortByReportTypeInSearch will show the personal details as part of the recent reports
+ if (sortByReportTypeInSearch) {
+ const personalDetailsWithoutDMs = filteredPersonalDetailsOfRecentReports(options.recentReports, options.personalDetails);
+ const reportsAndPersonalDetails = options.recentReports.concat(personalDetailsWithoutDMs);
+ return orderOptions({recentReports: reportsAndPersonalDetails, personalDetails: []}, searchInputValue, orderReportOptionsConfig);
+ }
+
+ let orderedReports = orderReportOptionsWithSearch(options.recentReports, searchInputValue, orderReportOptionsConfig);
+ if (typeof maxRecentReportsToShow === 'number') {
+ orderedReports = orderedReports.slice(0, maxRecentReportsToShow);
+ }
+
+ const personalDetailsWithoutDMs = filteredPersonalDetailsOfRecentReports(orderedReports, options.personalDetails);
+ const orderedPersonalDetails = orderPersonalDetailsOptions(personalDetailsWithoutDMs);
+
+ return {
+ recentReports: orderedReports,
+ personalDetails: orderedPersonalDetails,
+ };
+}
/**
* Filters and orders the options based on the search input value.
* Note that personal details that are part of the recent reports will always be shown as part of the recent reports (ie. DMs).
*/
function filterAndOrderOptions(options: Options, searchInputValue: string, config: FilterAndOrderConfig = {}): Options {
- const {sortByReportTypeInSearch = false} = config;
-
let filterResult = options;
if (searchInputValue.trim().length > 0) {
filterResult = filterOptions(options, searchInputValue, config);
}
- let {recentReports: filteredReports, personalDetails: filteredPersonalDetails} = filterResult;
+ const orderedOptions = combineOrderingOfReportsAndPersonalDetails(filterResult, searchInputValue, config);
// on staging server, in specific cases (see issue) BE returns duplicated personalDetails entries
- filteredPersonalDetails = filteredPersonalDetails.filter((detail, index, array) => array.findIndex((i) => i.login === detail.login) === index);
-
- if (typeof config?.maxRecentReportsToShow === 'number') {
- filteredReports = orderReportOptionsWithSearch(filteredReports, searchInputValue, config);
- filteredReports = filteredReports.slice(0, config.maxRecentReportsToShow);
- }
-
- const personalDetailsWithoutDMs = filteredPersonalDetailsOfRecentReports(filteredReports, filteredPersonalDetails);
- const orderedPersonalDetails = orderPersonalDetailsOptions(personalDetailsWithoutDMs);
-
- // sortByReportTypeInSearch option will show the personal details as part of the recent reports
- if (sortByReportTypeInSearch) {
- filteredReports = filteredReports.concat(orderedPersonalDetails);
- filteredPersonalDetails = [];
- } else {
- filteredPersonalDetails = orderedPersonalDetails;
- }
-
- const orderedReports = orderReportOptionsWithSearch(filteredReports, searchInputValue, config);
+ orderedOptions.personalDetails = orderedOptions.personalDetails.filter((detail, index, array) => array.findIndex((i) => i.login === detail.login) === index);
return {
- recentReports: orderedReports,
- personalDetails: filteredPersonalDetails,
- userToInvite: filterResult.userToInvite,
- currentUserOption: filterResult.currentUserOption,
+ ...filterResult,
+ ...orderedOptions,
};
}
@@ -1829,12 +1862,13 @@ export {
formatMemberForList,
formatSectionsFromSearchTerm,
getShareLogOptions,
+ orderOptions,
+ filterUserToInvite,
filterOptions,
filteredPersonalDetailsOfRecentReports,
orderReportOptions,
orderReportOptionsWithSearch,
orderPersonalDetailsOptions,
- orderOptions,
filterAndOrderOptions,
createOptionList,
createOptionFromReport,
@@ -1849,6 +1883,7 @@ export {
getAttendeeOptions,
getAlternateText,
hasReportErrors,
+ combineOrderingOfReportsAndPersonalDetails,
};
-export type {Section, SectionBase, MemberForList, Options, OptionList, SearchOption, PayeePersonalDetails, Option, OptionTree};
+export type {Section, SectionBase, MemberForList, Options, OptionList, SearchOption, PayeePersonalDetails, Option, OptionTree, ReportAndPersonalDetailOptions, GetUserToInviteConfig};
diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts
index bebd54698288..f9bf53bdd362 100644
--- a/src/libs/Permissions.ts
+++ b/src/libs/Permissions.ts
@@ -35,11 +35,6 @@ function canUsePerDiem(betas: OnyxEntry): boolean {
return !!betas?.includes(CONST.BETAS.PER_DIEM) || canUseAllBetas(betas);
}
-// TEMPORARY BETA TO HIDE PRODUCT TRAINING TOOLTIP AND MIGRATE USER WELCOME MODAL
-function shouldShowProductTrainingElements(betas: OnyxEntry): boolean {
- return !!betas?.includes(CONST.BETAS.PRODUCT_TRAINING) || canUseAllBetas(betas);
-}
-
/**
* Link previews are temporarily disabled.
*/
@@ -47,14 +42,6 @@ function canUseLinkPreviews(): boolean {
return false;
}
-/**
- * Workspace downgrade is temporarily disabled
- * API is being integrated in this GH issue https://github.com/Expensify/App/issues/51494
- */
-function canUseWorkspaceDowngrade() {
- return false;
-}
-
export default {
canUseDefaultRooms,
canUseLinkPreviews,
@@ -63,6 +50,4 @@ export default {
canUseCombinedTrackSubmit,
canUseCategoryAndTagApprovers,
canUsePerDiem,
- canUseWorkspaceDowngrade,
- shouldShowProductTrainingElements,
};
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index 4982e8660dec..91381a0b0119 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -246,7 +246,13 @@ const isPolicyUser = (policy: OnyxInputOrEntry, currentUserLogin?: strin
const isPolicyAuditor = (policy: OnyxInputOrEntry, currentUserLogin?: string): boolean =>
(policy?.role ?? (currentUserLogin && policy?.employeeList?.[currentUserLogin]?.role)) === CONST.POLICY.ROLE.AUDITOR;
-const isPolicyEmployee = (policyID: string, policies: OnyxCollection): boolean => Object.values(policies ?? {}).some((policy) => policy?.id === policyID);
+const isPolicyEmployee = (policyID: string | undefined, policies: OnyxCollection): boolean => {
+ if (!policyID) {
+ return false;
+ }
+
+ return Object.values(policies ?? {}).some((policy) => policy?.id === policyID);
+};
/**
* Checks if the current user is an owner (creator) of the policy.
@@ -406,6 +412,10 @@ function isControlPolicy(policy: OnyxEntry): boolean {
return policy?.type === CONST.POLICY.TYPE.CORPORATE;
}
+function isCollectPolicy(policy: OnyxEntry): boolean {
+ return policy?.type === CONST.POLICY.TYPE.TEAM;
+}
+
function isTaxTrackingEnabled(isPolicyExpenseChat: boolean, policy: OnyxEntry, isDistanceRequest: boolean): boolean {
const distanceUnit = getDistanceRateCustomUnit(policy);
const customUnitID = distanceUnit?.customUnitID ?? CONST.DEFAULT_NUMBER_ID;
@@ -1093,7 +1103,7 @@ function getCurrentTaxID(policy: OnyxEntry, taxID: string): string | und
return Object.keys(policy?.taxRates?.taxes ?? {}).find((taxIDKey) => policy?.taxRates?.taxes?.[taxIDKey].previousTaxCode === taxID || taxIDKey === taxID);
}
-function getWorkspaceAccountID(policyID: string) {
+function getWorkspaceAccountID(policyID?: string) {
const policy = getPolicy(policyID);
if (!policy) {
@@ -1108,6 +1118,10 @@ function hasVBBA(policyID: string) {
}
function getTagApproverRule(policyOrID: string | SearchPolicy | OnyxEntry, tagName: string) {
+ if (!policyOrID) {
+ return;
+ }
+
const policy = typeof policyOrID === 'string' ? getPolicy(policyOrID) : policyOrID;
const approvalRules = policy?.rules?.approvalRules ?? [];
@@ -1165,6 +1179,11 @@ function areAllGroupPoliciesExpenseChatDisabled(policies = allPolicies) {
return !groupPolicies.some((policy) => !!policy?.isPolicyExpenseChatEnabled);
}
+function hasOtherControlWorkspaces(currentPolicyID: string) {
+ const otherControlWorkspaces = Object.values(allPolicies ?? {}).filter((policy) => policy?.id !== currentPolicyID && isPolicyAdmin(policy) && isControlPolicy(policy));
+ return otherControlWorkspaces.length > 0;
+}
+
export {
canEditTaxRate,
extractPolicyIDFromPath,
@@ -1265,6 +1284,7 @@ export {
getApprovalWorkflow,
getReimburserAccountID,
isControlPolicy,
+ isCollectPolicy,
isNetSuiteCustomSegmentRecord,
getNameFromNetSuiteCustomField,
isNetSuiteCustomFieldPropertyEditable,
@@ -1288,6 +1308,7 @@ export {
getUserFriendlyWorkspaceType,
isPolicyAccessible,
areAllGroupPoliciesExpenseChatDisabled,
+ hasOtherControlWorkspaces,
getManagerAccountEmail,
getRuleApprovers,
};
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index 455a125ad0c3..4fa704944bc9 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -33,7 +33,6 @@ import StringUtils from './StringUtils';
import * as TransactionUtils from './TransactionUtils';
type LastVisibleMessage = {
- lastMessageTranslationKey?: string;
lastMessageText: string;
lastMessageHtml?: string;
};
@@ -812,7 +811,6 @@ function getLastVisibleMessage(
if (message && isReportMessageAttachment(message)) {
return {
- lastMessageTranslationKey: CONST.TRANSLATION_KEYS.ATTACHMENT,
lastMessageText: CONST.ATTACHMENT_MESSAGE_TEXT,
lastMessageHtml: CONST.TRANSLATION_KEYS.ATTACHMENT,
};
@@ -929,7 +927,11 @@ function getLinkedTransactionID(reportActionOrID: string | OnyxEntry isActionableTrackExpense(action) && getOriginalMessage(action)?.transactionID === transactionID);
}
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index c20ec7386b0a..e4480c511931 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -52,8 +52,9 @@ import type {ErrorFields, Errors, Icon, PendingAction} from '@src/types/onyx/Ony
import type {OriginalMessageChangeLog, PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import type {Status} from '@src/types/onyx/PersonalDetails';
import type {ConnectionName} from '@src/types/onyx/Policy';
-import type {NotificationPreference, Participants, PendingChatMember, Participant as ReportParticipant} from '@src/types/onyx/Report';
+import type {NotificationPreference, Participants, Participant as ReportParticipant} from '@src/types/onyx/Report';
import type {Message, OldDotReportAction, ReportActions} from '@src/types/onyx/ReportAction';
+import type {PendingChatMember} from '@src/types/onyx/ReportMetadata';
import type {SearchPolicy, SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults';
import type {Comment, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
@@ -314,7 +315,6 @@ type OptimisticChatReport = Pick<
| 'isOwnPolicyExpenseChat'
| 'isPinned'
| 'lastActorAccountID'
- | 'lastMessageTranslationKey'
| 'lastMessageHtml'
| 'lastMessageText'
| 'lastReadTime'
@@ -385,6 +385,7 @@ type OptimisticWorkspaceChats = {
expenseChatData: OptimisticChatReport;
expenseReportActionData: Record;
expenseCreatedReportActionID: string;
+ pendingChatMembers: PendingChatMember[];
};
type OptimisticModifiedExpenseReportAction = Pick<
@@ -702,6 +703,7 @@ Onyx.connect({
});
let allReportMetadata: OnyxCollection;
+const allReportMetadataKeyValue: Record = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT_METADATA,
waitForCollectionCallback: true,
@@ -710,6 +712,15 @@ Onyx.connect({
return;
}
allReportMetadata = value;
+
+ Object.entries(value).forEach(([reportID, reportMetadata]) => {
+ if (!reportMetadata) {
+ return;
+ }
+
+ const [, id] = reportID.split('_');
+ allReportMetadataKeyValue[id] = reportMetadata;
+ });
},
});
@@ -737,7 +748,7 @@ Onyx.connect({
},
});
-let onboarding: OnyxEntry;
+let onboarding: OnyxEntry;
Onyx.connect({
key: ONYXKEYS.NVP_ONBOARDING,
callback: (value) => (onboarding = value),
@@ -1760,7 +1771,11 @@ function isPayAtEndExpenseReport(reportID: string, transactions: Transaction[] |
/**
* Checks if a report is a transaction thread associated with a report that has only one transaction
*/
-function isOneTransactionThread(reportID: string, parentReportID: string, threadParentReportAction: OnyxEntry): boolean {
+function isOneTransactionThread(reportID: string | undefined, parentReportID: string | undefined, threadParentReportAction: OnyxEntry): boolean {
+ if (!reportID || !parentReportID) {
+ return false;
+ }
+
const parentReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`] ?? ([] as ReportAction[]);
const transactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(parentReportID, parentReportActions);
return reportID === transactionThreadReportID && !ReportActionsUtils.isSentMoneyReportAction(threadParentReportAction);
@@ -2214,6 +2229,7 @@ function getDisplayNameForParticipant(
function getParticipantsAccountIDsForDisplay(report: OnyxEntry, shouldExcludeHidden = false, shouldExcludeDeleted = false, shouldForceExcludeCurrentUser = false): number[] {
const reportParticipants = report?.participants ?? {};
+ const reportMetadata = getReportMetadata(report?.reportID);
let participantsEntries = Object.entries(reportParticipants);
// We should not show participants that have an optimistic entry with the same login in the personal details
@@ -2253,7 +2269,7 @@ function getParticipantsAccountIDsForDisplay(report: OnyxEntry, shouldEx
if (
shouldExcludeDeleted &&
- report?.pendingChatMembers?.findLast((member) => Number(member.accountID) === accountID)?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE
+ reportMetadata?.pendingChatMembers?.findLast((member) => Number(member.accountID) === accountID)?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE
) {
return false;
}
@@ -2314,8 +2330,10 @@ function getGroupChatName(participants?: SelectedParticipant[], shouldApplyLimit
return report.reportName;
}
+ const reportMetadata = getReportMetadata(report?.reportID);
+
const pendingMemberAccountIDs = new Set(
- report?.pendingChatMembers?.filter((member) => member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).map((member) => member.accountID),
+ reportMetadata?.pendingChatMembers?.filter((member) => member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).map((member) => member.accountID),
);
let participantAccountIDs =
participants?.map((participant) => participant.accountID) ??
@@ -3018,7 +3036,11 @@ function getTitleReportField(reportFields: Record) {
/**
* Get the key for a report field
*/
-function getReportFieldKey(reportFieldId: string) {
+function getReportFieldKey(reportFieldId: string | undefined) {
+ if (!reportFieldId) {
+ return '';
+ }
+
// We don't need to add `expensify_` prefix to the title field key, because backend stored title under a unique key `text_title`,
// and all the other report field keys are stored under `expensify_FIELD_ID`.
if (reportFieldId === CONST.REPORT_FIELD_TITLE_FIELD_ID) {
@@ -5506,7 +5528,6 @@ function buildOptimisticChatReport(
isOwnPolicyExpenseChat,
isPinned: isNewlyCreatedWorkspaceChat,
lastActorAccountID: 0,
- lastMessageTranslationKey: '',
lastMessageHtml: '',
lastMessageText: undefined,
lastReadTime: currentTime,
@@ -6073,7 +6094,6 @@ function buildOptimisticWorkspaceChats(policyID: string, policyName: string, exp
false,
policyName,
),
- pendingChatMembers,
};
const adminsChatReportID = adminsChatData.reportID;
const adminsCreatedAction = buildOptimisticCreatedReportAction(CONST.POLICY.OWNER_EMAIL_FAKE);
@@ -6113,6 +6133,7 @@ function buildOptimisticWorkspaceChats(policyID: string, policyName: string, exp
expenseChatData,
expenseReportActionData,
expenseCreatedReportActionID: expenseReportCreatedAction.reportActionID,
+ pendingChatMembers,
};
}
@@ -6301,12 +6322,12 @@ function isEmptyReport(report: OnyxEntry): boolean {
return true;
}
- if (report.lastMessageText ?? report.lastMessageTranslationKey) {
+ if (report.lastMessageText) {
return false;
}
const lastVisibleMessage = getLastVisibleMessage(report.reportID);
- return !lastVisibleMessage.lastMessageText && !lastVisibleMessage.lastMessageTranslationKey;
+ return !lastVisibleMessage.lastMessageText;
}
function isUnread(report: OnyxEntry): boolean {
@@ -7799,7 +7820,7 @@ function hasHeldExpenses(iouReportID?: string, allReportTransactions?: SearchTra
/**
* Check if all expenses in the Report are on hold
*/
-function hasOnlyHeldExpenses(iouReportID: string, allReportTransactions?: SearchTransaction[]): boolean {
+function hasOnlyHeldExpenses(iouReportID?: string, allReportTransactions?: SearchTransaction[]): boolean {
const reportTransactions = allReportTransactions ?? reportsTransactions[iouReportID ?? ''] ?? [];
return reportTransactions.length > 0 && !reportTransactions.some((transaction) => !TransactionUtils.isOnHold(transaction));
}
@@ -8227,7 +8248,16 @@ function createDraftWorkspaceAndNavigateToConfirmationScreen(transactionID: stri
}
}
-function createDraftTransactionAndNavigateToParticipantSelector(transactionID: string, reportID: string, actionName: IOUAction, reportActionID: string): void {
+function createDraftTransactionAndNavigateToParticipantSelector(
+ transactionID: string | undefined,
+ reportID: string | undefined,
+ actionName: IOUAction,
+ reportActionID: string | undefined,
+): void {
+ if (!transactionID || !reportID || !reportActionID) {
+ return;
+ }
+
const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? ({} as Transaction);
const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? ([] as ReportAction[]);
@@ -8354,8 +8384,8 @@ function shouldShowMerchantColumn(transactions: Transaction[]) {
* only use the Concierge chat.
*/
function isChatUsedForOnboarding(optionOrReport: OnyxEntry | OptionData): boolean {
- // onboarding can be an array or an empty object for old accounts and accounts created from olddot
- if (onboarding && !Array.isArray(onboarding) && !isEmptyObject(onboarding) && onboarding.chatReportID) {
+ // onboarding can be an empty object for old accounts and accounts created from olddot
+ if (onboarding && !isEmptyObject(onboarding) && onboarding.chatReportID) {
return onboarding.chatReportID === optionOrReport?.reportID;
}
@@ -8421,20 +8451,18 @@ function findPolicyExpenseChatByPolicyID(policyID: string): OnyxEntry {
*/
function getReportLastMessage(reportID: string, actionsToMerge?: ReportActions) {
let result: Partial = {
- lastMessageTranslationKey: '',
lastMessageText: '',
lastVisibleActionCreated: '',
};
- const {lastMessageText = '', lastMessageTranslationKey = ''} = getLastVisibleMessage(reportID, actionsToMerge);
+ const {lastMessageText = ''} = getLastVisibleMessage(reportID, actionsToMerge);
- if (lastMessageText || lastMessageTranslationKey) {
+ if (lastMessageText) {
const report = getReport(reportID);
const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID, canUserPerformWriteAction(report), actionsToMerge);
const lastVisibleActionCreated = lastVisibleAction?.created;
const lastActorAccountID = lastVisibleAction?.actorAccountID;
result = {
- lastMessageTranslationKey,
lastMessageText,
lastVisibleActionCreated,
lastActorAccountID,
@@ -8542,7 +8570,7 @@ function hasInvoiceReports() {
}
function getReportMetadata(reportID?: string) {
- return allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`];
+ return allReportMetadataKeyValue[reportID ?? '-1'];
}
export {
diff --git a/src/libs/SuffixUkkonenTree/index.ts b/src/libs/SuffixUkkonenTree/index.ts
new file mode 100644
index 000000000000..bcefd1008493
--- /dev/null
+++ b/src/libs/SuffixUkkonenTree/index.ts
@@ -0,0 +1,211 @@
+/* eslint-disable rulesdir/prefer-at */
+// .at() has a performance overhead we explicitly want to avoid here
+
+/* eslint-disable no-continue */
+import {ALPHABET_SIZE, DELIMITER_CHAR_CODE, END_CHAR_CODE, SPECIAL_CHAR_CODE, stringToNumeric} from './utils';
+
+/**
+ * This implements a suffix tree using Ukkonen's algorithm.
+ * A good visualization to learn about the algorithm can be found here: https://brenden.github.io/ukkonen-animation/
+ * A good video explaining Ukkonen's algorithm can be found here: https://www.youtube.com/watch?v=ALEV0Hc5dDk
+ * Note: This implementation is optimized for performance, not necessarily for readability.
+ *
+ * You probably don't want to use this directly, but rather use @libs/FastSearch.ts as a easy to use wrapper around this.
+ */
+
+/**
+ * Creates a new tree instance that can be used to build a suffix tree and search in it.
+ * The input is a numeric representation of the search string, which can be created using {@link stringToNumeric}.
+ * Separate search values must be separated by the {@link DELIMITER_CHAR_CODE}. The search string must end with the {@link END_CHAR_CODE}.
+ *
+ * The tree will be built using the Ukkonen's algorithm: https://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf
+ */
+function makeTree(numericSearchValues: Uint8Array) {
+ // Every leaf represents a suffix. There can't be more than n suffixes.
+ // Every internal node has to have at least 2 children. So the total size of ukkonen tree is not bigger than 2n - 1.
+ // + 1 is because an extra character at the beginning to offset the 1-based indexing.
+ const maxNodes = 2 * numericSearchValues.length + 1;
+ /*
+ This array represents all internal nodes in the suffix tree.
+ When building this tree, we'll be given a character in the string, and we need to be able to lookup in constant time
+ if there's any edge connected to a node starting with that character. For example, given a tree like this:
+
+ root
+ / | \
+ a b c
+
+ and the next character in our string is 'd', we need to be able do check if any of the edges from the root node
+ start with the letter 'd', without looping through all the edges.
+
+ To accomplish this, each node gets an array matching the alphabet size.
+ So you can imagine if our alphabet was just [a,b,c,d], then each node would get an array like [0,0,0,0].
+ If we add an edge starting with 'a', then the root node would be [1,0,0,0]
+ So given an arbitrary letter such as 'd', then we can take the position of that letter in its alphabet (position 3 in our example)
+ and check whether that index in the array is 0 or 1. If it's a 1, then there's an edge starting with the letter 'd'.
+
+ Note that for efficiency, all nodes are stored in a single flat array. That's how we end up with (maxNodes * alphabet_size).
+ In the example of a 4-character alphabet, we'd have an array like this:
+
+ root root.left root.right last possible node
+ / \ / \ / \ / \
+ [0,0,0,0, 0,0,0,0, 0,0,0,0, ................. 0,0,0,0]
+ */
+ const transitionNodes = new Uint32Array(maxNodes * ALPHABET_SIZE);
+
+ // Storing the range of the original string that each node represents:
+ const rangeStart = new Uint32Array(maxNodes);
+ const rangeEnd = new Uint32Array(maxNodes);
+
+ const parent = new Uint32Array(maxNodes);
+ const suffixLink = new Uint32Array(maxNodes);
+
+ let currentNode = 1;
+ let currentPosition = 1;
+ let nodeCounter = 3;
+ let currentIndex = 1;
+
+ function initializeTree() {
+ rangeEnd.fill(numericSearchValues.length);
+ rangeEnd[1] = 0;
+ rangeEnd[2] = 0;
+ suffixLink[1] = 2;
+ for (let i = 0; i < ALPHABET_SIZE; ++i) {
+ transitionNodes[ALPHABET_SIZE * 2 + i] = 1;
+ }
+ }
+
+ function processCharacter(char: number) {
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ if (rangeEnd[currentNode] < currentPosition) {
+ if (transitionNodes[currentNode * ALPHABET_SIZE + char] === 0) {
+ createNewLeaf(char);
+ continue;
+ }
+ currentNode = transitionNodes[currentNode * ALPHABET_SIZE + char];
+ currentPosition = rangeStart[currentNode];
+ }
+ if (currentPosition === 0 || char === numericSearchValues[currentPosition]) {
+ currentPosition++;
+ } else {
+ splitEdge(char);
+ continue;
+ }
+ break;
+ }
+ }
+
+ function createNewLeaf(c: number) {
+ transitionNodes[currentNode * ALPHABET_SIZE + c] = nodeCounter;
+ rangeStart[nodeCounter] = currentIndex;
+ parent[nodeCounter++] = currentNode;
+ currentNode = suffixLink[currentNode];
+
+ currentPosition = rangeEnd[currentNode] + 1;
+ }
+
+ function splitEdge(c: number) {
+ rangeStart[nodeCounter] = rangeStart[currentNode];
+ rangeEnd[nodeCounter] = currentPosition - 1;
+ parent[nodeCounter] = parent[currentNode];
+
+ transitionNodes[nodeCounter * ALPHABET_SIZE + numericSearchValues[currentPosition]] = currentNode;
+ transitionNodes[nodeCounter * ALPHABET_SIZE + c] = nodeCounter + 1;
+ rangeStart[nodeCounter + 1] = currentIndex;
+ parent[nodeCounter + 1] = nodeCounter;
+ rangeStart[currentNode] = currentPosition;
+ parent[currentNode] = nodeCounter;
+
+ transitionNodes[parent[nodeCounter] * ALPHABET_SIZE + numericSearchValues[rangeStart[nodeCounter]]] = nodeCounter;
+ nodeCounter += 2;
+ handleDescent(nodeCounter);
+ }
+
+ function handleDescent(latestNodeIndex: number) {
+ currentNode = suffixLink[parent[latestNodeIndex - 2]];
+ currentPosition = rangeStart[latestNodeIndex - 2];
+ while (currentPosition <= rangeEnd[latestNodeIndex - 2]) {
+ currentNode = transitionNodes[currentNode * ALPHABET_SIZE + numericSearchValues[currentPosition]];
+ currentPosition += rangeEnd[currentNode] - rangeStart[currentNode] + 1;
+ }
+ if (currentPosition === rangeEnd[latestNodeIndex - 2] + 1) {
+ suffixLink[latestNodeIndex - 2] = currentNode;
+ } else {
+ suffixLink[latestNodeIndex - 2] = latestNodeIndex;
+ }
+ currentPosition = rangeEnd[currentNode] - (currentPosition - rangeEnd[latestNodeIndex - 2]) + 2;
+ }
+
+ function build() {
+ initializeTree();
+ for (currentIndex = 1; currentIndex < numericSearchValues.length; ++currentIndex) {
+ const c = numericSearchValues[currentIndex];
+ processCharacter(c);
+ }
+ }
+
+ /**
+ * Returns all occurrences of the given (sub)string in the input string.
+ *
+ * You can think of the tree that we create as a big string that looks like this:
+ *
+ * "banana$pancake$apple|"
+ * The example delimiter character '$' is used to separate the different strings.
+ * The end character '|' is used to indicate the end of our search string.
+ *
+ * This function will return the index(es) of found occurrences within this big string.
+ * So, when searching for "an", it would return [1, 3, 8].
+ */
+ function findSubstring(searchValue: number[]) {
+ const occurrences: number[] = [];
+
+ function dfs(node: number, depth: number) {
+ const leftRange = rangeStart[node];
+ const rightRange = rangeEnd[node];
+ const rangeLen = node === 1 ? 0 : rightRange - leftRange + 1;
+
+ for (let i = 0; i < rangeLen && depth + i < searchValue.length && leftRange + i < numericSearchValues.length; i++) {
+ if (searchValue[depth + i] !== numericSearchValues[leftRange + i]) {
+ return;
+ }
+ }
+
+ let isLeaf = true;
+ for (let i = 0; i < ALPHABET_SIZE; ++i) {
+ const tNode = transitionNodes[node * ALPHABET_SIZE + i];
+
+ // Search speed optimization: don't go through the edge if it's different than the next char:
+ const correctChar = depth + rangeLen >= searchValue.length || i === searchValue[depth + rangeLen];
+
+ if (tNode !== 0 && tNode !== 1 && correctChar) {
+ isLeaf = false;
+ dfs(tNode, depth + rangeLen);
+ }
+ }
+
+ if (isLeaf && depth + rangeLen >= searchValue.length) {
+ occurrences.push(numericSearchValues.length - (depth + rangeLen) + 1);
+ }
+ }
+
+ dfs(1, 0);
+ return occurrences;
+ }
+
+ return {
+ build,
+ findSubstring,
+ };
+}
+
+const SuffixUkkonenTree = {
+ makeTree,
+
+ // Re-exported from utils:
+ DELIMITER_CHAR_CODE,
+ SPECIAL_CHAR_CODE,
+ END_CHAR_CODE,
+ stringToNumeric,
+};
+
+export default SuffixUkkonenTree;
diff --git a/src/libs/SuffixUkkonenTree/utils.ts b/src/libs/SuffixUkkonenTree/utils.ts
new file mode 100644
index 000000000000..96ee35b15796
--- /dev/null
+++ b/src/libs/SuffixUkkonenTree/utils.ts
@@ -0,0 +1,115 @@
+/* eslint-disable rulesdir/prefer-at */ // .at() has a performance overhead we explicitly want to avoid here
+/* eslint-disable no-continue */
+
+const CHAR_CODE_A = 'a'.charCodeAt(0);
+const ALPHABET = 'abcdefghijklmnopqrstuvwxyz';
+const LETTER_ALPHABET_SIZE = ALPHABET.length;
+const ALPHABET_SIZE = LETTER_ALPHABET_SIZE + 3; // +3: special char, delimiter char, end char
+const SPECIAL_CHAR_CODE = ALPHABET_SIZE - 3;
+const DELIMITER_CHAR_CODE = ALPHABET_SIZE - 2;
+const END_CHAR_CODE = ALPHABET_SIZE - 1;
+
+// Store the results for a char code in a lookup table to avoid recalculating the same values (performance optimization)
+const base26LookupTable = new Array();
+
+/**
+ * Converts a number to a base26 representation.
+ */
+function convertToBase26(num: number): number[] {
+ if (base26LookupTable[num]) {
+ return base26LookupTable[num];
+ }
+ if (num < 0) {
+ throw new Error('convertToBase26: Input must be a non-negative integer');
+ }
+
+ const result: number[] = [];
+
+ do {
+ // eslint-disable-next-line no-param-reassign
+ num--;
+ result.unshift(num % 26);
+ // eslint-disable-next-line no-bitwise, no-param-reassign
+ num >>= 5; // Equivalent to Math.floor(num / 26), but faster
+ } while (num > 0);
+
+ base26LookupTable[num] = result;
+ return result;
+}
+
+/**
+ * Converts a string to an array of numbers representing the characters of the string.
+ * Every number in the array is in the range [0, ALPHABET_SIZE-1] (0-28).
+ *
+ * The numbers are offset by the character code of 'a' (97).
+ * - This is so that the numbers from a-z are in the range 0-28.
+ * - 26 is for encoding special characters. Character numbers that are not within the range of a-z will be encoded as "specialCharacter + base26(charCode)"
+ * - 27 is for the delimiter character
+ * - 28 is for the end character
+ *
+ * Note: The string should be converted to lowercase first (otherwise uppercase letters get base26'ed taking more space than necessary).
+ */
+function stringToNumeric(
+ // The string we want to convert to a numeric representation
+ input: string,
+ options?: {
+ // A set of characters that should be skipped and not included in the numeric representation
+ charSetToSkip?: Set;
+ // When out is provided, the function will write the result to the provided arrays instead of creating new ones (performance)
+ out?: {
+ outArray: Uint8Array;
+ // As outArray is a ArrayBuffer we need to keep track of the current offset
+ offset: {value: number};
+ // A map of to map the found occurrences to the correct data set
+ // As the search string can be very long for high traffic accounts (500k+), this has to be big enough, thus its a Uint32Array
+ outOccurrenceToIndex?: Uint32Array;
+ // The index that will be used in the outOccurrenceToIndex array (this is the index of your original data position)
+ index?: number;
+ };
+ // By default false. By default the outArray may be larger than necessary. If clamp is set to true the outArray will be clamped to the actual size.
+ clamp?: boolean;
+ },
+): {
+ numeric: Uint8Array;
+ occurrenceToIndex: Uint32Array;
+ offset: {value: number};
+} {
+ // The out array might be longer than our input string length, because we encode special characters as multiple numbers using the base26 encoding.
+ // * 6 is because the upper limit of encoding any char in UTF-8 to base26 is at max 6 numbers.
+ const outArray = options?.out?.outArray ?? new Uint8Array(input.length * 6);
+ const offset = options?.out?.offset ?? {value: 0};
+ const occurrenceToIndex = options?.out?.outOccurrenceToIndex ?? new Uint32Array(input.length * 16 * 4);
+ const index = options?.out?.index ?? 0;
+
+ for (let i = 0; i < input.length; i++) {
+ const char = input[i];
+
+ if (options?.charSetToSkip?.has(char)) {
+ continue;
+ }
+
+ if (char >= 'a' && char <= 'z') {
+ // char is an alphabet character
+ occurrenceToIndex[offset.value] = index;
+ outArray[offset.value++] = char.charCodeAt(0) - CHAR_CODE_A;
+ } else {
+ const charCode = input.charCodeAt(i);
+ occurrenceToIndex[offset.value] = index;
+ outArray[offset.value++] = SPECIAL_CHAR_CODE;
+ const asBase26Numeric = convertToBase26(charCode);
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of
+ for (let j = 0; j < asBase26Numeric.length; j++) {
+ occurrenceToIndex[offset.value] = index;
+ outArray[offset.value++] = asBase26Numeric[j];
+ }
+ }
+ }
+
+ return {
+ numeric: options?.clamp ? outArray.slice(0, offset.value) : outArray,
+ occurrenceToIndex,
+ offset,
+ };
+}
+
+export {stringToNumeric, ALPHABET, ALPHABET_SIZE, SPECIAL_CHAR_CODE, DELIMITER_CHAR_CODE, END_CHAR_CODE};
diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts
index 6643cd721d45..21c346f613b5 100644
--- a/src/libs/TransactionUtils/index.ts
+++ b/src/libs/TransactionUtils/index.ts
@@ -703,7 +703,7 @@ function hasMissingSmartscanFields(transaction: OnyxInputOrEntry):
/**
* Get all transaction violations of the transaction with given tranactionID.
*/
-function getTransactionViolations(transactionID: string, transactionViolations: OnyxCollection | null): TransactionViolations | null {
+function getTransactionViolations(transactionID: string | undefined, transactionViolations: OnyxCollection | null): TransactionViolations | null {
return transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID] ?? null;
}
@@ -723,7 +723,7 @@ function hasPendingRTERViolation(transactionViolations?: TransactionViolations |
/**
* Check if there is broken connection violation.
*/
-function hasBrokenConnectionViolation(transactionID: string): boolean {
+function hasBrokenConnectionViolation(transactionID?: string): boolean {
const violations = getTransactionViolations(transactionID, allTransactionViolations);
return !!violations?.find(
(violation) =>
@@ -735,7 +735,7 @@ function hasBrokenConnectionViolation(transactionID: string): boolean {
/**
* Check if user should see broken connection violation warning.
*/
-function shouldShowBrokenConnectionViolation(transactionID: string, report: OnyxEntry | SearchReport, policy: OnyxEntry | SearchPolicy): boolean {
+function shouldShowBrokenConnectionViolation(transactionID: string | undefined, report: OnyxEntry | SearchReport, policy: OnyxEntry | SearchPolicy): boolean {
return (
hasBrokenConnectionViolation(transactionID) &&
(!PolicyUtils.isPolicyAdmin(policy) || ReportUtils.isOpenExpenseReport(report) || (ReportUtils.isProcessingReport(report) && PolicyUtils.isInstantSubmitEnabled(policy)))
@@ -881,7 +881,7 @@ function isOnHoldByTransactionID(transactionID: string): boolean {
/**
* Checks if any violations for the provided transaction are of type 'violation'
*/
-function hasViolation(transactionID: string, transactionViolations: OnyxCollection, showInReview?: boolean): boolean {
+function hasViolation(transactionID: string | undefined, transactionViolations: OnyxCollection, showInReview?: boolean): boolean {
return !!transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some(
(violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.VIOLATION && (showInReview === undefined || showInReview === (violation.showInReview ?? false)),
);
@@ -890,7 +890,7 @@ function hasViolation(transactionID: string, transactionViolations: OnyxCollecti
/**
* Checks if any violations for the provided transaction are of type 'notice'
*/
-function hasNoticeTypeViolation(transactionID: string, transactionViolations: OnyxCollection, showInReview?: boolean): boolean {
+function hasNoticeTypeViolation(transactionID: string | undefined, transactionViolations: OnyxCollection, showInReview?: boolean): boolean {
return !!transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some(
(violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.NOTICE && (showInReview === undefined || showInReview === (violation.showInReview ?? false)),
);
@@ -899,7 +899,7 @@ function hasNoticeTypeViolation(transactionID: string, transactionViolations: On
/**
* Checks if any violations for the provided transaction are of type 'warning'
*/
-function hasWarningTypeViolation(transactionID: string, transactionViolations: OnyxCollection, showInReview?: boolean): boolean {
+function hasWarningTypeViolation(transactionID: string | undefined, transactionViolations: OnyxCollection, showInReview?: boolean): boolean {
const violations = transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID];
const warningTypeViolations =
violations?.filter(
@@ -1060,21 +1060,24 @@ function removeSettledAndApprovedTransactions(transactionIDs: string[]) {
*/
function compareDuplicateTransactionFields(
- reviewingTransactionID: string | undefined,
- reportID: string | undefined,
+ reviewingTransactionID?: string | undefined,
+ reportID?: string | undefined,
selectedTransactionID?: string,
): {keep: Partial; change: FieldsToChange} {
if (!reviewingTransactionID || !reportID) {
return {change: {}, keep: {}};
}
- const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${reviewingTransactionID}`];
- const duplicates = transactionViolations?.find((violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION)?.data?.duplicates ?? [];
- const transactions = removeSettledAndApprovedTransactions([reviewingTransactionID, ...duplicates]).map((item) => getTransaction(item));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const keep: Record = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const change: Record = {};
+ if (!reviewingTransactionID || !reportID) {
+ return {keep, change};
+ }
+ const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${reviewingTransactionID}`];
+ const duplicates = transactionViolations?.find((violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION)?.data?.duplicates ?? [];
+ const transactions = removeSettledAndApprovedTransactions([reviewingTransactionID, ...duplicates]).map((item) => getTransaction(item));
const fieldsToCompare: FieldsToCompare = {
merchant: ['modifiedMerchant', 'merchant'],
@@ -1210,7 +1213,11 @@ function compareDuplicateTransactionFields(
return {keep, change};
}
-function getTransactionID(threadReportID: string): string | undefined {
+function getTransactionID(threadReportID: string | undefined): string | undefined {
+ if (!threadReportID) {
+ return;
+ }
+
const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${threadReportID}`];
const parentReportAction = ReportUtils.isThread(report) ? ReportActionsUtils.getReportAction(report.parentReportID, report.parentReportActionID) : undefined;
const IOUTransactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID : undefined;
diff --git a/src/libs/actions/EmojiPickerAction.ts b/src/libs/actions/EmojiPickerAction.ts
index e6123733b0e8..134364ddbad6 100644
--- a/src/libs/actions/EmojiPickerAction.ts
+++ b/src/libs/actions/EmojiPickerAction.ts
@@ -79,8 +79,8 @@ function hideEmojiPicker(isNavigating?: boolean) {
/**
* Whether Emoji Picker is active for the given id.
*/
-function isActive(id: string): boolean {
- if (!emojiPickerRef.current) {
+function isActive(id?: string): boolean {
+ if (!emojiPickerRef.current || !id) {
return false;
}
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index c8d6fb36f60d..c0906f77850a 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -713,7 +713,6 @@ function buildOnyxDataForMoneyRequest(moneyRequestParams: BuildOnyxDataForMoneyR
value: {
...chat.report,
lastReadTime: DateUtils.getDBTime(),
- lastMessageTranslationKey: '',
iouReportID: iou.report.reportID,
...outstandingChildRequest,
...(isNewChatReport ? {pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}} : {}),
@@ -1185,7 +1184,6 @@ function buildOnyxDataForInvoice(
value: {
...chatReport,
lastReadTime: DateUtils.getDBTime(),
- lastMessageTranslationKey: '',
iouReportID: iouReport.reportID,
...(isNewChatReport ? {pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}} : {}),
},
@@ -5750,7 +5748,11 @@ function prepareToCleanUpMoneyRequest(transactionID: string, reportAction: OnyxT
* @param isSingleTransactionView - whether we are in the transaction thread report
* @returns The URL to navigate to
*/
-function getNavigationUrlOnMoneyRequestDelete(transactionID: string, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false): Route | undefined {
+function getNavigationUrlOnMoneyRequestDelete(transactionID: string | undefined, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false): Route | undefined {
+ if (!transactionID) {
+ return undefined;
+ }
+
const {shouldDeleteTransactionThread, shouldDeleteIOUReport, iouReport} = prepareToCleanUpMoneyRequest(transactionID, reportAction);
// Determine which report to navigate back to
@@ -5773,7 +5775,16 @@ function getNavigationUrlOnMoneyRequestDelete(transactionID: string, reportActio
* @param isSingleTransactionView - Whether we're in single transaction view
* @returns The URL to navigate to
*/
-function getNavigationUrlAfterTrackExpenseDelete(chatReportID: string, transactionID: string, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false): Route | undefined {
+function getNavigationUrlAfterTrackExpenseDelete(
+ chatReportID: string | undefined,
+ transactionID: string | undefined,
+ reportAction: OnyxTypes.ReportAction,
+ isSingleTransactionView = false,
+): Route | undefined {
+ if (!chatReportID || !transactionID) {
+ return undefined;
+ }
+
const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] ?? null;
// If not a self DM, handle it as a regular money request
@@ -5948,7 +5959,11 @@ function cleanUpMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repo
* @param isSingleTransactionView - whether we are in the transaction thread report
* @return the url to navigate back once the money request is deleted
*/
-function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false) {
+function deleteMoneyRequest(transactionID: string | undefined, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false) {
+ if (!transactionID) {
+ return;
+ }
+
// STEP 1: Calculate and prepare the data
const {
shouldDeleteTransactionThread,
@@ -6209,7 +6224,11 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor
return urlToNavigateBack;
}
-function deleteTrackExpense(chatReportID: string, transactionID: string, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false) {
+function deleteTrackExpense(chatReportID: string | undefined, transactionID: string | undefined, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false) {
+ if (!chatReportID || !transactionID) {
+ return;
+ }
+
const urlToNavigateBack = getNavigationUrlAfterTrackExpenseDelete(chatReportID, transactionID, reportAction, isSingleTransactionView);
// STEP 1: Get all collections we're updating
@@ -8724,7 +8743,6 @@ function resolveDuplicates(params: TransactionMergeParams) {
});
const iouActionList = params.reportID ? getIOUActionForTransactions(params.transactionIDList, params.reportID) : [];
- const transactionThreadReportIDList = iouActionList.map((action) => action?.childReportID);
const orderedTransactionIDList = iouActionList.map((action) => {
const message = ReportActionsUtils.getOriginalMessage(action);
return message?.IOUTransactionID ?? '';
@@ -8735,10 +8753,13 @@ function resolveDuplicates(params: TransactionMergeParams) {
const reportActionIDList: string[] = [];
const optimisticHoldTransactionActions: OnyxUpdate[] = [];
const failureHoldTransactionActions: OnyxUpdate[] = [];
- transactionThreadReportIDList.forEach((transactionThreadReportID) => {
+ iouActionList.forEach((action) => {
+ const transactionThreadReportID = action?.childReportID;
const createdReportAction = ReportUtils.buildOptimisticHoldReportAction();
reportActionIDList.push(createdReportAction.reportActionID);
- const transactionID = TransactionUtils.getTransactionID(transactionThreadReportID ?? '-1');
+ const transactionID = ReportActionsUtils.isMoneyRequestAction(action)
+ ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID
+ : CONST.DEFAULT_NUMBER_ID;
optimisticHoldTransactionActions.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts
index a40617f27cbd..ea3edb8d7a4d 100644
--- a/src/libs/actions/Policy/Member.ts
+++ b/src/libs/actions/Policy/Member.ts
@@ -90,7 +90,7 @@ Onyx.connect({
key: ONYXKEYS.SESSION,
callback: (val) => {
sessionEmail = val?.email ?? '';
- sessionAccountID = val?.accountID ?? -1;
+ sessionAccountID = val?.accountID ?? CONST.DEFAULT_NUMBER_ID;
},
});
@@ -134,6 +134,7 @@ function getPolicy(policyID: string | undefined): OnyxEntry {
*/
function buildAnnounceRoomMembersOnyxData(policyID: string, accountIDs: number[]): AnnounceRoomMembersOnyxData {
const announceReport = ReportUtils.getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID);
+ const announceReportMetadata = ReportUtils.getReportMetadata(announceReport?.reportID);
const announceRoomMembers: AnnounceRoomMembersOnyxData = {
onyxOptimisticData: [],
onyxFailureData: [],
@@ -145,33 +146,49 @@ function buildAnnounceRoomMembersOnyxData(policyID: string, accountIDs: number[]
}
const participantAccountIDs = [...Object.keys(announceReport.participants ?? {}).map(Number), ...accountIDs];
- const pendingChatMembers = ReportUtils.getPendingChatMembers(accountIDs, announceReport?.pendingChatMembers ?? [], CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
+ const pendingChatMembers = ReportUtils.getPendingChatMembers(accountIDs, announceReportMetadata?.pendingChatMembers ?? [], CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
- announceRoomMembers.onyxOptimisticData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`,
- value: {
- participants: ReportUtils.buildParticipantsFromAccountIDs(participantAccountIDs),
- pendingChatMembers,
+ announceRoomMembers.onyxOptimisticData.push(
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`,
+ value: {
+ participants: ReportUtils.buildParticipantsFromAccountIDs(participantAccountIDs),
+ },
},
- });
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${announceReport?.reportID}`,
+ value: {
+ pendingChatMembers,
+ },
+ },
+ );
- announceRoomMembers.onyxFailureData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`,
- value: {
- participants: accountIDs.reduce((acc, curr) => {
- Object.assign(acc, {[curr]: null});
- return acc;
- }, {}),
- pendingChatMembers: announceReport?.pendingChatMembers ?? null,
+ announceRoomMembers.onyxFailureData.push(
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`,
+ value: {
+ participants: accountIDs.reduce((acc, curr) => {
+ Object.assign(acc, {[curr]: null});
+ return acc;
+ }, {}),
+ },
},
- });
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${announceReport?.reportID}`,
+ value: {
+ pendingChatMembers: announceReportMetadata?.pendingChatMembers ?? null,
+ },
+ },
+ );
announceRoomMembers.onyxSuccessData.push({
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${announceReport?.reportID}`,
value: {
- pendingChatMembers: announceReport?.pendingChatMembers ?? null,
+ pendingChatMembers: announceReportMetadata?.pendingChatMembers ?? null,
},
});
return announceRoomMembers;
@@ -213,53 +230,75 @@ function updateImportSpreadsheetData(membersLength: number): OnyxData {
/**
* Build optimistic data for removing users from the announcement room
*/
-function removeOptimisticAnnounceRoomMembers(policyID: string, policyName: string, accountIDs: number[]): AnnounceRoomMembersOnyxData {
- const announceReport = ReportUtils.getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID);
+function removeOptimisticAnnounceRoomMembers(policyID: string | undefined, policyName: string, accountIDs: number[]): AnnounceRoomMembersOnyxData {
const announceRoomMembers: AnnounceRoomMembersOnyxData = {
onyxOptimisticData: [],
onyxFailureData: [],
onyxSuccessData: [],
};
+ if (!policyID) {
+ return announceRoomMembers;
+ }
+
+ const announceReport = ReportUtils.getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID);
+ const announceReportMetadata = ReportUtils.getReportMetadata(announceReport?.reportID);
+
if (!announceReport) {
return announceRoomMembers;
}
- const pendingChatMembers = ReportUtils.getPendingChatMembers(accountIDs, announceReport?.pendingChatMembers ?? [], CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
+ const pendingChatMembers = ReportUtils.getPendingChatMembers(accountIDs, announceReportMetadata?.pendingChatMembers ?? [], CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
- announceRoomMembers.onyxOptimisticData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`,
- value: {
- pendingChatMembers,
- ...(accountIDs.includes(sessionAccountID)
- ? {
- statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
- stateNum: CONST.REPORT.STATE_NUM.APPROVED,
- oldPolicyName: policyName,
- }
- : {}),
+ announceRoomMembers.onyxOptimisticData.push(
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`,
+ value: {
+ ...(accountIDs.includes(sessionAccountID)
+ ? {
+ statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
+ stateNum: CONST.REPORT.STATE_NUM.APPROVED,
+ oldPolicyName: policyName,
+ }
+ : {}),
+ },
},
- });
- announceRoomMembers.onyxFailureData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`,
- value: {
- pendingChatMembers: announceReport?.pendingChatMembers ?? null,
- ...(accountIDs.includes(sessionAccountID)
- ? {
- statusNum: announceReport.statusNum,
- stateNum: announceReport.stateNum,
- oldPolicyName: announceReport.oldPolicyName,
- }
- : {}),
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${announceReport.reportID}`,
+ value: {
+ pendingChatMembers,
+ },
},
- });
+ );
+ announceRoomMembers.onyxFailureData.push(
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`,
+ value: {
+ ...(accountIDs.includes(sessionAccountID)
+ ? {
+ statusNum: announceReport.statusNum,
+ stateNum: announceReport.stateNum,
+ oldPolicyName: announceReport.oldPolicyName,
+ }
+ : {}),
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${announceReport.reportID}`,
+ value: {
+ pendingChatMembers: announceReportMetadata?.pendingChatMembers ?? null,
+ },
+ },
+ );
announceRoomMembers.onyxSuccessData.push({
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${announceReport.reportID}`,
value: {
- pendingChatMembers: announceReport?.pendingChatMembers ?? null,
+ pendingChatMembers: announceReportMetadata?.pendingChatMembers ?? null,
},
});
@@ -286,7 +325,7 @@ function removeMembers(accountIDs: number[], policyID: string) {
ReportUtils.buildOptimisticClosedReportAction(sessionEmail, policy?.name ?? '', CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY),
);
- const announceRoomMembers = removeOptimisticAnnounceRoomMembers(policy?.id ?? '-1', policy?.name ?? '', accountIDs);
+ const announceRoomMembers = removeOptimisticAnnounceRoomMembers(policy?.id, policy?.name ?? '', accountIDs);
const optimisticMembersState: OnyxCollectionInputValue = {};
const successMembersState: OnyxCollectionInputValue = {};
@@ -373,26 +412,34 @@ function removeMembers(accountIDs: number[], policyID: string) {
const pendingChatMembers = ReportUtils.getPendingChatMembers(accountIDs, [], CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
workspaceChats.forEach((report) => {
- optimisticData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`,
- value: {
- statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
- stateNum: CONST.REPORT.STATE_NUM.APPROVED,
- oldPolicyName: policy?.name,
- pendingChatMembers,
+ optimisticData.push(
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`,
+ value: {
+ statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
+ stateNum: CONST.REPORT.STATE_NUM.APPROVED,
+ oldPolicyName: policy?.name,
+ },
},
- });
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`,
+ value: {
+ pendingChatMembers,
+ },
+ },
+ );
successData.push({
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`,
value: {
pendingChatMembers: null,
},
});
failureData.push({
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`,
value: {
pendingChatMembers: null,
},
@@ -873,7 +920,7 @@ function acceptJoinRequest(reportID: string, reportAction: OnyxEntry): WorkspaceF
expenseChatData: workspaceChatData,
expenseReportActionData: workspaceChatReportActionData,
expenseCreatedReportActionID: workspaceChatCreatedReportActionID,
+ pendingChatMembers,
} = ReportUtils.buildOptimisticWorkspaceChats(policyID, workspaceName);
if (!employeeAccountID || !oldPersonalPolicyID) {
@@ -2308,6 +2329,13 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF
...adminsChatData,
},
},
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${adminsChatReportID}`,
+ value: {
+ pendingChatMembers,
+ },
+ },
{
onyxMethod: Onyx.METHOD.SET,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`,
@@ -3447,6 +3475,45 @@ function upgradeToCorporate(policyID: string, featureName?: string) {
API.write(WRITE_COMMANDS.UPGRADE_TO_CORPORATE, parameters, {optimisticData, successData, failureData});
}
+function downgradeToTeam(policyID: string) {
+ const policy = getPolicy(policyID);
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `policy_${policyID}`,
+ value: {
+ isPendingDowngrade: true,
+ type: CONST.POLICY.TYPE.TEAM,
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `policy_${policyID}`,
+ value: {
+ isPendingDowngrade: false,
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `policy_${policyID}`,
+ value: {
+ isPendingDowngrade: false,
+ type: policy?.type,
+ },
+ },
+ ];
+
+ const parameters: DowngradeToTeamParams = {policyID};
+
+ API.write(WRITE_COMMANDS.DOWNGRADE_TO_TEAM, parameters, {optimisticData, successData, failureData});
+}
+
function setWorkspaceDefaultSpendCategory(policyID: string, groupID: string, category: string) {
const policy = getPolicy(policyID);
if (!policy) {
@@ -4728,6 +4795,7 @@ export {
updateInvoiceCompanyName,
updateInvoiceCompanyWebsite,
getAssignedSupportData,
+ downgradeToTeam,
};
export type {NewCustomUnit};
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index ab924906352e..462d291acf84 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -459,7 +459,7 @@ function unsubscribeFromReportChannel(reportID: string) {
/**
* Remove our pusher subscriptions to listen for someone leaving a report.
*/
-function unsubscribeFromLeavingRoomReportChannel(reportID: string) {
+function unsubscribeFromLeavingRoomReportChannel(reportID: string | undefined) {
if (!reportID) {
return;
}
@@ -537,7 +537,6 @@ function addActions(reportID: string, text = '', file?: FileObject) {
const optimisticReport: Partial = {
lastVisibleActionCreated: lastAction?.created,
- lastMessageTranslationKey: lastComment?.translationKey ?? '',
lastMessageText: lastCommentText,
lastMessageHtml: lastCommentText,
lastActorAccountID: currentUserAccountID,
@@ -605,17 +604,15 @@ function addActions(reportID: string, text = '', file?: FileObject) {
];
let failureReport: Partial = {
- lastMessageTranslationKey: '',
lastMessageText: '',
lastVisibleActionCreated: '',
};
- const {lastMessageText = '', lastMessageTranslationKey = ''} = ReportActionsUtils.getLastVisibleMessage(reportID);
- if (lastMessageText || lastMessageTranslationKey) {
+ const {lastMessageText = ''} = ReportActionsUtils.getLastVisibleMessage(reportID);
+ if (lastMessageText) {
const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID);
const lastVisibleActionCreated = lastVisibleAction?.created;
const lastActorAccountID = lastVisibleAction?.actorAccountID;
failureReport = {
- lastMessageTranslationKey,
lastMessageText,
lastVisibleActionCreated,
lastActorAccountID,
@@ -1336,7 +1333,11 @@ function expandURLPreview(reportID: string, reportActionID: string) {
* @param shouldResetUnreadMarker Indicates whether the unread indicator should be reset.
* Currently, the unread indicator needs to be reset only when users mark a report as read.
*/
-function readNewestAction(reportID: string, shouldResetUnreadMarker = false) {
+function readNewestAction(reportID: string | undefined, shouldResetUnreadMarker = false) {
+ if (!reportID) {
+ return;
+ }
+
const lastReadTime = DateUtils.getDBTime();
const optimisticData: OnyxUpdate[] = [
@@ -1555,19 +1556,17 @@ function deleteReportComment(reportID: string, reportAction: ReportAction) {
// If we are deleting the last visible message, let's find the previous visible one (or set an empty one if there are none) and update the lastMessageText in the LHN.
// Similarly, if we are deleting the last read comment we will want to update the lastVisibleActionCreated to use the previous visible message.
let optimisticReport: Partial = {
- lastMessageTranslationKey: '',
lastMessageText: '',
lastVisibleActionCreated: '',
};
- const {lastMessageText = '', lastMessageTranslationKey = ''} = ReportUtils.getLastVisibleMessage(originalReportID, optimisticReportActions as ReportActions);
+ const {lastMessageText = ''} = ReportUtils.getLastVisibleMessage(originalReportID, optimisticReportActions as ReportActions);
const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report);
- if (lastMessageText || lastMessageTranslationKey) {
+ if (lastMessageText) {
const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(originalReportID, canUserPerformWriteAction, optimisticReportActions as ReportActions);
const lastVisibleActionCreated = lastVisibleAction?.created;
const lastActorAccountID = lastVisibleAction?.actorAccountID;
optimisticReport = {
- lastMessageTranslationKey,
lastMessageText,
lastVisibleActionCreated,
lastActorAccountID,
@@ -1774,7 +1773,6 @@ function editReportComment(reportID: string, originalReportAction: OnyxEntry>((participantCleanUp, newAccountID) => {
// eslint-disable-next-line no-param-reassign
@@ -3137,14 +3136,20 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: InvitedEmails
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
value: {
participants: participantsAfterInvitation,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
+ value: {
pendingChatMembers,
},
},
];
optimisticData.push(...newPersonalDetailsOnyxData.optimisticData);
- const successPendingChatMembers = report?.pendingChatMembers
- ? report?.pendingChatMembers?.filter(
+ const successPendingChatMembers = reportMetadata?.pendingChatMembers
+ ? reportMetadata?.pendingChatMembers?.filter(
(pendingMember) => !(inviteeAccountIDs.includes(Number(pendingMember.accountID)) && pendingMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE),
)
: null;
@@ -3153,17 +3158,23 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: InvitedEmails
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
value: {
- pendingChatMembers: successPendingChatMembers,
participants: newParticipantAccountCleanUp,
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
+ value: {
+ pendingChatMembers: successPendingChatMembers,
+ },
+ },
];
successData.push(...newPersonalDetailsOnyxData.finallyData);
const failureData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
value: {
pendingChatMembers:
pendingChatMembers.map((pendingChatMember) => {
@@ -3201,13 +3212,15 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: InvitedEmails
}
function clearAddRoomMemberError(reportID: string, invitedAccountID: string) {
- const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
+ const reportMetadata = ReportUtils.getReportMetadata(reportID);
Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {
- pendingChatMembers: report?.pendingChatMembers?.filter((pendingChatMember) => pendingChatMember.accountID !== invitedAccountID),
participants: {
[invitedAccountID]: null,
},
});
+ Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {
+ pendingChatMembers: reportMetadata?.pendingChatMembers?.filter((pendingChatMember) => pendingChatMember.accountID !== invitedAccountID),
+ });
Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {
[invitedAccountID]: null,
});
@@ -3264,6 +3277,7 @@ function inviteToGroupChat(reportID: string, inviteeEmailsToAccountIDs: InvitedE
*/
function removeFromRoom(reportID: string, targetAccountIDs: number[]) {
const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
+ const reportMetadata = ReportUtils.getReportMetadata(reportID);
if (!report) {
return;
}
@@ -3272,12 +3286,12 @@ function removeFromRoom(reportID: string, targetAccountIDs: number[]) {
targetAccountIDs.forEach((accountID) => {
removeParticipantsData[accountID] = null;
});
- const pendingChatMembers = ReportUtils.getPendingChatMembers(targetAccountIDs, report?.pendingChatMembers ?? [], CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
+ const pendingChatMembers = ReportUtils.getPendingChatMembers(targetAccountIDs, reportMetadata?.pendingChatMembers ?? [], CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
value: {
pendingChatMembers,
},
@@ -3287,9 +3301,9 @@ function removeFromRoom(reportID: string, targetAccountIDs: number[]) {
const failureData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
value: {
- pendingChatMembers: report?.pendingChatMembers ?? null,
+ pendingChatMembers: reportMetadata?.pendingChatMembers ?? null,
},
},
];
@@ -3302,7 +3316,13 @@ function removeFromRoom(reportID: string, targetAccountIDs: number[]) {
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
value: {
participants: removeParticipantsData,
- pendingChatMembers: report?.pendingChatMembers ?? null,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
+ value: {
+ pendingChatMembers: reportMetadata?.pendingChatMembers ?? null,
},
},
];
@@ -3862,20 +3882,18 @@ function prepareOnboardingOptimisticData(
}
let failureReport: Partial = {
- lastMessageTranslationKey: '',
lastMessageText: '',
lastVisibleActionCreated: '',
hasOutstandingChildTask: false,
};
const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${targetChatReportID}`];
const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report);
- const {lastMessageText = '', lastMessageTranslationKey = ''} = ReportActionsUtils.getLastVisibleMessage(targetChatReportID, canUserPerformWriteAction);
- if (lastMessageText || lastMessageTranslationKey) {
+ const {lastMessageText = ''} = ReportActionsUtils.getLastVisibleMessage(targetChatReportID, canUserPerformWriteAction);
+ if (lastMessageText) {
const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(targetChatReportID, canUserPerformWriteAction);
const prevLastVisibleActionCreated = lastVisibleAction?.created;
const lastActorAccountID = lastVisibleAction?.actorAccountID;
failureReport = {
- lastMessageTranslationKey,
lastMessageText,
lastVisibleActionCreated: prevLastVisibleActionCreated,
lastActorAccountID,
@@ -4177,7 +4195,6 @@ function resolveActionableMentionWhisper(reportId: string, reportAction: OnyxEnt
const reportUpdateDataWithPreviousLastMessage = ReportUtils.getReportLastMessage(reportId, optimisticReportActions as ReportActions);
const reportUpdateDataWithCurrentLastMessage = {
- lastMessageTranslationKey: report?.lastMessageTranslationKey,
lastMessageText: report?.lastMessageText,
lastVisibleActionCreated: report?.lastVisibleActionCreated,
lastActorAccountID: report?.lastActorAccountID,
@@ -4252,7 +4269,6 @@ function resolveActionableReportMentionWhisper(
const reportUpdateDataWithPreviousLastMessage = ReportUtils.getReportLastMessage(reportId, optimisticReportActions as ReportActions);
const reportUpdateDataWithCurrentLastMessage = {
- lastMessageTranslationKey: report?.lastMessageTranslationKey,
lastMessageText: report?.lastMessageText,
lastVisibleActionCreated: report?.lastVisibleActionCreated,
lastActorAccountID: report?.lastActorAccountID,
diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts
index b5c76a6009ef..ff0b644bdaee 100644
--- a/src/libs/actions/Search.ts
+++ b/src/libs/actions/Search.ts
@@ -394,14 +394,6 @@ function clearAdvancedFilters() {
Onyx.merge(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, values);
}
-function showSavedSearchRenameTooltip() {
- Onyx.set(ONYXKEYS.SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP, true);
-}
-
-function dismissSavedSearchRenameTooltip() {
- Onyx.set(ONYXKEYS.SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP, false);
-}
-
export {
saveSearch,
search,
@@ -414,8 +406,6 @@ export {
clearAllFilters,
clearAdvancedFilters,
deleteSavedSearch,
- dismissSavedSearchRenameTooltip,
- showSavedSearchRenameTooltip,
payMoneyRequestOnSearch,
approveMoneyRequestOnSearch,
handleActionButtonPress,
diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts
index bd61f9b5c17a..2b4b8fe73ccc 100644
--- a/src/libs/actions/Task.ts
+++ b/src/libs/actions/Task.ts
@@ -142,7 +142,6 @@ function createTaskAndNavigate(
lastMessageText: lastCommentText,
lastActorAccountID: currentUserAccountID,
lastReadTime: currentTime,
- lastMessageTranslationKey: '',
hasOutstandingChildTask: assigneeAccountID === currentUserAccountID ? true : parentReport?.hasOutstandingChildTask,
};
diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts
index fd9d5f1820e6..71973a5adbc3 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -1370,14 +1370,6 @@ function dismissTrackTrainingModal() {
});
}
-function dismissWorkspaceTooltip() {
- Onyx.merge(ONYXKEYS.NVP_WORKSPACE_TOOLTIP, {shouldShow: false});
-}
-
-function dismissGBRTooltip() {
- Onyx.merge(ONYXKEYS.NVP_SHOULD_HIDE_GBR_TOOLTIP, true);
-}
-
function requestRefund() {
API.write(WRITE_COMMANDS.REQUEST_REFUND, null);
}
@@ -1398,7 +1390,6 @@ export {
closeAccount,
dismissReferralBanner,
dismissTrackTrainingModal,
- dismissWorkspaceTooltip,
resendValidateCode,
requestContactMethodValidateCode,
updateNewsletterSubscription,
@@ -1432,7 +1423,6 @@ export {
addPendingContactMethod,
clearValidateCodeActionError,
subscribeToActiveGuides,
- dismissGBRTooltip,
setIsDebugModeEnabled,
resetValidateActionCodeSent,
};
diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts
index 9aa0f07dc59c..d515d1333be4 100644
--- a/src/libs/actions/Welcome/OnboardingFlow.ts
+++ b/src/libs/actions/Welcome/OnboardingFlow.ts
@@ -30,7 +30,7 @@ Onyx.connect({
if (value === undefined) {
return;
}
- onboardingValues = value as Onboarding;
+ onboardingValues = value;
},
});
diff --git a/src/libs/actions/Welcome/index.ts b/src/libs/actions/Welcome/index.ts
index b306daf444ba..585f5a59937c 100644
--- a/src/libs/actions/Welcome/index.ts
+++ b/src/libs/actions/Welcome/index.ts
@@ -13,7 +13,7 @@ import type TryNewDot from '@src/types/onyx/TryNewDot';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import * as OnboardingFlow from './OnboardingFlow';
-type OnboardingData = Onboarding | [] | undefined;
+type OnboardingData = Onboarding | undefined;
let isLoadingReportData = true;
let tryNewDotData: TryNewDot | undefined;
@@ -44,7 +44,7 @@ function onServerDataReady(): Promise {
let isOnboardingInProgress = false;
function isOnboardingFlowCompleted({onCompleted, onNotCompleted, onCanceled}: HasCompletedOnboardingFlowProps) {
isOnboardingFlowStatusKnownPromise.then(() => {
- if (Array.isArray(onboarding) || isEmptyObject(onboarding) || onboarding?.hasCompletedGuidedSetupFlow === undefined) {
+ if (isEmptyObject(onboarding) || onboarding?.hasCompletedGuidedSetupFlow === undefined) {
onCanceled?.();
return;
}
@@ -207,20 +207,16 @@ function setSelfTourViewed(shouldUpdateOnyxDataOnlyLocally = false) {
function dismissProductTraining(elementName: string) {
const date = new Date();
- // const optimisticData = [
- // {
- // onyxMethod: Onyx.METHOD.MERGE,
- // key: ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING,
- // value: {
- // [elementName]: DateUtils.getDBTime(date.valueOf()),
- // },
- // },
- // ];
- // API.write(WRITE_COMMANDS.DISMISS_PRODUCT_TRAINING, {name: elementName}, {optimisticData});
-
- Onyx.merge(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {
- [elementName]: DateUtils.getDBTime(date.valueOf()),
- });
+ const optimisticData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING,
+ value: {
+ [elementName]: DateUtils.getDBTime(date.valueOf()),
+ },
+ },
+ ];
+ API.write(WRITE_COMMANDS.DISMISS_PRODUCT_TRAINING, {name: elementName}, {optimisticData});
}
export {
diff --git a/src/libs/migrateOnyx.ts b/src/libs/migrateOnyx.ts
index 7053c2f6f75f..f0a9ec9db977 100644
--- a/src/libs/migrateOnyx.ts
+++ b/src/libs/migrateOnyx.ts
@@ -2,6 +2,7 @@ import Log from './Log';
import KeyReportActionsDraftByReportActionID from './migrations/KeyReportActionsDraftByReportActionID';
import MoveIsOptimisticReportToMetadata from './migrations/MoveIsOptimisticReportToMetadata';
import NVPMigration from './migrations/NVPMigration';
+import PendingMembersToMetadata from './migrations/PendingMembersToMetadata';
import PronounsMigration from './migrations/PronounsMigration';
import RemoveEmptyReportActionsDrafts from './migrations/RemoveEmptyReportActionsDrafts';
import RenameCardIsVirtual from './migrations/RenameCardIsVirtual';
@@ -23,6 +24,7 @@ export default function () {
NVPMigration,
PronounsMigration,
MoveIsOptimisticReportToMetadata,
+ PendingMembersToMetadata,
];
// Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the
diff --git a/src/libs/migrations/NVPMigration.ts b/src/libs/migrations/NVPMigration.ts
index 20f3d0a86495..8c743e66e79f 100644
--- a/src/libs/migrations/NVPMigration.ts
+++ b/src/libs/migrations/NVPMigration.ts
@@ -9,7 +9,6 @@ import type {OnyxKey} from '@src/ONYXKEYS';
const migrations = {
// eslint-disable-next-line @typescript-eslint/naming-convention
nvp_lastPaymentMethod: ONYXKEYS.NVP_LAST_PAYMENT_METHOD,
- isFirstTimeNewExpensifyUser: ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER,
preferredLocale: ONYXKEYS.NVP_PREFERRED_LOCALE,
preferredEmojiSkinTone: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
frequentlyUsedEmojis: ONYXKEYS.FREQUENTLY_USED_EMOJIS,
diff --git a/src/libs/migrations/PendingMembersToMetadata.ts b/src/libs/migrations/PendingMembersToMetadata.ts
new file mode 100644
index 000000000000..d71dc0d17e83
--- /dev/null
+++ b/src/libs/migrations/PendingMembersToMetadata.ts
@@ -0,0 +1,54 @@
+import Onyx from 'react-native-onyx';
+import type {OnyxCollection} from 'react-native-onyx';
+import Log from '@libs/Log';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Report} from '@src/types/onyx';
+import type {PendingChatMember} from '@src/types/onyx/ReportMetadata';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+
+type OldReport = Report & {pendingChatMembers?: PendingChatMember[]};
+
+/**
+ * This migration moves pendingChatMembers from the report object to reportMetadata
+ */
+export default function (): Promise {
+ return new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (reports: OnyxCollection) => {
+ Onyx.disconnect(connection);
+ if (!reports || isEmptyObject(reports)) {
+ Log.info('[Migrate Onyx] Skipping migration PendingMembersToMetadata because there are no reports');
+ return resolve();
+ }
+
+ const promises: Array> = [];
+ Object.entries(reports).forEach(([reportID, report]) => {
+ if (report?.pendingChatMembers === undefined) {
+ return;
+ }
+
+ promises.push(
+ Promise.all([
+ // @ts-expect-error pendingChatMembers is not a valid property of Report anymore
+ // eslint-disable-next-line rulesdir/prefer-actions-set-data
+ Onyx.merge(reportID, {pendingChatMembers: null}),
+ // eslint-disable-next-line rulesdir/prefer-actions-set-data
+ Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`, {pendingChatMembers: report.pendingChatMembers}),
+ ]).then(() => {
+ Log.info(`[Migrate Onyx] Successfully moved pendingChatMembers to reportMetadata for ${reportID}`);
+ }),
+ );
+ });
+
+ if (promises.length === 0) {
+ Log.info('[Migrate Onyx] Skipping migration PendingMembersToMetadata because there are no reports with pendingChatMembers');
+ return resolve();
+ }
+
+ Promise.all(promises).then(() => resolve());
+ },
+ });
+ });
+}
diff --git a/src/libs/onboardingSelectors.ts b/src/libs/onboardingSelectors.ts
index 91185e5c67bf..b21626cf8a07 100644
--- a/src/libs/onboardingSelectors.ts
+++ b/src/libs/onboardingSelectors.ts
@@ -11,7 +11,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject';
*/
function hasCompletedGuidedSetupFlowSelector(onboarding: OnyxValue): boolean | undefined {
// Onboarding is an empty object for old accounts and accounts migrated from OldDot
- if (Array.isArray(onboarding) || isEmptyObject(onboarding)) {
+ if (isEmptyObject(onboarding)) {
return true;
}
@@ -49,7 +49,7 @@ function tryNewDotOnyxSelector(tryNewDotData: OnyxValue): boolean | undefined {
- if (Array.isArray(onboarding) || isEmptyObject(onboarding)) {
+ if (isEmptyObject(onboarding)) {
return false;
}
diff --git a/src/pages/Debug/Report/DebugReportActions.tsx b/src/pages/Debug/Report/DebugReportActions.tsx
index 9368ca5116bd..fdc2aa8b1ca8 100644
--- a/src/pages/Debug/Report/DebugReportActions.tsx
+++ b/src/pages/Debug/Report/DebugReportActions.tsx
@@ -1,8 +1,6 @@
import React from 'react';
-import type {ListRenderItemInfo} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
-import FlatList from '@components/FlatList';
import {PressableWithFeedback} from '@components/Pressable';
import ScrollView from '@components/ScrollView';
import Text from '@components/Text';
@@ -28,17 +26,20 @@ function DebugReportActions({reportID}: DebugReportActionsProps) {
canEvict: false,
selector: (allReportActions) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, canUserPerformWriteAction, true),
});
- const renderItem = ({item}: ListRenderItemInfo) => (
+
+ const renderItem = (item: ReportAction, index: number) => (
Navigation.navigate(ROUTES.DEBUG_REPORT_ACTION.getRoute(reportID, item.reportActionID))}
style={({pressed}) => [styles.flexRow, styles.justifyContentBetween, pressed && styles.hoveredComponentBG, styles.p4]}
hoverStyle={styles.hoveredComponentBG}
+ key={index}
>
{item.reportActionID}
{datetimeToCalendarTime(item.created, false, false)}
);
+
return (
);
}
diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx
index a31597bb59dd..d706dbe97610 100644
--- a/src/pages/Debug/Report/DebugReportPage.tsx
+++ b/src/pages/Debug/Report/DebugReportPage.tsx
@@ -1,11 +1,10 @@
-import React, {useMemo} from 'react';
+import React, {useCallback, useMemo} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
import ScreenWrapper from '@components/ScreenWrapper';
-import TabSelector from '@components/TabSelector/TabSelector';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
@@ -14,8 +13,9 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {navigateToConciergeChatAndDeleteReport} from '@libs/actions/Report';
import DebugUtils from '@libs/DebugUtils';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
+import type {DebugTabNavigatorRoutes} from '@libs/Navigation/DebugTabNavigator';
+import DebugTabNavigator from '@libs/Navigation/DebugTabNavigator';
import Navigation from '@libs/Navigation/Navigation';
-import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {DebugParamList} from '@libs/Navigation/types';
import * as ReportUtils from '@libs/ReportUtils';
@@ -114,6 +114,97 @@ function DebugReportPage({
];
}, [report, reportActions, reportID, transactionViolations, translate]);
+ const DebugDetailsTab = useCallback(
+ () => (
+ {
+ Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, data);
+ }}
+ onDelete={() => {
+ navigateToConciergeChatAndDeleteReport(reportID, true, true);
+ }}
+ validate={DebugUtils.validateReportDraftProperty}
+ >
+
+ {metadata?.map(({title, subtitle, message, action}) => (
+
+
+ {title}
+ {subtitle}
+
+ {!!message && {message}}
+ {!!action && (
+
+ )}
+
+ ))}
+
+
+ ),
+ [
+ StyleUtils,
+ metadata,
+ report,
+ reportID,
+ styles.br4,
+ styles.flexColumn,
+ styles.flexRow,
+ styles.gap2,
+ styles.gap5,
+ styles.h4,
+ styles.justifyContentBetween,
+ styles.mb5,
+ styles.p5,
+ styles.ph5,
+ styles.textSupporting,
+ theme.cardBG,
+ transactionID,
+ translate,
+ ],
+ );
+
+ const DebugJSONTab = useCallback(() => , [report]);
+
+ const DebugReportActionsTab = useCallback(() => , [reportID]);
+
+ const routes = useMemo(
+ () => [
+ {
+ name: CONST.DEBUG.DETAILS,
+ component: DebugDetailsTab,
+ },
+ {
+ name: CONST.DEBUG.JSON,
+ component: DebugJSONTab,
+ },
+ {
+ name: CONST.DEBUG.REPORT_ACTIONS,
+ component: DebugReportActionsTab,
+ },
+ ],
+ [DebugDetailsTab, DebugJSONTab, DebugReportActionsTab],
+ );
+
if (!report) {
return ;
}
@@ -131,61 +222,10 @@ function DebugReportPage({
title={`${translate('debug.debug')} - ${translate('debug.report')}`}
onBackButtonPress={Navigation.goBack}
/>
-
-
- {() => (
- {
- Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, data);
- }}
- onDelete={() => {
- navigateToConciergeChatAndDeleteReport(reportID, true, true);
- }}
- validate={DebugUtils.validateReportDraftProperty}
- >
-
- {metadata?.map(({title, subtitle, message, action}) => (
-
-
- {title}
- {subtitle}
-
- {!!message && {message}}
- {!!action && (
-
- )}
-
- ))}
- {
- Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID));
- }}
- icon={Expensicons.Eye}
- />
- {!!transactionID && (
- {
- Navigation.navigate(ROUTES.DEBUG_TRANSACTION.getRoute(transactionID));
- }}
- />
- )}
-
-
- )}
-
- {() => }
- {() => }
-
+
)}
diff --git a/src/pages/Debug/ReportAction/DebugReportActionPage.tsx b/src/pages/Debug/ReportAction/DebugReportActionPage.tsx
index 8c9e33af7f85..a7b6499c70aa 100644
--- a/src/pages/Debug/ReportAction/DebugReportActionPage.tsx
+++ b/src/pages/Debug/ReportAction/DebugReportActionPage.tsx
@@ -1,16 +1,16 @@
-import React from 'react';
+import React, {useCallback, useMemo} from 'react';
import {InteractionManager, View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
-import TabSelector from '@components/TabSelector/TabSelector';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import DebugUtils from '@libs/DebugUtils';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
+import type {DebugTabNavigatorRoutes} from '@libs/Navigation/DebugTabNavigator';
+import DebugTabNavigator from '@libs/Navigation/DebugTabNavigator';
import Navigation from '@libs/Navigation/Navigation';
-import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {DebugParamList} from '@libs/Navigation/types';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
@@ -38,6 +38,52 @@ function DebugReportActionPage({
});
const transactionID = ReportActionsUtils.getLinkedTransactionID(reportAction);
+ const DebugDetailsTab = useCallback(
+ () => (
+ {
+ Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: data});
+ }}
+ onDelete={() => {
+ Navigation.goBack();
+ // We need to wait for navigation animations to finish before deleting an action,
+ // otherwise the user will see a not found page briefly.
+ InteractionManager.runAfterInteractions(() => {
+ Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: null});
+ });
+ }}
+ validate={DebugUtils.validateReportActionDraftProperty}
+ >
+ {!!transactionID && (
+
+ {
+ Navigation.navigate(ROUTES.DEBUG_TRANSACTION.getRoute(transactionID));
+ }}
+ />
+
+ )}
+
+ ),
+ [reportAction, reportActionID, reportID, styles.mb5, styles.mh5, transactionID, translate],
+ );
+
+ const DebugJSONTab = useCallback(() => , [reportAction]);
+
+ const DebugReportActionPreviewTab = useCallback(() => , [reportAction]);
+
+ const routes = useMemo(
+ () => [
+ {name: CONST.DEBUG.DETAILS, component: DebugDetailsTab},
+ {name: CONST.DEBUG.JSON, component: DebugJSONTab},
+ {name: CONST.DEBUG.REPORT_ACTION_PREVIEW, component: DebugReportActionPreviewTab},
+ ],
+ [DebugDetailsTab, DebugJSONTab, DebugReportActionPreviewTab],
+ );
+
return (
-
-
- {() => (
- {
- Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: data});
- }}
- onDelete={() => {
- Navigation.goBack();
- // We need to wait for navigation animations to finish before deleting an action,
- // otherwise the user will see a not found page briefly.
- InteractionManager.runAfterInteractions(() => {
- Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: null});
- });
- }}
- validate={DebugUtils.validateReportActionDraftProperty}
- >
- {!!transactionID && (
-
- {
- Navigation.navigate(ROUTES.DEBUG_TRANSACTION.getRoute(transactionID));
- }}
- />
-
- )}
-
- )}
-
- {() => }
- {() => }
-
+ routes={routes}
+ />
)}
diff --git a/src/pages/Debug/Transaction/DebugTransactionPage.tsx b/src/pages/Debug/Transaction/DebugTransactionPage.tsx
index 86a8e3ded86a..453d87d07cf4 100644
--- a/src/pages/Debug/Transaction/DebugTransactionPage.tsx
+++ b/src/pages/Debug/Transaction/DebugTransactionPage.tsx
@@ -1,17 +1,17 @@
-import React, {useMemo} from 'react';
+import React, {useCallback, useMemo} from 'react';
import {InteractionManager, View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
-import TabSelector from '@components/TabSelector/TabSelector';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import Debug from '@libs/actions/Debug';
import DebugUtils from '@libs/DebugUtils';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
+import type {DebugTabNavigatorRoutes} from '@libs/Navigation/DebugTabNavigator';
+import DebugTabNavigator from '@libs/Navigation/DebugTabNavigator';
import Navigation from '@libs/Navigation/Navigation';
-import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {DebugParamList} from '@libs/Navigation/types';
import * as PolicyUtils from '@libs/PolicyUtils';
@@ -40,6 +40,52 @@ function DebugTransactionPage({
const styles = useThemeStyles();
+ const DebugDetailsTab = useCallback(
+ () => (
+ {
+ Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, data);
+ }}
+ onDelete={() => {
+ Navigation.goBack();
+ // We need to wait for navigation animations to finish before deleting a transaction,
+ // otherwise the user will see a not found page briefly.
+ InteractionManager.runAfterInteractions(() => {
+ Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, null);
+ });
+ }}
+ validate={DebugUtils.validateTransactionDraftProperty}
+ >
+
+ {
+ Navigation.navigate(ROUTES.DEBUG_REPORT.getRoute(`${transaction?.reportID}`));
+ }}
+ />
+
+
+ ),
+ [policyTagLists, report?.policyID, styles.mb5, styles.mh5, transaction, transactionID, translate],
+ );
+
+ const DebugJSONTab = useCallback(() => , [transaction]);
+
+ const DebugTransactionViolationsTab = useCallback(() => , [transactionID]);
+
+ const routes = useMemo(
+ () => [
+ {name: CONST.DEBUG.DETAILS, component: DebugDetailsTab},
+ {name: CONST.DEBUG.JSON, component: DebugJSONTab},
+ {name: CONST.DEBUG.TRANSACTION_VIOLATIONS, component: DebugTransactionViolationsTab},
+ ],
+ [DebugDetailsTab, DebugJSONTab, DebugTransactionViolationsTab],
+ );
+
if (!transaction) {
return ;
}
@@ -57,44 +103,10 @@ function DebugTransactionPage({
title={`${translate('debug.debug')} - ${translate('debug.transaction')}`}
onBackButtonPress={Navigation.goBack}
/>
-
-
- {() => (
- {
- Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, data);
- }}
- onDelete={() => {
- Navigation.goBack();
- // We need to wait for navigation animations to finish before deleting a transaction,
- // otherwise the user will see a not found page briefly.
- InteractionManager.runAfterInteractions(() => {
- Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, null);
- });
- }}
- validate={DebugUtils.validateTransactionDraftProperty}
- >
-
- {
- Navigation.navigate(ROUTES.DEBUG_REPORT.getRoute(transaction?.reportID ?? ''));
- }}
- />
-
-
- )}
-
- {() => }
- {() => }
-
+ routes={routes}
+ />
)}
diff --git a/src/pages/Debug/Transaction/DebugTransactionViolations.tsx b/src/pages/Debug/Transaction/DebugTransactionViolations.tsx
index d3e37f726a96..e13fd01fdcd7 100644
--- a/src/pages/Debug/Transaction/DebugTransactionViolations.tsx
+++ b/src/pages/Debug/Transaction/DebugTransactionViolations.tsx
@@ -1,8 +1,6 @@
import React from 'react';
-import type {ListRenderItemInfo} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
-import FlatList from '@components/FlatList';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import ScrollView from '@components/ScrollView';
import Text from '@components/Text';
@@ -23,12 +21,13 @@ function DebugTransactionViolations({transactionID}: DebugTransactionViolationsP
const styles = useThemeStyles();
const {translate} = useLocalize();
- const renderItem = ({item, index}: ListRenderItemInfo) => (
+ const renderItem = (item: TransactionViolation, index: number) => (
Navigation.navigate(ROUTES.DEBUG_TRANSACTION_VIOLATION.getRoute(transactionID, String(index)))}
style={({pressed}) => [styles.flexRow, styles.justifyContentBetween, pressed && styles.hoveredComponentBG, styles.p4]}
hoverStyle={styles.hoveredComponentBG}
+ key={index}
>
{item.type}
{item.name}
@@ -44,11 +43,9 @@ function DebugTransactionViolations({transactionID}: DebugTransactionViolationsP
onPress={() => Navigation.navigate(ROUTES.DEBUG_TRANSACTION_VIOLATION_CREATE.getRoute(transactionID))}
style={[styles.pb5, styles.ph3]}
/>
-
+ {/* This list was previously rendered as a FlatList, but it turned out that it caused the component to flash in some cases,
+ so it was replaced by this solution. */}
+ {transactionViolations?.map((item, index) => renderItem(item, index))}
);
}
diff --git a/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx b/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx
index f615060ab6df..9db84c341d59 100644
--- a/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx
+++ b/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx
@@ -3,14 +3,14 @@ import {InteractionManager, View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
-import TabSelector from '@components/TabSelector/TabSelector';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import Debug from '@libs/actions/Debug';
import DebugUtils from '@libs/DebugUtils';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
+import type {DebugTabNavigatorRoutes} from '@libs/Navigation/DebugTabNavigator';
+import DebugTabNavigator from '@libs/Navigation/DebugTabNavigator';
import Navigation from '@libs/Navigation/Navigation';
-import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {DebugParamList} from '@libs/Navigation/types';
import DebugDetails from '@pages/Debug/DebugDetails';
@@ -53,6 +53,29 @@ function DebugTransactionViolationPage({
});
}, [index, transactionID, transactionViolations]);
+ const DebugDetailsTab = useCallback(
+ () => (
+
+ ),
+ [deleteTransactionViolation, saveChanges, transactionViolation],
+ );
+
+ const DebugJSONTab = useCallback(() => , [transactionViolation]);
+
+ const routes = useMemo(
+ () => [
+ {name: CONST.DEBUG.DETAILS, component: DebugDetailsTab},
+ {name: CONST.DEBUG.JSON, component: DebugJSONTab},
+ ],
+ [DebugDetailsTab, DebugJSONTab],
+ );
+
if (!transactionViolation) {
return ;
}
@@ -70,23 +93,10 @@ function DebugTransactionViolationPage({
title={`${translate('debug.debug')} - ${translate('debug.transactionViolation')}`}
onBackButtonPress={Navigation.goBack}
/>
-
-
- {() => (
-
- )}
-
- {() => }
-
+ routes={routes}
+ />
)}
diff --git a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx
index 8f704da582c5..61fb6050460e 100644
--- a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx
+++ b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx
@@ -60,16 +60,26 @@ function BaseOnboardingAccounting({shouldUseNativeStyles, route}: BaseOnboarding
// If the signupQualifier is VSB, the company size step is skip.
// So we need to create the new workspace in the accounting step
+ const paidGroupPolicy = Object.values(allPolicies ?? {}).find(PolicyUtils.isPaidGroupPolicy);
useEffect(() => {
- const filteredPolicies = Object.values(allPolicies ?? {}).filter(PolicyUtils.isPaidGroupPolicy);
- if (!isVsb || filteredPolicies.length > 0 || isLoadingOnyxValue(allPoliciesResult)) {
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ if (!isVsb || paidGroupPolicy || isLoadingOnyxValue(allPoliciesResult)) {
return;
}
const {adminsChatReportID, policyID} = Policy.createWorkspace(undefined, true, '', Policy.generatePolicyID(), CONST.ONBOARDING_CHOICES.MANAGE_TEAM);
Welcome.setOnboardingAdminsChatReportID(adminsChatReportID);
Welcome.setOnboardingPolicyID(policyID);
- }, [isVsb, allPolicies, allPoliciesResult]);
+ }, [isVsb, paidGroupPolicy, allPolicies, allPoliciesResult]);
+
+ // Set onboardingPolicyID and onboardingAdminsChatReportID if a workspace is created by the backend for OD signups
+ useEffect(() => {
+ if (!paidGroupPolicy || onboardingPolicyID) {
+ return;
+ }
+ Welcome.setOnboardingAdminsChatReportID(paidGroupPolicy.chatReportIDAdmins?.toString());
+ Welcome.setOnboardingPolicyID(paidGroupPolicy.id);
+ }, [paidGroupPolicy, onboardingPolicyID]);
const accountingOptions: OnboardingListItem[] = useMemo(() => {
const policyAccountingOptions = Object.values(CONST.POLICY.CONNECTIONS.NAME)
diff --git a/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx b/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx
index 91919a64d0bb..dd8b9745ed7d 100644
--- a/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx
+++ b/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx
@@ -12,6 +12,7 @@ import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
+import * as PolicyUtils from '@libs/PolicyUtils';
import * as Policy from '@userActions/Policy/Policy';
import * as Welcome from '@userActions/Welcome';
import CONST from '@src/CONST';
@@ -29,6 +30,10 @@ function BaseOnboardingEmployees({shouldUseNativeStyles, route}: BaseOnboardingE
const [onboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE);
const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED);
const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID);
+ const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
+
+ const paidGroupPolicy = Object.values(allPolicies ?? {}).find(PolicyUtils.isPaidGroupPolicy);
+
const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
const [selectedCompanySize, setSelectedCompanySize] = useState(onboardingCompanySize);
const [error, setError] = useState('');
@@ -63,7 +68,7 @@ function BaseOnboardingEmployees({shouldUseNativeStyles, route}: BaseOnboardingE
}
Welcome.setOnboardingCompanySize(selectedCompanySize);
- if (!onboardingPolicyID) {
+ if (!onboardingPolicyID && !paidGroupPolicy) {
const {adminsChatReportID, policyID} = Policy.createWorkspace(undefined, true, '', Policy.generatePolicyID(), CONST.ONBOARDING_CHOICES.MANAGE_TEAM);
Welcome.setOnboardingAdminsChatReportID(adminsChatReportID);
Welcome.setOnboardingPolicyID(policyID);
diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx
index dc751bae7bff..15de7a6e5f4a 100644
--- a/src/pages/ReportDetailsPage.tsx
+++ b/src/pages/ReportDetailsPage.tsx
@@ -87,12 +87,10 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
const backTo = route.params.backTo;
// The app would crash due to subscribing to the entire report collection if parentReportID is an empty string. So we should have a fallback ID here.
- /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
- const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID || '-1'}`);
- const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID || '-1'}`);
- const [parentReportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.parentReportID || '-1'}`);
- const {reportActions} = usePaginatedReportActions(report.reportID || '-1');
- /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */
+ const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`);
+ const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`);
+ const [parentReportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.parentReportID}`);
+ const {reportActions} = usePaginatedReportActions(report.reportID);
const {currentSearchHash} = useSearchContext();
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal
@@ -116,9 +114,9 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false);
const [offlineModalVisible, setOfflineModalVisible] = useState(false);
const [downloadErrorModalVisible, setDownloadErrorModalVisible] = useState(false);
- const policy = useMemo(() => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? '-1'}`], [policies, report?.policyID]);
+ const policy = useMemo(() => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`], [policies, report?.policyID]);
const isPolicyAdmin = useMemo(() => PolicyUtils.isPolicyAdmin(policy), [policy]);
- const isPolicyEmployee = useMemo(() => PolicyUtils.isPolicyEmployee(report?.policyID ?? '-1', policies), [report?.policyID, policies]);
+ const isPolicyEmployee = useMemo(() => PolicyUtils.isPolicyEmployee(report?.policyID, policies), [report?.policyID, policies]);
const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(report), [report]);
const shouldUseFullTitle = useMemo(() => ReportUtils.shouldUseFullTitleToDisplay(report), [report]);
const isChatRoom = useMemo(() => ReportUtils.isChatRoom(report), [report]);
@@ -133,7 +131,7 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
const isTaskReport = useMemo(() => ReportUtils.isTaskReport(report), [report]);
const isSelfDM = useMemo(() => ReportUtils.isSelfDM(report), [report]);
const isTrackExpenseReport = ReportUtils.isTrackExpenseReport(report);
- const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '', report?.parentReportActionID ?? '');
+ const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID, report?.parentReportActionID);
const isCanceledTaskReport = ReportUtils.isCanceledTaskReport(report, parentReportAction);
const canEditReportDescription = useMemo(() => ReportUtils.canEditReportDescription(report, policy), [report, policy]);
const shouldShowReportDescription = isChatRoom && (canEditReportDescription || report.description !== '');
@@ -143,7 +141,15 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
const shouldDisableRename = useMemo(() => ReportUtils.shouldDisableRename(report), [report]);
const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(report);
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- policy is a dependency because `getChatRoomSubtitle` calls `getPolicyName` which in turn retrieves the value from the `policy` value stored in Onyx
- const chatRoomSubtitle = useMemo(() => ReportUtils.getChatRoomSubtitle(report), [report, policy]);
+ const chatRoomSubtitle = useMemo(() => {
+ const subtitle = ReportUtils.getChatRoomSubtitle(report);
+
+ if (subtitle) {
+ return subtitle;
+ }
+
+ return '';
+ }, [report]);
const isSystemChat = useMemo(() => ReportUtils.isSystemChat(report), [report]);
const isGroupChat = useMemo(() => ReportUtils.isGroupChat(report), [report]);
const isRootGroupChat = useMemo(() => ReportUtils.isRootGroupChat(report), [report]);
@@ -163,7 +169,7 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
// Get the active chat members by filtering out the pending members with delete action
const activeChatMembers = participants.flatMap((accountID) => {
- const pendingMember = report?.pendingChatMembers?.findLast((member) => member.accountID === accountID.toString());
+ const pendingMember = reportMetadata?.pendingChatMembers?.findLast((member) => member.accountID === accountID.toString());
const detail = personalDetails?.[accountID];
if (!detail) {
return [];
@@ -209,8 +215,8 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
const moneyRequestAction = transactionThreadReportID ? requestParentReportAction : parentReportAction;
- const canModifyTask = Task.canModifyTask(report, session?.accountID ?? -1);
- const canActionTask = Task.canActionTask(report, session?.accountID ?? -1);
+ const canModifyTask = Task.canModifyTask(report, session?.accountID ?? CONST.DEFAULT_NUMBER_ID);
+ const canActionTask = Task.canActionTask(report, session?.accountID ?? CONST.DEFAULT_NUMBER_ID);
const shouldShowTaskDeleteButton =
isTaskReport &&
!isCanceledTaskReport &&
@@ -239,7 +245,7 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
return;
}
- Report.getReportPrivateNote(report?.reportID ?? '-1');
+ Report.getReportPrivateNote(report?.reportID);
}, [report?.reportID, isOffline, isPrivateNotesFetchTriggered, isSelfDM]);
const leaveChat = useCallback(() => {
@@ -293,14 +299,12 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
const shouldShowMenuItem = shouldShowNotificationPref || shouldShowWriteCapability || (!!report?.visibility && report.chatType !== CONST.REPORT.CHAT_TYPE.INVOICE);
const isPayer = ReportUtils.isPayer(session, moneyRequestReport);
- const isSettled = ReportUtils.isSettled(moneyRequestReport?.reportID ?? '-1');
+ const isSettled = ReportUtils.isSettled(moneyRequestReport?.reportID);
const shouldShowCancelPaymentButton = caseID === CASES.MONEY_REPORT && isPayer && isSettled && ReportUtils.isExpenseReport(moneyRequestReport);
- const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport?.chatReportID ?? '-1'}`);
+ const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport?.chatReportID}`);
- const iouTransactionID = ReportActionsUtils.isMoneyRequestAction(requestParentReportAction)
- ? ReportActionsUtils.getOriginalMessage(requestParentReportAction)?.IOUTransactionID ?? ''
- : '';
+ const iouTransactionID = ReportActionsUtils.isMoneyRequestAction(requestParentReportAction) ? ReportActionsUtils.getOriginalMessage(requestParentReportAction)?.IOUTransactionID : '';
const cancelPayment = useCallback(() => {
if (!chatReport) {
@@ -343,9 +347,9 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
shouldShowRightIcon: true,
action: () => {
if (shouldOpenRoomMembersPage) {
- Navigation.navigate(ROUTES.ROOM_MEMBERS.getRoute(report?.reportID ?? '-1', backTo));
+ Navigation.navigate(ROUTES.ROOM_MEMBERS.getRoute(report?.reportID, backTo));
} else {
- Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(report?.reportID ?? '-1', backTo));
+ Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(report?.reportID, backTo));
}
},
});
@@ -357,7 +361,7 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
isAnonymousAction: false,
shouldShowRightIcon: true,
action: () => {
- Navigation.navigate(ROUTES.ROOM_INVITE.getRoute(report?.reportID ?? '-1'));
+ Navigation.navigate(ROUTES.ROOM_INVITE.getRoute(report?.reportID));
},
});
}
@@ -370,15 +374,15 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
isAnonymousAction: false,
shouldShowRightIcon: true,
action: () => {
- Navigation.navigate(ROUTES.REPORT_SETTINGS.getRoute(report?.reportID ?? '-1', backTo));
+ Navigation.navigate(ROUTES.REPORT_SETTINGS.getRoute(report?.reportID, backTo));
},
});
}
if (isTrackExpenseReport && !isDeletedParentAction) {
- const actionReportID = ReportUtils.getOriginalReportID(report.reportID, parentReportAction) ?? '0';
- const whisperAction = ReportActionsUtils.getTrackExpenseActionableWhisper(iouTransactionID, moneyRequestReport?.reportID ?? '0');
- const actionableWhisperReportActionID = whisperAction?.reportActionID ?? '0';
+ const actionReportID = ReportUtils.getOriginalReportID(report.reportID, parentReportAction);
+ const whisperAction = ReportActionsUtils.getTrackExpenseActionableWhisper(iouTransactionID, moneyRequestReport?.reportID);
+ const actionableWhisperReportActionID = whisperAction?.reportActionID;
items.push({
key: CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS,
translationKey: 'actionableMentionTrackExpense.submit',
@@ -493,7 +497,7 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
icon: Expensicons.Upload,
isAnonymousAction: false,
action: () => {
- Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS_EXPORT.getRoute(report?.reportID ?? '', connectedIntegration, backTo));
+ Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS_EXPORT.getRoute(report?.reportID, connectedIntegration, backTo));
},
});
}
@@ -608,18 +612,18 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
isUsingDefaultAvatar={!report.avatarUrl}
size={CONST.AVATAR_SIZE.XLARGE}
avatarStyle={styles.avatarXLarge}
- onViewPhotoPress={() => Navigation.navigate(ROUTES.REPORT_AVATAR.getRoute(report.reportID ?? '-1'))}
+ onViewPhotoPress={() => Navigation.navigate(ROUTES.REPORT_AVATAR.getRoute(report.reportID))}
onImageRemoved={() => {
// Calling this without a file will remove the avatar
- Report.updateGroupChatAvatar(report.reportID ?? '');
+ Report.updateGroupChatAvatar(report.reportID);
}}
- onImageSelected={(file) => Report.updateGroupChatAvatar(report.reportID ?? '-1', file)}
+ onImageSelected={(file) => Report.updateGroupChatAvatar(report.reportID, file)}
editIcon={Expensicons.Camera}
editIconStyle={styles.smallEditIconAccount}
pendingAction={report.pendingFields?.avatar ?? undefined}
errors={report.errorFields?.avatar ?? null}
errorRowStyles={styles.mt6}
- onErrorClose={() => Report.clearAvatarErrors(report.reportID ?? '-1')}
+ onErrorClose={() => Report.clearAvatarErrors(report.reportID)}
shouldUseStyleUtilityForAnchorPosition
style={[styles.w100, styles.mb3]}
/>
@@ -655,7 +659,7 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
PromotedActions.hold({
isTextHold: canHoldUnholdReportAction.canHoldRequest,
reportAction: moneyRequestAction,
- reportID: transactionThreadReportID ? report.reportID : moneyRequestAction?.childReportID ?? '-1',
+ reportID: transactionThreadReportID ? report.reportID : moneyRequestAction?.childReportID,
isDelegateAccessRestricted,
setIsNoDelegateAccessMenuVisible,
currentSearchHash,
@@ -689,7 +693,7 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
<>
{
- Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(report?.policyID ?? ''));
+ let policyID = report?.policyID;
+
+ if (!policyID) {
+ policyID = '';
+ }
+
+ Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(policyID));
}}
>
{chatRoomSubtitleText}
@@ -755,7 +765,7 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
const fields = ReportUtils.getAvailableReportFields(report, Object.values(policy?.fieldList ?? {}));
return fields.find((reportField) => ReportUtils.isReportFieldOfTypeTitle(reportField));
}, [report, policy?.fieldList]);
- const fieldKey = ReportUtils.getReportFieldKey(titleField?.fieldID ?? '-1');
+ const fieldKey = ReportUtils.getReportFieldKey(titleField?.fieldID);
const isFieldDisabled = ReportUtils.isReportFieldDisabled(report, titleField, policy);
const shouldShowTitleField = caseID !== CASES.MONEY_REQUEST && !isFieldDisabled && ReportUtils.isAdminOwnerApproverOrReportOwner(report, policy);
@@ -790,7 +800,15 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
titleStyle={styles.newKansasLarge}
shouldCheckActionAllowedOnPress={false}
description={Str.UCFirst(titleField.name)}
- onPress={() => Navigation.navigate(ROUTES.EDIT_REPORT_FIELD_REQUEST.getRoute(report.reportID, report.policyID ?? '-1', titleField.fieldID ?? '-1', backTo))}
+ onPress={() => {
+ let policyID = report.policyID;
+
+ if (!policyID) {
+ policyID = '';
+ }
+
+ Navigation.navigate(ROUTES.EDIT_REPORT_FIELD_REQUEST.getRoute(report.reportID, policyID, titleField.fieldID, backTo));
+ }}
furtherDetailsComponent={nameSectionFurtherDetailsContent}
/>
@@ -810,7 +828,7 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
const isTrackExpense = ReportActionsUtils.isTrackExpenseAction(requestParentReportAction);
if (isTrackExpense) {
- IOU.deleteTrackExpense(moneyRequestReport?.reportID ?? '', iouTransactionID, requestParentReportAction, isSingleTransactionView);
+ IOU.deleteTrackExpense(moneyRequestReport?.reportID, iouTransactionID, requestParentReportAction, isSingleTransactionView);
} else {
IOU.deleteMoneyRequest(iouTransactionID, requestParentReportAction, isSingleTransactionView);
}
@@ -851,7 +869,7 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
if (!isEmptyObject(requestParentReportAction)) {
const isTrackExpense = ReportActionsUtils.isTrackExpenseAction(requestParentReportAction);
if (isTrackExpense) {
- urlToNavigateBack = IOU.getNavigationUrlAfterTrackExpenseDelete(moneyRequestReport?.reportID ?? '', iouTransactionID, requestParentReportAction, isSingleTransactionView);
+ urlToNavigateBack = IOU.getNavigationUrlAfterTrackExpenseDelete(moneyRequestReport?.reportID, iouTransactionID, requestParentReportAction, isSingleTransactionView);
} else {
urlToNavigateBack = IOU.getNavigationUrlOnMoneyRequestDelete(iouTransactionID, requestParentReportAction, isSingleTransactionView);
}
diff --git a/src/pages/ReportParticipantsPage.tsx b/src/pages/ReportParticipantsPage.tsx
index 4c63cc4b4492..e02a51b26502 100755
--- a/src/pages/ReportParticipantsPage.tsx
+++ b/src/pages/ReportParticipantsPage.tsx
@@ -57,7 +57,8 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
const selectionListRef = useRef(null);
const textInputRef = useRef(null);
const [userSearchPhrase] = useOnyx(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE);
- const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`);
+ const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`);
+ const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`);
const {selectionMode} = useMobileSelectionMode();
const [session] = useOnyx(ONYXKEYS.SESSION);
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
@@ -78,7 +79,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
const chatParticipants = ReportUtils.getParticipantsList(report, personalDetails);
- const pendingChatMembers = report?.pendingChatMembers;
+ const pendingChatMembers = reportMetadata?.pendingChatMembers;
const reportParticipants = report?.participants;
// Get the active chat members by filtering out the pending members with delete action
diff --git a/src/pages/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx
index 0f29f1d00ba9..a14ada3d3f00 100644
--- a/src/pages/RoomMembersPage.tsx
+++ b/src/pages/RoomMembersPage.tsx
@@ -48,6 +48,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
const route = useRoute>();
const styles = useThemeStyles();
const [session] = useOnyx(ONYXKEYS.SESSION);
+ const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`);
const currentUserAccountID = Number(session?.accountID);
const {formatPhoneNumber, translate} = useLocalize();
const [selectedMembers, setSelectedMembers] = useState([]);
@@ -56,7 +57,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
const [searchValue, setSearchValue] = useState('');
const [didLoadRoomMembers, setDidLoadRoomMembers] = useState(false);
const personalDetails = usePersonalDetails();
- const policy = useMemo(() => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? ''}`], [policies, report?.policyID]);
+ const policy = useMemo(() => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`], [policies, report?.policyID]);
const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(report), [report]);
const backTo = route.params.backTo;
@@ -175,7 +176,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
const shouldShowTextInput = useMemo(() => {
// Get the active chat members by filtering out the pending members with delete action
const activeParticipants = participants.filter((accountID) => {
- const pendingMember = report?.pendingChatMembers?.findLast((member) => member.accountID === accountID.toString());
+ const pendingMember = reportMetadata?.pendingChatMembers?.findLast((member) => member.accountID === accountID.toString());
if (!personalDetails?.[accountID]) {
return false;
}
@@ -183,7 +184,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
return !pendingMember || isOffline || pendingMember.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
});
return activeParticipants.length >= CONST.STANDARD_LIST_ITEM_LIMIT;
- }, [participants, personalDetails, isOffline, report]);
+ }, [participants, reportMetadata?.pendingChatMembers, personalDetails, isOffline]);
useEffect(() => {
if (!isFocusedScreen || !shouldShowTextInput) {
@@ -218,7 +219,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
if (!details || (searchValue.trim() && !OptionsListUtils.isSearchStringMatchUserDetails(details, searchValue))) {
return;
}
- const pendingChatMember = report?.pendingChatMembers?.findLast((member) => member.accountID === accountID.toString());
+ const pendingChatMember = reportMetadata?.pendingChatMembers?.findLast((member) => member.accountID === accountID.toString());
const isAdmin = PolicyUtils.isUserPolicyAdmin(policy, details.login);
const isDisabled = pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || details.isOptimisticPersonalDetail;
const isDisabledCheckbox =
@@ -251,11 +252,22 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
result = result.sort((value1, value2) => localeCompare(value1.text ?? '', value2.text ?? ''));
return result;
- }, [formatPhoneNumber, isPolicyExpenseChat, participants, personalDetails, policy, report.ownerAccountID, report?.pendingChatMembers, searchValue, selectedMembers, session?.accountID]);
+ }, [
+ formatPhoneNumber,
+ isPolicyExpenseChat,
+ participants,
+ personalDetails,
+ policy,
+ report.ownerAccountID,
+ reportMetadata?.pendingChatMembers,
+ searchValue,
+ selectedMembers,
+ session?.accountID,
+ ]);
const dismissError = useCallback(
(item: ListItem) => {
- Report.clearAddRoomMemberError(report.reportID, String(item.accountID ?? '-1'));
+ Report.clearAddRoomMemberError(report.reportID, String(item.accountID));
},
[report.reportID],
);
@@ -313,7 +325,11 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
/** Opens the room member details page */
const openRoomMemberDetails = useCallback(
(item: ListItem) => {
- Navigation.navigate(ROUTES.ROOM_MEMBER_DETAILS.getRoute(report.reportID, item?.accountID ?? -1, backTo));
+ if (!item?.accountID) {
+ return;
+ }
+
+ Navigation.navigate(ROUTES.ROOM_MEMBER_DETAILS.getRoute(report.reportID, item?.accountID, backTo));
},
[report, backTo],
);
diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx
index 02eca4b9fbbc..10c8401b98aa 100644
--- a/src/pages/Search/AdvancedSearchFilters.tsx
+++ b/src/pages/Search/AdvancedSearchFilters.tsx
@@ -439,11 +439,6 @@ function AdvancedSearchFilters() {
return;
}
- // We only want to show the tooltip once, the NVP will not be set if the user has not saved a search yet
- if (!savedSearches) {
- SearchActions.showSavedSearchRenameTooltip();
- }
-
SearchActions.saveSearch({
queryJSON,
});
diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx
index 5c93a3877ff6..6d6543554869 100644
--- a/src/pages/Search/SearchTypeMenu.tsx
+++ b/src/pages/Search/SearchTypeMenu.tsx
@@ -8,6 +8,7 @@ import MenuItem from '@components/MenuItem';
import MenuItemList from '@components/MenuItemList';
import type {MenuItemWithLink} from '@components/MenuItemList';
import {usePersonalDetails} from '@components/OnyxProvider';
+import {useProductTrainingContext} from '@components/ProductTrainingContext';
import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider';
import ScrollView from '@components/ScrollView';
import type {SearchQueryJSON} from '@components/Search/types';
@@ -62,14 +63,18 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) {
const {singleExecution} = useSingleExecution();
const {translate} = useLocalize();
const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES);
- const [shouldShowSavedSearchRenameTooltip] = useOnyx(ONYXKEYS.SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP);
+ const {isOffline} = useNetwork();
+ const shouldShowSavedSearchesMenuItemTitle = Object.values(savedSearches ?? {}).filter((s) => s.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline).length > 0;
+ const {shouldShowProductTrainingTooltip, renderProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext(
+ CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.RENAME_SAVED_SEARCH,
+ shouldShowSavedSearchesMenuItemTitle,
+ );
const {showDeleteModal, DeleteConfirmModal} = useDeleteSavedSearch();
const [session] = useOnyx(ONYXKEYS.SESSION);
const personalDetails = usePersonalDetails();
const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
const taxRates = getAllTaxRates();
- const {isOffline} = useNetwork();
const typeMenuItems: SearchTypeMenuItem[] = [
{
@@ -118,65 +123,69 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) {
[showDeleteModal],
);
- const createSavedSearchMenuItem = (item: SaveSearchItem, key: string, isNarrow: boolean, index: number) => {
- let title = item.name;
- if (title === item.query) {
- const jsonQuery = SearchQueryUtils.buildSearchQueryJSON(item.query) ?? ({} as SearchQueryJSON);
- title = SearchQueryUtils.buildUserReadableQueryString(jsonQuery, personalDetails, reports, taxRates);
- }
-
- const baseMenuItem: SavedSearchMenuItem = {
- key,
- title,
- hash: key,
- query: item.query,
- shouldShowRightComponent: true,
- focused: Number(key) === hash,
- onPress: () => {
- SearchActions.clearAllFilters();
- Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: item?.query ?? '', name: item?.name}));
- },
- rightComponent: (
-
- ),
- styles: [styles.alignItemsCenter],
- pendingAction: item.pendingAction,
- disabled: item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
- shouldIconUseAutoWidthStyle: true,
- };
+ const createSavedSearchMenuItem = useCallback(
+ (item: SaveSearchItem, key: string, isNarrow: boolean, index: number) => {
+ let title = item.name;
+ if (title === item.query) {
+ const jsonQuery = SearchQueryUtils.buildSearchQueryJSON(item.query) ?? ({} as SearchQueryJSON);
+ title = SearchQueryUtils.buildUserReadableQueryString(jsonQuery, personalDetails, reports, taxRates);
+ }
- if (!isNarrow) {
- return {
- ...baseMenuItem,
- shouldRenderTooltip: index === 0 && shouldShowSavedSearchRenameTooltip === true,
- tooltipAnchorAlignment: {
- horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
- vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
- },
- tooltipShiftHorizontal: -32,
- tooltipShiftVertical: 15,
- tooltipWrapperStyle: [styles.bgPaleGreen, styles.mh4, styles.pv2],
- onHideTooltip: SearchActions.dismissSavedSearchRenameTooltip,
- renderTooltipContent: () => {
- return (
-
-
- {translate('search.saveSearchTooltipText')}
-
- );
+ const baseMenuItem: SavedSearchMenuItem = {
+ key,
+ title,
+ hash: key,
+ query: item.query,
+ shouldShowRightComponent: true,
+ focused: Number(key) === hash,
+ onPress: () => {
+ SearchActions.clearAllFilters();
+ Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: item?.query ?? '', name: item?.name}));
},
+ rightComponent: (
+
+ ),
+ styles: [styles.alignItemsCenter],
+ pendingAction: item.pendingAction,
+ disabled: item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
+ shouldIconUseAutoWidthStyle: true,
};
- }
- return baseMenuItem;
- };
+ if (!isNarrow) {
+ return {
+ ...baseMenuItem,
+ shouldRenderTooltip: index === 0 && shouldShowProductTrainingTooltip,
+ tooltipAnchorAlignment: {
+ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
+ vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
+ },
+ tooltipShiftHorizontal: -32,
+ tooltipShiftVertical: 15,
+ tooltipWrapperStyle: [styles.bgPaleGreen, styles.mh4, styles.pv2],
+ onHideTooltip: hideProductTrainingTooltip,
+ renderTooltipContent: renderProductTrainingTooltip,
+ };
+ }
+ return baseMenuItem;
+ },
+ [
+ hash,
+ getOverflowMenu,
+ styles.alignItemsCenter,
+ styles.bgPaleGreen,
+ styles.mh4,
+ styles.pv2,
+ personalDetails,
+ reports,
+ taxRates,
+ shouldShowProductTrainingTooltip,
+ hideProductTrainingTooltip,
+ renderProductTrainingTooltip,
+ ],
+ );
const route = useRoute();
const scrollViewRef = useRef(null);
@@ -201,12 +210,12 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) {
scrollViewRef.current.scrollTo({y: scrollOffset, animated: false});
}, [getScrollOffset, route]);
- const savedSearchesMenuItems = () => {
+ const savedSearchesMenuItems = useCallback(() => {
if (!savedSearches) {
return [];
}
return Object.entries(savedSearches).map(([key, item], index) => createSavedSearchMenuItem(item, key, shouldUseNarrowLayout, index));
- };
+ }, [createSavedSearchMenuItem, savedSearches, shouldUseNarrowLayout]);
const renderSavedSearchesSection = useCallback(
(menuItems: MenuItemWithLink[]) => (
@@ -240,7 +249,6 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) {
/>
);
}
- const shouldShowSavedSearchesMenuItemTitle = Object.values(savedSearches ?? {}).filter((s) => s.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline).length > 0;
return (
(null);
+ const platform = getPlatform();
+ const isWebOrDesktop = platform === CONST.PLATFORM.WEB || platform === CONST.PLATFORM.DESKTOP;
+
const openMenu = useCallback(() => setIsPopoverVisible(true), []);
const closeMenu = useCallback(() => setIsPopoverVisible(false), []);
const onPress = () => {
@@ -75,6 +81,9 @@ function SearchTypeMenuNarrow({typeMenuItems, activeItemIndex, queryJSON, title,
SearchActions.updateAdvancedFilters(values);
Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS);
};
+ const {renderProductTrainingTooltip, shouldShowProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext(
+ CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.SEARCH_FILTER_BUTTON_TOOLTIP,
+ );
const currentSavedSearch = savedSearchesMenuItems.find((item) => Number(item.hash) === hash);
@@ -200,10 +209,24 @@ function SearchTypeMenuNarrow({typeMenuItems, activeItemIndex, queryJSON, title,
)}
-
+
+
+
getParentReportAction(parentReportActions, reportOnyx?.parentReportActionID ?? ''),
});
const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP);
- const [workspaceTooltip] = useOnyx(ONYXKEYS.NVP_WORKSPACE_TOOLTIP);
const wasLoadingApp = usePrevious(isLoadingApp);
const finishedLoadingApp = wasLoadingApp && !isLoadingApp;
const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(parentReportAction);
@@ -229,7 +228,6 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
permissions,
invoiceReceiver: reportOnyx.invoiceReceiver,
policyAvatar: reportOnyx.policyAvatar,
- pendingChatMembers: reportOnyx.pendingChatMembers,
},
[reportOnyx, permissions],
);
@@ -254,7 +252,6 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
const {reportPendingAction, reportErrors} = ReportUtils.getReportOfflinePendingActionAndErrors(report);
const screenWrapperStyle: ViewStyle[] = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}];
- const isEmptyChat = useMemo(() => ReportUtils.isEmptyReport(report), [report]);
const isOptimisticDelete = report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED;
const indexOfLinkedMessage = useMemo(
(): number => reportActions.findIndex((obj) => String(obj.reportActionID) === String(reportActionIDFromRoute)),
@@ -282,6 +279,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? '-1'}`];
const isTopMostReportId = currentReportID === reportIDFromRoute;
const didSubscribeToReportLeavingEvents = useRef(false);
+ const [showSoftInputOnFocus, setShowSoftInputOnFocus] = useState(false);
useEffect(() => {
if (!report?.reportID || shouldHideReport) {
@@ -296,7 +294,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
Navigation.dismissModal();
return;
}
- Navigation.goBack(ROUTES.HOME, false, true);
+ Navigation.goBack(undefined, false, true);
}, [isInNarrowPaneModal]);
let headerView = (
@@ -759,7 +757,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
) : null}
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
index 7b0d6663facf..b56109b64c40 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
@@ -40,7 +40,6 @@ import getPlatform from '@libs/getPlatform';
import * as KeyDownListener from '@libs/KeyboardShortcut/KeyDownPressListener';
import Parser from '@libs/Parser';
import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager';
-import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
import updateMultilineInputRange from '@libs/updateMultilineInputRange';
import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside';
@@ -126,27 +125,26 @@ type ComposerWithSuggestionsProps = Partial & {
/** The ref to the next modal will open */
isNextModalWillOpenRef: MutableRefObject;
- /** Wheater chat is empty */
- isEmptyChat?: boolean;
-
/** The last report action */
lastReportAction?: OnyxEntry;
/** Whether to include chronos */
includeChronos?: boolean;
- /** The parent report action ID */
- parentReportActionID?: string;
-
- /** The parent report ID */
- // eslint-disable-next-line react/no-unused-prop-types -- its used in the withOnyx HOC
- parentReportID: string | undefined;
-
/** Whether report is from group policy */
isGroupPolicyReport: boolean;
/** policy ID of the report */
- policyID: string;
+ policyID?: string;
+
+ /** Whether to show the keyboard on focus */
+ showSoftInputOnFocus: boolean;
+
+ /** A method to update showSoftInputOnFocus */
+ setShowSoftInputOnFocus: (value: boolean) => void;
+
+ /** Whether the main composer was hidden */
+ didHideComposerInput?: boolean;
};
type SwitchToCurrentReportProps = {
@@ -187,10 +185,6 @@ const debouncedBroadcastUserIsTyping = lodashDebounce(
const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc();
-// We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will
-// prevent auto focus on existing chat for mobile device
-const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
-
/**
* This component holds the value and selection state.
* If a component really needs access to these state values it should be put here.
@@ -201,11 +195,8 @@ function ComposerWithSuggestions(
{
// Props: Report
reportID,
- parentReportID,
includeChronos,
- isEmptyChat,
lastReportAction,
- parentReportActionID,
isGroupPolicyReport,
policyID,
@@ -236,6 +227,9 @@ function ComposerWithSuggestions(
// For testing
children,
+ showSoftInputOnFocus,
+ setShowSoftInputOnFocus,
+ didHideComposerInput,
}: ComposerWithSuggestionsProps,
ref: ForwardedRef,
) {
@@ -257,14 +251,12 @@ function ComposerWithSuggestions(
}
return draftComment;
});
+
const commentRef = useRef(value);
- const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
const [modal] = useOnyx(ONYXKEYS.MODAL);
const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, {selector: EmojiUtils.getPreferredSkinToneIndex});
const [editFocused] = useOnyx(ONYXKEYS.INPUT_FOCUSED);
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID || '-1'}`, {canEvict: false, initWithStoredValues: false});
const lastTextRef = useRef(value);
useEffect(() => {
@@ -274,13 +266,7 @@ function ComposerWithSuggestions(
const {shouldUseNarrowLayout} = useResponsiveLayout();
const maxComposerLines = shouldUseNarrowLayout ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES;
- const parentReportAction = useMemo(() => parentReportActions?.[parentReportActionID ?? '-1'], [parentReportActionID, parentReportActions]);
- const shouldAutoFocus =
- !modal?.isVisible &&
- Modal.areAllModalsHidden() &&
- isFocused &&
- (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentReportAction) && !ReportUtils.isTaskReport(report))) &&
- shouldShowComposeInput;
+ const shouldAutoFocus = !modal?.isVisible && shouldShowComposeInput && Modal.areAllModalsHidden() && isFocused && !didHideComposerInput;
const valueRef = useRef(value);
valueRef.current = value;
@@ -643,7 +629,15 @@ function ComposerWithSuggestions(
// We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused.
// We avoid doing this on native platforms since the software keyboard popping
// open creates a jarring and broken UX.
- if (!((willBlurTextInputOnTapOutside || shouldAutoFocus) && !isNextModalWillOpenRef.current && !modal?.isVisible && isFocused && (!!prevIsModalVisible || !prevIsFocused))) {
+ if (
+ !(
+ (willBlurTextInputOnTapOutside || (shouldAutoFocus && canFocusInputOnScreenFocus())) &&
+ !isNextModalWillOpenRef.current &&
+ !modal?.isVisible &&
+ isFocused &&
+ (!!prevIsModalVisible || !prevIsFocused)
+ )
+ ) {
return;
}
@@ -775,6 +769,19 @@ function ComposerWithSuggestions(
onScroll={hideSuggestionMenu}
shouldContainScroll={Browser.isMobileSafari()}
isGroupPolicyReport={isGroupPolicyReport}
+ showSoftInputOnFocus={showSoftInputOnFocus}
+ onTouchStart={() => {
+ if (showSoftInputOnFocus) {
+ return;
+ }
+ if (Browser.isMobileSafari()) {
+ setTimeout(() => {
+ setShowSoftInputOnFocus(true);
+ }, CONST.ANIMATED_TRANSITION);
+ return;
+ }
+ setShowSoftInputOnFocus(true);
+ }}
/>
diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
index 893d2b3060d9..c3caaf16a3ef 100644
--- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
+++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
@@ -12,14 +12,12 @@ import type {FileObject} from '@components/AttachmentModal';
import AttachmentModal from '@components/AttachmentModal';
import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton';
import ExceededCommentLength from '@components/ExceededCommentLength';
-import Icon from '@components/Icon';
-import * as Expensicons from '@components/Icon/Expensicons';
import ImportedStateIndicator from '@components/ImportedStateIndicator';
import type {Mention} from '@components/MentionSuggestions';
import OfflineIndicator from '@components/OfflineIndicator';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import {usePersonalDetails} from '@components/OnyxProvider';
-import Text from '@components/Text';
+import {useProductTrainingContext} from '@components/ProductTrainingContext';
import EducationalTooltip from '@components/Tooltip/EducationalTooltip';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useDebounce from '@hooks/useDebounce';
@@ -28,7 +26,6 @@ import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitl
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
-import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
@@ -67,7 +64,7 @@ type SuggestionsRef = {
getIsSuggestionsMenuVisible: () => boolean;
};
-type ReportActionComposeProps = Pick & {
+type ReportActionComposeProps = Pick & {
/** A method to call when the form is submitted */
onSubmit: (newComment: string) => void;
@@ -91,6 +88,15 @@ type ReportActionComposeProps = Pick void;
+
+ /** Whether the main composer was hidden */
+ didHideComposerInput?: boolean;
};
// We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will
@@ -110,13 +116,14 @@ function ReportActionCompose({
report,
reportID,
isReportReadyForDisplay = true,
- isEmptyChat,
lastReportAction,
shouldShowEducationalTooltip,
+ showSoftInputOnFocus,
onComposerFocus,
onComposerBlur,
+ setShowSoftInputOnFocus,
+ didHideComposerInput,
}: ReportActionComposeProps) {
- const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
@@ -129,6 +136,11 @@ function ReportActionCompose({
const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE);
const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT);
+ const {renderProductTrainingTooltip, hideProductTrainingTooltip, shouldShowProductTrainingTooltip} = useProductTrainingContext(
+ CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.WORKSAPCE_CHAT_CREATE,
+ shouldShowEducationalTooltip,
+ );
+
/**
* Updates the Highlight state of the composer
*/
@@ -323,7 +335,7 @@ function ReportActionCompose({
// We are returning a callback here as we want to incoke the method on unmount only
useEffect(
() => () => {
- if (!EmojiPickerActions.isActive(report?.reportID ?? '-1')) {
+ if (!EmojiPickerActions.isActive(report?.reportID)) {
return;
}
EmojiPickerActions.hideEmojiPicker();
@@ -380,34 +392,6 @@ function ReportActionCompose({
return reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM;
}, [styles]);
- const renderWorkspaceChatTooltip = useCallback(
- () => (
-
-
-
- {translate('reportActionCompose.tooltip.title')}
- {translate('reportActionCompose.tooltip.subtitle')}
-
-
- ),
- [
- styles.alignItemsCenter,
- styles.flexRow,
- styles.justifyContentCenter,
- styles.flexWrap,
- styles.textAlignCenter,
- styles.gap1,
- styles.quickActionTooltipTitle,
- styles.quickActionTooltipSubtitle,
- theme.tooltipHighlightText,
- translate,
- ],
- );
-
const validateMaxLength = useCallback(
(value: string) => {
const taskCommentMatch = value?.match(CONST.REGEX.TASK_TITLE_WITH_OPTONAL_SHORT_MENTION);
@@ -448,10 +432,10 @@ function ReportActionCompose({
contentContainerStyle={isComposerFullSize ? styles.flex1 : {}}
>
{
diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx
index e5bb2d4e608d..84f867afa0aa 100644
--- a/src/pages/home/report/ReportActionsList.tsx
+++ b/src/pages/home/report/ReportActionsList.tsx
@@ -169,7 +169,7 @@ function ReportActionsList({
const [isVisible, setIsVisible] = useState(Visibility.isVisible);
const isFocused = useIsFocused();
- const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`);
+ const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`);
const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID});
useEffect(() => {
@@ -184,7 +184,7 @@ function ReportActionsList({
const readActionSkipped = useRef(false);
const hasHeaderRendered = useRef(false);
const hasFooterRendered = useRef(false);
- const linkedReportActionID = route?.params?.reportActionID ?? '-1';
+ const linkedReportActionID = route?.params?.reportActionID;
const lastAction = sortedVisibleReportActions.at(0);
const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = useMemo(
@@ -263,10 +263,8 @@ function ReportActionsList({
return true;
}
- const isWithinVisibleThreshold = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? message.created < (userActiveSince.current ?? '') : true;
-
// If the unread marker should be hidden or is not within the visible area, don't show the unread marker.
- if (ReportActionsUtils.shouldHideNewMarker(message) || !isWithinVisibleThreshold) {
+ if (ReportActionsUtils.shouldHideNewMarker(message)) {
return false;
}
diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx
index 9771e357b15f..174549806db9 100644
--- a/src/pages/home/report/ReportFooter.tsx
+++ b/src/pages/home/report/ReportFooter.tsx
@@ -1,6 +1,6 @@
import {Str} from 'expensify-common';
import lodashIsEqual from 'lodash/isEqual';
-import React, {memo, useCallback} from 'react';
+import React, {memo, useCallback, useEffect, useState} from 'react';
import {Keyboard, View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
@@ -45,12 +45,6 @@ type ReportFooterProps = {
/** The last report action */
lastReportAction?: OnyxEntry;
- /** Whether to show educational tooltip in workspace chat for first-time user */
- workspaceTooltip: OnyxEntry;
-
- /** Whether the chat is empty */
- isEmptyChat?: boolean;
-
/** The pending action when we are adding a chat */
pendingAction?: PendingAction;
@@ -65,6 +59,12 @@ type ReportFooterProps = {
/** A method to call when the input is blur */
onComposerBlur: () => void;
+
+ /** Whether to show the keyboard on focus */
+ showSoftInputOnFocus: boolean;
+
+ /** A method to update showSoftInputOnFocus */
+ setShowSoftInputOnFocus: (value: boolean) => void;
};
function ReportFooter({
@@ -73,12 +73,12 @@ function ReportFooter({
report = {reportID: '-1'},
reportMetadata,
policy,
- isEmptyChat = true,
isReportReadyForDisplay = true,
isComposerFullSize = false,
- workspaceTooltip,
+ showSoftInputOnFocus,
onComposerBlur,
onComposerFocus,
+ setShowSoftInputOnFocus,
}: ReportFooterProps) {
const styles = useThemeStyles();
const {isOffline} = useNetwork();
@@ -103,7 +103,7 @@ function ReportFooter({
}
},
});
- const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`);
+ const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`);
const chatFooterStyles = {...styles.chatFooter, minHeight: !isOffline ? CONST.CHAT_FOOTER_MIN_HEIGHT : 0};
const isArchivedRoom = ReportUtils.isArchivedRoom(report, reportNameValuePairs);
@@ -118,7 +118,7 @@ function ReportFooter({
const isSystemChat = ReportUtils.isSystemChat(report);
const isAdminsOnlyPostingRoom = ReportUtils.isAdminsOnlyPostingRoom(report);
const isUserPolicyAdmin = PolicyUtils.isPolicyAdmin(policy);
- const shouldShowEducationalTooltip = !!workspaceTooltip?.shouldShow && !isUserPolicyAdmin;
+ const shouldShowEducationalTooltip = ReportUtils.isPolicyExpenseChat(report) && !!report.isOwnPolicyExpenseChat && !isUserPolicyAdmin;
const allPersonalDetails = usePersonalDetails();
@@ -172,6 +172,15 @@ function ReportFooter({
[report.reportID, handleCreateTask],
);
+ const [didHideComposerInput, setDidHideComposerInput] = useState(!shouldShowComposeInput);
+
+ useEffect(() => {
+ if (didHideComposerInput || shouldShowComposeInput) {
+ return;
+ }
+ setDidHideComposerInput(true);
+ }, [shouldShowComposeInput, didHideComposerInput]);
+
return (
<>
{!!shouldHideComposer && (
@@ -213,12 +222,14 @@ function ReportFooter({
onComposerBlur={onComposerBlur}
reportID={report.reportID}
report={report}
- isEmptyChat={isEmptyChat}
lastReportAction={lastReportAction}
pendingAction={pendingAction}
isComposerFullSize={isComposerFullSize}
isReportReadyForDisplay={isReportReadyForDisplay}
shouldShowEducationalTooltip={didScreenTransitionEnd && shouldShowEducationalTooltip}
+ showSoftInputOnFocus={showSoftInputOnFocus}
+ setShowSoftInputOnFocus={setShowSoftInputOnFocus}
+ didHideComposerInput={didHideComposerInput}
/>
@@ -235,10 +246,9 @@ export default memo(
lodashIsEqual(prevProps.report, nextProps.report) &&
prevProps.pendingAction === nextProps.pendingAction &&
prevProps.isComposerFullSize === nextProps.isComposerFullSize &&
- prevProps.isEmptyChat === nextProps.isEmptyChat &&
prevProps.lastReportAction === nextProps.lastReportAction &&
prevProps.isReportReadyForDisplay === nextProps.isReportReadyForDisplay &&
- prevProps.workspaceTooltip?.shouldShow === nextProps.workspaceTooltip?.shouldShow &&
+ prevProps.showSoftInputOnFocus === nextProps.showSoftInputOnFocus &&
lodashIsEqual(prevProps.reportMetadata, nextProps.reportMetadata) &&
lodashIsEqual(prevProps.policy?.employeeList, nextProps.policy?.employeeList) &&
lodashIsEqual(prevProps.policy?.role, nextProps.policy?.role),
diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
index f5cba2cb0edd..35c0bc5e0b1c 100644
--- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
+++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
@@ -11,7 +11,7 @@ import FloatingActionButton from '@components/FloatingActionButton';
import * as Expensicons from '@components/Icon/Expensicons';
import type {PopoverMenuItem} from '@components/PopoverMenu';
import PopoverMenu from '@components/PopoverMenu';
-import Text from '@components/Text';
+import {useProductTrainingContext} from '@components/ProductTrainingContext';
import useEnvironment from '@hooks/useEnvironment';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
@@ -207,6 +207,12 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl
const [hasSeenTour = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
selector: hasSeenTourSelector,
});
+
+ const {renderProductTrainingTooltip, hideProductTrainingTooltip, shouldShowProductTrainingTooltip} = useProductTrainingContext(
+ CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.QUICK_ACTION_BUTTON,
+ isCreateMenuActive && (!shouldUseNarrowLayout || isFocused),
+ );
+
/**
* There are scenarios where users who have not yet had their group workspace-chats in NewDot (isPolicyExpenseChatEnabled). In those scenarios, things can get confusing if they try to submit/track expenses. To address this, we block them from Creating, Tracking, Submitting expenses from NewDot if they are:
* 1. on at least one group policy
@@ -233,16 +239,6 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [personalDetails, session?.accountID, quickActionReport, quickActionPolicy, policyChatForActivePolicy]);
- const renderQuickActionTooltip = useCallback(
- () => (
-
- {translate('quickAction.tooltip.title')}
- {translate('quickAction.tooltip.subtitle')}
-
- ),
- [styles.quickActionTooltipTitle, styles.quickActionTooltipSubtitle, translate],
- );
-
const quickActionTitle = useMemo(() => {
if (isEmptyObject(quickActionReport)) {
return '';
@@ -381,8 +377,10 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl
},
tooltipShiftHorizontal: styles.popoverMenuItem.paddingHorizontal,
tooltipShiftVertical: styles.popoverMenuItem.paddingVertical / 2,
- renderTooltipContent: renderQuickActionTooltip,
- tooltipWrapperStyle: styles.quickActionTooltipWrapper,
+ renderTooltipContent: renderProductTrainingTooltip,
+ tooltipWrapperStyle: styles.productTrainingTooltipWrapper,
+ onHideTooltip: hideProductTrainingTooltip,
+ shouldRenderTooltip: shouldShowProductTrainingTooltip,
};
if (quickAction?.action) {
@@ -401,7 +399,6 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl
QuickActionNavigation.navigateToQuickAction(isValidReport, `${quickActionReport?.reportID ?? CONST.DEFAULT_NUMBER_ID}`, quickAction, selectOption),
),
shouldShowSubscriptRightAvatar: ReportUtils.isPolicyExpenseChat(quickActionReport),
- shouldRenderTooltip: quickAction.isFirstQuickAction,
},
];
}
@@ -420,7 +417,6 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl
}, true);
}),
shouldShowSubscriptRightAvatar: true,
- shouldRenderTooltip: false,
},
];
}
@@ -434,13 +430,15 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl
styles.popoverMenuItem.paddingVertical,
styles.pt3,
styles.pb2,
+ styles.productTrainingTooltipWrapper,
+ renderProductTrainingTooltip,
+ hideProductTrainingTooltip,
quickAction,
- styles.quickActionTooltipWrapper,
- renderQuickActionTooltip,
policyChatForActivePolicy,
quickActionTitle,
hideQABSubtitle,
quickActionReport,
+ shouldShowProductTrainingTooltip,
selectOption,
quickActionPolicy,
]);
diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
index ca41764260c9..6a67a1040f1b 100644
--- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx
+++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
@@ -5,7 +5,9 @@ import FormHelpMessage from '@components/FormHelpMessage';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import {READ_COMMANDS} from '@libs/API/types';
+import * as Browser from '@libs/Browser';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
+import getPlatform from '@libs/getPlatform';
import HttpUtils from '@libs/HttpUtils';
import * as IOUUtils from '@libs/IOUUtils';
import Navigation from '@libs/Navigation/Navigation';
@@ -15,9 +17,11 @@ import MoneyRequestParticipantsSelector from '@pages/iou/request/MoneyRequestPar
import * as IOU from '@userActions/IOU';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import type {Participant} from '@src/types/onyx/IOU';
+import KeyboardUtils from '@src/utils/keyboard';
import StepScreenWrapper from './StepScreenWrapper';
import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound';
import withFullTransactionOrNotFound from './withFullTransactionOrNotFound';
@@ -37,7 +41,7 @@ function IOURequestStepParticipants({
const {translate} = useLocalize();
const styles = useThemeStyles();
const isFocused = useIsFocused();
- const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? -1}`);
+ const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? CONST.DEFAULT_NUMBER_ID}`);
// We need to set selectedReportID if user has navigated back from confirmation page and navigates to confirmation page with already selected participant
const selectedReportID = useRef(participants?.length === 1 ? participants.at(0)?.reportID ?? reportID : reportID);
@@ -70,6 +74,8 @@ function IOURequestStepParticipants({
const receiptFilename = transaction?.filename;
const receiptPath = transaction?.receipt?.source;
const receiptType = transaction?.receipt?.type;
+ const isAndroidNative = getPlatform() === CONST.PLATFORM.ANDROID;
+ const isMobileSafari = Browser.isMobileSafari();
// When the component mounts, if there is a receipt, see if the image can be read from the disk. If not, redirect the user to the starting step of the flow.
// This is because until the expense is saved, the receipt file is only stored in the browsers memory as a blob:// and if the browser is refreshed, then
@@ -86,7 +92,7 @@ function IOURequestStepParticipants({
(val: Participant[]) => {
HttpUtils.cancelPendingRequests(READ_COMMANDS.SEARCH_FOR_REPORTS);
- const firstParticipantReportID = val.at(0)?.reportID ?? '';
+ const firstParticipantReportID = val.at(0)?.reportID;
const rateID = DistanceRequestUtils.getCustomUnitRateID(firstParticipantReportID);
const isInvoice = iouType === CONST.IOU.TYPE.INVOICE && ReportUtils.isInvoiceRoomWithID(firstParticipantReportID);
numberOfParticipants.current = val.length;
@@ -102,11 +108,24 @@ function IOURequestStepParticipants({
}
// When a participant is selected, the reportID needs to be saved because that's the reportID that will be used in the confirmation step.
- selectedReportID.current = firstParticipantReportID || reportID;
+ selectedReportID.current = firstParticipantReportID ?? reportID;
},
[iouType, reportID, transactionID],
);
+ const handleNavigation = useCallback(
+ (route: Route) => {
+ if (isAndroidNative || isMobileSafari) {
+ KeyboardUtils.dismiss().then(() => {
+ Navigation.navigate(route);
+ });
+ } else {
+ Navigation.navigate(route);
+ }
+ },
+ [isAndroidNative, isMobileSafari],
+ );
+
const goToNextStep = useCallback(() => {
const isCategorizing = action === CONST.IOU.ACTION.CATEGORIZE;
const isShareAction = action === CONST.IOU.ACTION.SHARE;
@@ -132,12 +151,13 @@ function IOURequestStepParticipants({
transactionID,
selectedReportID.current || reportID,
);
- if (isCategorizing) {
- Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, selectedReportID.current || reportID, iouConfirmationPageRoute));
- } else {
- Navigation.navigate(iouConfirmationPageRoute);
- }
- }, [iouType, transactionID, transaction, reportID, action, participants]);
+
+ const route = isCategorizing
+ ? ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, selectedReportID.current || reportID, iouConfirmationPageRoute)
+ : iouConfirmationPageRoute;
+
+ handleNavigation(route);
+ }, [action, participants, iouType, transaction, transactionID, reportID, handleNavigation]);
const navigateBack = useCallback(() => {
IOUUtils.navigateToStartMoneyRequestStep(iouRequestType, iouType, transactionID, reportID, action);
@@ -154,7 +174,8 @@ function IOURequestStepParticipants({
IOU.setCustomUnitRateID(transactionID, rateID);
IOU.setMoneyRequestParticipantsFromReport(transactionID, selfDMReport);
const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, CONST.IOU.TYPE.TRACK, transactionID, selfDMReportID);
- Navigation.navigate(iouConfirmationPageRoute);
+
+ handleNavigation(iouConfirmationPageRoute);
};
useEffect(() => {
diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx
index a40b14eae4c9..bc1d5b37c94e 100755
--- a/src/pages/settings/InitialSettingsPage.tsx
+++ b/src/pages/settings/InitialSettingsPage.tsx
@@ -181,7 +181,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr
const items: MenuData[] = [
{
translationKey: 'common.workspaces',
- icon: Expensicons.Building,
+ icon: Expensicons.Buildings,
routeName: ROUTES.SETTINGS_WORKSPACES,
brickRoadIndicator: hasGlobalWorkspaceSettingsRBR(policies, allConnectionSyncProgresses) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined,
},
diff --git a/src/pages/workspace/AccessOrNotFoundWrapper.tsx b/src/pages/workspace/AccessOrNotFoundWrapper.tsx
index 9bda7f3972f9..b9da0147b525 100644
--- a/src/pages/workspace/AccessOrNotFoundWrapper.tsx
+++ b/src/pages/workspace/AccessOrNotFoundWrapper.tsx
@@ -123,7 +123,7 @@ function AccessOrNotFoundWrapper({
...props
}: AccessOrNotFoundWrapperProps) {
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
- const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID ?? CONST.DEFAULT_NUMBER_ID}`);
const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {initialValue: true});
const {login = ''} = useCurrentUserPersonalDetails();
const isPolicyIDInRoute = !!policyID?.length;
diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx
index 95449e4c10ea..f706a299c30f 100644
--- a/src/pages/workspace/WorkspaceInitialPage.tsx
+++ b/src/pages/workspace/WorkspaceInitialPage.tsx
@@ -325,7 +325,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac
const menuItems: WorkspaceMenuItem[] = [
{
translationKey: 'workspace.common.profile',
- icon: Expensicons.Home,
+ icon: Expensicons.Building,
action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE.getRoute(policyID)))),
brickRoadIndicator: hasGeneralSettingsError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined,
routeName: SCREENS.WORKSPACE.PROFILE,
diff --git a/src/pages/workspace/WorkspaceInviteMessagePage.tsx b/src/pages/workspace/WorkspaceInviteMessagePage.tsx
index 5b9d64d52cbc..463b7df2124f 100644
--- a/src/pages/workspace/WorkspaceInviteMessagePage.tsx
+++ b/src/pages/workspace/WorkspaceInviteMessagePage.tsx
@@ -92,7 +92,7 @@ function WorkspaceInviteMessagePage({policy, route, currentUserPersonalDetails}:
if (isEmptyObject(policy)) {
return;
}
- Navigation.goBack(ROUTES.WORKSPACE_INVITE.getRoute(route.params.policyID), true);
+ Navigation.goBack(ROUTES.WORKSPACE_INVITE.getRoute(route.params.policyID, route.params.backTo), true);
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [isOnyxLoading]);
@@ -103,7 +103,12 @@ function WorkspaceInviteMessagePage({policy, route, currentUserPersonalDetails}:
Member.addMembersToWorkspace(invitedEmailsToAccountIDsDraft ?? {}, `${welcomeNoteSubject}\n\n${welcomeNote}`, route.params.policyID, policyMemberAccountIDs);
Policy.setWorkspaceInviteMessageDraft(route.params.policyID, welcomeNote ?? null);
FormActions.clearDraftValues(ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM);
- Navigation.dismissModal();
+ if ((route.params?.backTo as string)?.endsWith('members')) {
+ Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.dismissModal());
+
+ return;
+ }
+ Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(route.params.policyID)));
};
/** Opens privacy url as an external link */
@@ -141,7 +146,7 @@ function WorkspaceInviteMessagePage({policy, route, currentUserPersonalDetails}:
guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS}
shouldShowBackButton
onCloseButtonPress={() => Navigation.dismissModal()}
- onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_INVITE.getRoute(route.params.policyID))}
+ onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_INVITE.getRoute(route.params.policyID, route.params.backTo))}
/>
{
const login = option.login ?? '';
- const accountID = option.accountID ?? '-1';
+ const accountID = option.accountID ?? CONST.DEFAULT_NUMBER_ID;
if (!login.toLowerCase().trim() || !accountID) {
return;
}
invitedEmailsToAccountIDs[login] = Number(accountID);
});
Member.setWorkspaceInviteMembersDraft(route.params.policyID, invitedEmailsToAccountIDs);
- Navigation.navigate(ROUTES.WORKSPACE_INVITE_MESSAGE.getRoute(route.params.policyID));
+ Navigation.navigate(ROUTES.WORKSPACE_INVITE_MESSAGE.getRoute(route.params.policyID, Navigation.getActiveRoute()));
}, [route.params.policyID, selectedOptions]);
const [policyName, shouldShowAlertPrompt] = useMemo(() => [policy?.name ?? '', !isEmptyObject(policy?.errors) || !!policy?.alertMessage], [policy]);
diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx
index 6b88f3b59995..407ccca99438 100644
--- a/src/pages/workspace/WorkspaceMembersPage.tsx
+++ b/src/pages/workspace/WorkspaceMembersPage.tsx
@@ -208,7 +208,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
*/
const inviteUser = () => {
clearInviteDraft();
- Navigation.navigate(ROUTES.WORKSPACE_INVITE.getRoute(route.params.policyID));
+ Navigation.navigate(ROUTES.WORKSPACE_INVITE.getRoute(route.params.policyID, Navigation.getActiveRouteWithoutParams()));
};
/**
diff --git a/src/pages/workspace/WorkspaceNamePage.tsx b/src/pages/workspace/WorkspaceNamePage.tsx
index d9632a1a2ae8..42c97d5d85e6 100644
--- a/src/pages/workspace/WorkspaceNamePage.tsx
+++ b/src/pages/workspace/WorkspaceNamePage.tsx
@@ -58,7 +58,7 @@ function WorkspaceNamePage({policy}: Props) {
return (
Navigation.goBack()}
/>
@@ -85,8 +85,8 @@ function WorkspaceNamePage({policy}: Props) {
InputComponent={TextInput}
role={CONST.ROLE.PRESENTATION}
inputID={INPUT_IDS.NAME}
- label={translate('workspace.editor.nameInputLabel')}
- accessibilityLabel={translate('workspace.editor.nameInputLabel')}
+ label={translate('workspace.common.workspaceName')}
+ accessibilityLabel={translate('workspace.common.workspaceName')}
defaultValue={policy?.name}
spellCheck={false}
autoFocus
diff --git a/src/pages/workspace/WorkspaceProfilePage.tsx b/src/pages/workspace/WorkspaceProfilePage.tsx
index 845937ceaf75..04dd1885d486 100644
--- a/src/pages/workspace/WorkspaceProfilePage.tsx
+++ b/src/pages/workspace/WorkspaceProfilePage.tsx
@@ -47,13 +47,14 @@ function WorkspaceProfilePage({policyDraft, policy: policyProp, route}: Workspac
const {shouldUseNarrowLayout} = useResponsiveLayout();
const illustrations = useThemeIllustrations();
const {activeWorkspaceID, setActiveWorkspaceID} = useActiveWorkspace();
- const {canUseSpotnanaTravel, canUseWorkspaceDowngrade} = usePermissions();
+ const {canUseSpotnanaTravel} = usePermissions();
const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST);
const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID});
// When we create a new workspace, the policy prop will be empty on the first render. Therefore, we have to use policyDraft until policy has been set in Onyx.
const policy = policyDraft?.id ? policyDraft : policyProp;
+ const isPolicyAdmin = PolicyUtils.isPolicyAdmin(policy);
const outputCurrency = policy?.outputCurrency ?? DistanceRequestUtils.getDefaultMileageRate(policy)?.currency ?? PolicyUtils.getPersonalPolicy()?.outputCurrency ?? '';
const currencySymbol = currencyList?.[outputCurrency]?.symbol ?? '';
const formattedCurrency = !isEmptyObject(policy) && !isEmptyObject(currencyList) ? `${outputCurrency} - ${currencySymbol}` : '';
@@ -184,7 +185,7 @@ function WorkspaceProfilePage({policyDraft, policy: policyProp, route}: Workspac
shouldUseScrollView
shouldShowOfflineIndicatorInWideScreen
shouldShowNonAdmin
- icon={Illustrations.House}
+ icon={Illustrations.Building}
shouldShowNotFoundPage={policy === undefined}
>
{(hasVBA?: boolean) => (
@@ -253,7 +254,7 @@ function WorkspaceProfilePage({policyDraft, policy: policyProp, route}: Workspac
)}
- {!!canUseWorkspaceDowngrade && !readOnly && !!policy?.type && (
+ {!readOnly && !!policy?.type && (
+ {isPolicyAdmin && (
+ Navigation.navigate(ROUTES.WORKSPACE_INVITE.getRoute(route.params.policyID, Navigation.getActiveRouteWithoutParams()))}
+ icon={Expensicons.UserPlus}
+ style={[styles.mr2]}
+ />
+ )}
{
+ setCurrentPlan(policy?.type);
+ }, [policy?.type]);
+
const workspacePlanTypes = Object.values(CONST.POLICY.TYPE)
.filter((type) => type !== CONST.POLICY.TYPE.PERSONAL)
.map((policyType) => ({
@@ -76,13 +80,18 @@ function WorkspaceProfilePlanTypePage({policy}: WithPolicyProps) {
) : null;
const handleUpdatePlan = () => {
- if (policy?.type === currentPlan) {
- Navigation.goBack();
+ if (policyID && policy?.type === CONST.POLICY.TYPE.TEAM && currentPlan === CONST.POLICY.TYPE.CORPORATE) {
+ Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID));
return;
}
- if (policyID && policy?.type === CONST.POLICY.TYPE.TEAM && currentPlan === CONST.POLICY.TYPE.CORPORATE) {
- Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID));
+ if (policyID && policy?.type === CONST.POLICY.TYPE.CORPORATE && currentPlan === CONST.POLICY.TYPE.TEAM) {
+ Navigation.navigate(ROUTES.WORKSPACE_DOWNGRADE.getRoute(policyID));
+ return;
+ }
+
+ if (policy?.type === currentPlan) {
+ Navigation.goBack();
}
};
diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx
index a2746652685e..591f97064ea1 100755
--- a/src/pages/workspace/WorkspacesListPage.tsx
+++ b/src/pages/workspace/WorkspacesListPage.tsx
@@ -122,7 +122,7 @@ function WorkspacesListPage() {
const isLessThanMediumScreen = isMediumScreenWidth || shouldUseNarrowLayout;
// We need this to update translation for deleting a workspace when it has third party card feeds or expensify card assigned.
- const workspaceAccountID = PolicyUtils.getWorkspaceAccountID(policyIDToDelete ?? '-1');
+ const workspaceAccountID = PolicyUtils.getWorkspaceAccountID(policyIDToDelete);
const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`);
const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`);
const policyToDelete = PolicyUtils.getPolicy(policyIDToDelete);
@@ -180,7 +180,7 @@ function WorkspacesListPage() {
setIsSupportalActionRestrictedModalOpen(true);
return;
}
- setPolicyIDToDelete(item.policyID ?? '-1');
+ setPolicyIDToDelete(item.policyID);
setPolicyNameToDelete(item.title);
setIsDeleteModalOpen(true);
},
@@ -192,7 +192,7 @@ function WorkspacesListPage() {
threeDotsMenuItems.push({
icon: Expensicons.Exit,
text: translate('common.leave'),
- onSelected: Session.checkIfActionIsAllowed(() => Policy.leaveWorkspace(item.policyID ?? '-1')),
+ onSelected: Session.checkIfActionIsAllowed(() => Policy.leaveWorkspace(item.policyID)),
});
}
@@ -416,7 +416,7 @@ function WorkspacesListPage() {
shouldShowBackButton={shouldUseNarrowLayout}
shouldDisplaySearchRouter
onBackButtonPress={() => Navigation.goBack()}
- icon={Illustrations.BigRocket}
+ icon={Illustrations.Buildings}
shouldUseHeadlineHeader
/>
@@ -451,7 +451,7 @@ function WorkspacesListPage() {
shouldShowBackButton={shouldUseNarrowLayout}
shouldDisplaySearchRouter
onBackButtonPress={() => Navigation.goBack()}
- icon={Illustrations.BigRocket}
+ icon={Illustrations.Buildings}
shouldUseHeadlineHeader
>
{!shouldUseNarrowLayout && getHeaderButton()}
diff --git a/src/pages/workspace/accounting/intacct/EnterSageIntacctCredentialsPage.tsx b/src/pages/workspace/accounting/intacct/EnterSageIntacctCredentialsPage.tsx
index 29474431cd72..7ac4329c3a50 100644
--- a/src/pages/workspace/accounting/intacct/EnterSageIntacctCredentialsPage.tsx
+++ b/src/pages/workspace/accounting/intacct/EnterSageIntacctCredentialsPage.tsx
@@ -7,6 +7,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
+import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import {connectToSageIntacct} from '@libs/actions/connections/SageIntacct';
@@ -24,6 +25,7 @@ type SageIntacctPrerequisitesPageProps = PlatformStackScreenProps
{translate('workspace.intacct.enterCredentials')}
- {formItems.map((formItem) => (
+ {formItems.map((formItem, index) => (
void;
+ policyID: string;
+};
+
+function DowngradeConfirmation({onConfirmDowngrade, policyID}: Props) {
+ const {translate} = useLocalize();
+ const hasOtherControlWorkspaces = PolicyUtils.hasOtherControlWorkspaces(policyID);
+
+ return (
+
+ );
+}
+
+export default DowngradeConfirmation;
diff --git a/src/pages/workspace/downgrade/DowngradeIntro.tsx b/src/pages/workspace/downgrade/DowngradeIntro.tsx
new file mode 100644
index 000000000000..ba8c91550561
--- /dev/null
+++ b/src/pages/workspace/downgrade/DowngradeIntro.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import Icon from '@components/Icon';
+import * as Illustrations from '@components/Icon/Illustrations';
+import Text from '@components/Text';
+import TextLink from '@components/TextLink';
+import useEnvironment from '@hooks/useEnvironment';
+import useLocalize from '@hooks/useLocalize';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {openLink} from '@libs/actions/Link';
+import CONST from '@src/CONST';
+
+type Props = {
+ buttonDisabled?: boolean;
+ loading?: boolean;
+ onDowngrade: () => void;
+};
+
+function DowngradeIntro({onDowngrade, buttonDisabled, loading}: Props) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {environmentURL} = useEnvironment();
+ const {isExtraSmallScreenWidth} = useResponsiveLayout();
+
+ const benefits = [
+ translate('workspace.downgrade.commonFeatures.benefits.benefit1'),
+ translate('workspace.downgrade.commonFeatures.benefits.benefit2'),
+ translate('workspace.downgrade.commonFeatures.benefits.benefit3'),
+ translate('workspace.downgrade.commonFeatures.benefits.benefit4'),
+ ];
+
+ return (
+
+
+
+
+
+ {translate('workspace.downgrade.commonFeatures.title')}
+ {translate('workspace.downgrade.commonFeatures.note')}
+ {benefits.map((benefit) => (
+
+ •
+ {benefit}
+
+ ))}
+
+ {translate('workspace.downgrade.commonFeatures.benefits.note')}{' '}
+ openLink(CONST.PLAN_TYPES_AND_PRICING_HELP_URL, environmentURL)}
+ >
+ {translate('workspace.downgrade.commonFeatures.benefits.pricingPage')}
+
+ .
+
+
+ {translate('workspace.downgrade.commonFeatures.benefits.confirm')}{' '}
+ {translate('workspace.downgrade.commonFeatures.benefits.warning')}
+
+
+
+
+ );
+}
+
+export default DowngradeIntro;
diff --git a/src/pages/workspace/downgrade/WorkspaceDowngradePage.tsx b/src/pages/workspace/downgrade/WorkspaceDowngradePage.tsx
new file mode 100644
index 000000000000..ab9a0c9fbfde
--- /dev/null
+++ b/src/pages/workspace/downgrade/WorkspaceDowngradePage.tsx
@@ -0,0 +1,78 @@
+import React, {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
+import * as Policy from '@src/libs/actions/Policy/Policy';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
+import DowngradeConfirmation from './DowngradeConfirmation';
+import DowngradeIntro from './DowngradeIntro';
+
+type WorkspaceDowngradePageProps = PlatformStackScreenProps;
+
+function WorkspaceDowngradePage({route}: WorkspaceDowngradePageProps) {
+ const styles = useThemeStyles();
+ const policyID = route.params.policyID;
+ const {translate} = useLocalize();
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
+
+ const {isOffline} = useNetwork();
+
+ const canPerformDowngrade = !!policy && PolicyUtils.isPolicyAdmin(policy);
+ const isDowngraded = useMemo(() => PolicyUtils.isCollectPolicy(policy), [policy]);
+
+ const downgradeToTeam = () => {
+ if (!canPerformDowngrade) {
+ return;
+ }
+ Policy.downgradeToTeam(policy.id);
+ };
+
+ if (!canPerformDowngrade) {
+ return ;
+ }
+
+ return (
+
+ {
+ if (isDowngraded) {
+ Navigation.dismissModal();
+ } else {
+ Navigation.goBack();
+ }
+ }}
+ />
+ {isDowngraded && (
+ {
+ Navigation.dismissModal();
+ }}
+ policyID={policyID}
+ />
+ )}
+ {!isDowngraded && (
+
+ )}
+
+ );
+}
+
+export default WorkspaceDowngradePage;
diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx
index 9428f8eeb0a0..26d2509f0f1f 100644
--- a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx
+++ b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx
@@ -16,6 +16,7 @@ import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
import * as PerDiem from '@userActions/Policy/PerDiem';
import CONST from '@src/CONST';
import * as Policy from '@src/libs/actions/Policy/Policy';
+import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import UpgradeConfirmation from './UpgradeConfirmation';
@@ -44,7 +45,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) {
const feature = useMemo(() => Object.values(CONST.UPGRADE_FEATURE_INTRO_MAPPING).find((f) => f.alias === featureNameAlias), [featureNameAlias]);
const {translate} = useLocalize();
- const [policy] = useOnyx(`policy_${policyID}`);
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
const qboConfig = policy?.connections?.quickbooksOnline?.config;
const {isOffline} = useNetwork();
diff --git a/src/styles/index.ts b/src/styles/index.ts
index a6c4a39f3a56..139207835685 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -4006,19 +4006,15 @@ const styles = (theme: ThemeColors) =>
borderRadius: variables.componentBorderRadiusMedium,
},
- quickActionTooltipWrapper: {
+ productTrainingTooltipWrapper: {
backgroundColor: theme.tooltipHighlightBG,
+ borderRadius: variables.componentBorderRadiusNormal,
},
- quickActionTooltipTitle: {
- ...FontUtils.fontFamily.platform.EXP_NEUE_BOLD,
- fontSize: variables.fontSizeLabel,
- color: theme.tooltipHighlightText,
- },
-
- quickActionTooltipSubtitle: {
+ productTrainingTooltipText: {
fontSize: variables.fontSizeLabel,
color: theme.textDark,
+ lineHeight: variables.lineHeightLarge,
},
quickReactionsContainer: {
diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts
index 223fc1c56818..68132de5fcad 100644
--- a/src/styles/theme/themes/dark.ts
+++ b/src/styles/theme/themes/dark.ts
@@ -81,7 +81,7 @@ const darkTheme = {
ourMentionText: colors.green100,
ourMentionBG: colors.green600,
tooltipHighlightBG: colors.green100,
- tooltipHighlightText: colors.green500,
+ tooltipHighlightText: colors.green400,
tooltipSupportingText: colors.productLight800,
tooltipPrimaryText: colors.productLight900,
trialBannerBackgroundColor: colors.green700,
diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts
index 151388e77136..7be69e5461d1 100644
--- a/src/styles/theme/themes/light.ts
+++ b/src/styles/theme/themes/light.ts
@@ -81,7 +81,7 @@ const lightTheme = {
ourMentionText: colors.green600,
ourMentionBG: colors.green100,
tooltipHighlightBG: colors.green100,
- tooltipHighlightText: colors.green500,
+ tooltipHighlightText: colors.green400,
tooltipSupportingText: colors.productDark800,
tooltipPrimaryText: colors.productDark900,
trialBannerBackgroundColor: colors.green100,
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index b49d5784a905..174e23fe64a9 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -258,6 +258,12 @@ export default {
composerTooltipShiftHorizontal: 10,
composerTooltipShiftVertical: -10,
gbrTooltipShiftHorizontal: -20,
+ fabTooltipShiftHorizontal: -15,
+ workspaceLHNtooltipShiftHorizontal: 26,
+ searchFiltersTooltipShiftHorizontal: -20,
+ searchFiltersTooltipShiftHorizontalNarrow: -10,
+ searchFiltersTooltipShiftVerticalNarrow: 5,
+ bottomTabInboxTooltipShiftHorizontal: 36,
inlineImagePreviewMinSize: 64,
inlineImagePreviewMaxSize: 148,
diff --git a/src/types/onyx/DismissedProductTraining.ts b/src/types/onyx/DismissedProductTraining.ts
index 9539bc9f0187..aba386448c09 100644
--- a/src/types/onyx/DismissedProductTraining.ts
+++ b/src/types/onyx/DismissedProductTraining.ts
@@ -1,3 +1,15 @@
+import CONST from '@src/CONST';
+
+const {
+ CONCEIRGE_LHN_GBR,
+ RENAME_SAVED_SEARCH,
+ WORKSAPCE_CHAT_CREATE,
+ QUICK_ACTION_BUTTON,
+ SEARCH_FILTER_BUTTON_TOOLTIP,
+ BOTTOM_NAV_INBOX_TOOLTIP,
+ LHN_WORKSPACE_CHAT_TOOLTIP,
+ GLOBAL_CREATE_TOOLTIP,
+} = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES;
/**
* This type is used to store the timestamp of when the user dismisses a product training ui elements.
*/
@@ -5,7 +17,47 @@ type DismissedProductTraining = {
/**
* When user dismisses the nudgeMigration Welcome Modal, we store the timestamp here.
*/
- migratedUserWelcomeModal: Date;
+ [CONST.MIGRATED_USER_WELCOME_MODAL]: string;
+
+ /**
+ * When user dismisses the conciergeLHNGBR product training tooltip, we store the timestamp here.
+ */
+ [CONCEIRGE_LHN_GBR]: string;
+
+ /**
+ * When user dismisses the renameSavedSearch product training tooltip, we store the timestamp here.
+ */
+ [RENAME_SAVED_SEARCH]: string;
+
+ /**
+ * When user dismisses the workspaceChatCreate product training tooltip, we store the timestamp here.
+ */
+ [WORKSAPCE_CHAT_CREATE]: string;
+
+ /**
+ * When user dismisses the quickActionButton product training tooltip, we store the timestamp here.
+ */
+ [QUICK_ACTION_BUTTON]: string;
+
+ /**
+ * When user dismisses the searchFilterButtonTooltip product training tooltip, we store the timestamp here.
+ */
+ [SEARCH_FILTER_BUTTON_TOOLTIP]: string;
+
+ /**
+ * When user dismisses the bottomNavInboxTooltip product training tooltip, we store the timestamp here.
+ */
+ [BOTTOM_NAV_INBOX_TOOLTIP]: string;
+
+ /**
+ * When user dismisses the lhnWorkspaceChatTooltip product training tooltip, we store the timestamp here.
+ */
+ [LHN_WORKSPACE_CHAT_TOOLTIP]: string;
+
+ /**
+ * When user dismisses the globalCreateTooltip product training tooltip, we store the timestamp here.
+ */
+ [GLOBAL_CREATE_TOOLTIP]: string;
};
export default DismissedProductTraining;
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index 892cdd527ff2..5ea02862599e 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -1822,6 +1822,9 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback<
/** Indicates if the policy is pending an upgrade */
isPendingUpgrade?: boolean;
+ /** Indicates if the policy is pending a downgrade */
+ isPendingDowngrade?: boolean;
+
/** Max expense age for a Policy violation */
maxExpenseAge?: number;
diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts
index b89b6c8e0777..8e79ff1accf5 100644
--- a/src/types/onyx/Report.ts
+++ b/src/types/onyx/Report.ts
@@ -23,18 +23,6 @@ type Note = OnyxCommon.OnyxValueWithOfflineFeedback<{
errors?: OnyxCommon.Errors;
}>;
-/** The pending member of report */
-type PendingChatMember = {
- /** Account ID of the pending member */
- accountID: string;
-
- /** Action to be applied to the pending member of report */
- pendingAction: OnyxCommon.PendingAction;
-
- /** Collection of errors to show to the user */
- errors?: OnyxCommon.Errors;
-};
-
/** Report participant properties */
type Participant = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** What is the role of the participant in the report */
@@ -148,9 +136,6 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback<
/** Invoice room receiver data */
invoiceReceiver?: InvoiceReceiver;
- /** Translation key of the last message in the report */
- lastMessageTranslationKey?: string;
-
/** ID of the parent report of the current report, if it exists */
parentReportID?: string;
@@ -211,9 +196,6 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback<
/** Collection of participant private notes, indexed by their accountID */
privateNotes?: Record;
- /** Pending members of the report */
- pendingChatMembers?: PendingChatMember[];
-
/** Collection of policy report fields, indexed by their fieldID */
fieldList?: Record;
@@ -244,4 +226,4 @@ type ReportCollectionDataSet = CollectionDataSet {
+ const currentHeight = window?.visualViewport?.height;
+
+ if (!currentHeight || !initialViewportHeight) {
+ return;
+ }
+
+ if (currentHeight < initialViewportHeight) {
+ isVisible = true;
+ return;
+ }
+
+ if (currentHeight === initialViewportHeight) {
+ isVisible = false;
+ }
+};
+
+window.visualViewport?.addEventListener('resize', handleResize);
+
+const dismiss = (): Promise => {
+ return new Promise((resolve) => {
+ if (!isVisible) {
+ resolve();
+ return;
+ }
+
+ const handleDismissResize = () => {
+ if (window.visualViewport?.height !== initialViewportHeight) {
+ return;
+ }
+
+ window.visualViewport?.removeEventListener('resize', handleDismissResize);
+ return resolve();
+ };
+
+ window.visualViewport?.addEventListener('resize', handleDismissResize);
+ Keyboard.dismiss();
+ });
+};
+
+const utils = {dismiss};
+
+export default utils;
diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts
index 311e8f121c9e..49e1150f1a57 100644
--- a/tests/actions/IOUTest.ts
+++ b/tests/actions/IOUTest.ts
@@ -1,7 +1,8 @@
import isEqual from 'lodash/isEqual';
-import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
+import type {OnyxCollection, OnyxEntry, OnyxInputValue} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {OptimisticChatReport} from '@libs/ReportUtils';
+import * as TransactionUtils from '@libs/TransactionUtils';
import CONST from '@src/CONST';
import * as IOU from '@src/libs/actions/IOU';
import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager';
@@ -19,6 +20,8 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type * as OnyxTypes from '@src/types/onyx';
import type {Participant} from '@src/types/onyx/Report';
+import type {ReportActionsCollectionDataSet} from '@src/types/onyx/ReportAction';
+import type {TransactionCollectionDataSet} from '@src/types/onyx/Transaction';
import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import createRandomPolicy, {createCategoryTaxExpenseRules} from '../utils/collections/policies';
@@ -3400,6 +3403,56 @@ describe('actions/IOU', () => {
});
});
+ describe('resolveDuplicate', () => {
+ test('Resolving duplicates of two transaction by keeping one of them should properly set the other one on hold even if the transaction thread reports do not exist in onyx', () => {
+ // Given two duplicate transactions
+ const iouReport = ReportUtils.buildOptimisticIOUReport(1, 2, 100, '1', 'USD');
+ const transaction1 = TransactionUtils.buildOptimisticTransaction(100, 'USD', iouReport.reportID);
+ const transaction2 = TransactionUtils.buildOptimisticTransaction(100, 'USD', iouReport.reportID);
+ const transactionCollectionDataSet: TransactionCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`]: transaction1,
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2,
+ };
+ const iouActions: OnyxTypes.ReportAction[] = [];
+ [transaction1, transaction2].forEach((transaction) =>
+ iouActions.push(ReportUtils.buildOptimisticIOUReportAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, transaction.amount, transaction.currency, '', [], transaction.transactionID)),
+ );
+ const actions: OnyxInputValue = {};
+ iouActions.forEach((iouAction) => (actions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouAction.reportActionID}`] = iouAction));
+ const actionCollectionDataSet: ReportActionsCollectionDataSet = {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`]: actions};
+
+ return waitForBatchedUpdates()
+ .then(() => Onyx.multiSet({...transactionCollectionDataSet, ...actionCollectionDataSet}))
+ .then(() => {
+ // When resolving duplicates with transaction thread reports no existing in onyx
+ IOU.resolveDuplicates({
+ ...transaction1,
+ receiptID: 1,
+ category: '',
+ comment: '',
+ billable: false,
+ reimbursable: true,
+ tag: '',
+ transactionIDList: [transaction2.transactionID],
+ });
+ return waitForBatchedUpdates();
+ })
+ .then(() => {
+ return new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`,
+ callback: (transaction) => {
+ Onyx.disconnect(connection);
+ // Then the duplicate transaction should correctly be set on hold.
+ expect(transaction?.comment?.hold).toBeDefined();
+ resolve();
+ },
+ });
+ });
+ });
+ });
+ });
+
describe('sendInvoice', () => {
it('should not clear transaction pending action when send invoice fails', async () => {
// Given a send invoice request
diff --git a/tests/perf-test/ReportActionCompose.perf-test.tsx b/tests/perf-test/ReportActionCompose.perf-test.tsx
index 845727c75c97..1827e23ffe4b 100644
--- a/tests/perf-test/ReportActionCompose.perf-test.tsx
+++ b/tests/perf-test/ReportActionCompose.perf-test.tsx
@@ -96,6 +96,8 @@ function ReportActionComposeWrapper() {
disabled={false}
report={LHNTestUtils.getFakeReport()}
isComposerFullSize
+ showSoftInputOnFocus={false}
+ setShowSoftInputOnFocus={() => {}}
/>
);
diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx
index f7f4574b1d29..295bfff0ae10 100644
--- a/tests/ui/UnreadIndicatorsTest.tsx
+++ b/tests/ui/UnreadIndicatorsTest.tsx
@@ -547,4 +547,44 @@ describe('Unread Indicators', () => {
})
);
});
+
+ it('Move the new line indicator to the next message when the unread message is deleted', async () => {
+ let reportActions: OnyxEntry;
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
+ callback: (val) => (reportActions = val),
+ });
+ await signInAndGetAppWithUnreadChat();
+ await navigateToSidebarOption(0);
+
+ Report.addComment(REPORT_ID, 'Comment 1');
+
+ await waitForBatchedUpdates();
+
+ const firstNewReportAction = reportActions ? CollectionUtils.lastItem(reportActions) : undefined;
+
+ if (firstNewReportAction) {
+ Report.markCommentAsUnread(REPORT_ID, firstNewReportAction?.created);
+
+ await waitForBatchedUpdates();
+
+ Report.addComment(REPORT_ID, 'Comment 2');
+
+ await waitForBatchedUpdates();
+
+ Report.deleteReportComment(REPORT_ID, firstNewReportAction);
+
+ await waitForBatchedUpdates();
+ }
+
+ const secondNewReportAction = reportActions ? CollectionUtils.lastItem(reportActions) : undefined;
+
+ const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator');
+ const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText);
+ expect(unreadIndicator).toHaveLength(1);
+ const reportActionID = unreadIndicator.at(0)?.props?.['data-action-id'] as string;
+ expect(reportActionID).toBe(secondNewReportAction?.reportActionID);
+
+ Onyx.disconnect(connection);
+ });
});
diff --git a/tests/unit/FastSearchTest.ts b/tests/unit/FastSearchTest.ts
new file mode 100644
index 000000000000..42487b716d09
--- /dev/null
+++ b/tests/unit/FastSearchTest.ts
@@ -0,0 +1,169 @@
+import FastSearch from '../../src/libs/FastSearch';
+
+describe('FastSearch', () => {
+ it('should insert, and find the word', () => {
+ const {search} = FastSearch.createFastSearch([
+ {
+ data: ['banana'],
+ toSearchableString: (data) => data,
+ },
+ ]);
+ expect(search('an')).toEqual([['banana']]);
+ });
+
+ it('should work with multiple words', () => {
+ const {search} = FastSearch.createFastSearch([
+ {
+ data: ['banana', 'test'],
+ toSearchableString: (data) => data,
+ },
+ ]);
+
+ expect(search('es')).toEqual([['test']]);
+ });
+
+ it('should work when providing two data sets', () => {
+ const {search} = FastSearch.createFastSearch([
+ {
+ data: ['erica', 'banana'],
+ toSearchableString: (data) => data,
+ },
+ {
+ data: ['banana', 'test'],
+ toSearchableString: (data) => data,
+ },
+ ]);
+
+ expect(search('es')).toEqual([[], ['test']]);
+ });
+
+ it('should work with numbers', () => {
+ const {search} = FastSearch.createFastSearch([
+ {
+ data: [1, 2, 3, 4, 5],
+ toSearchableString: (data) => String(data),
+ },
+ ]);
+
+ expect(search('2')).toEqual([[2]]);
+ });
+
+ it('should work with unicodes', () => {
+ const {search} = FastSearch.createFastSearch([
+ {
+ data: ['banana', 'ñèşťǒř', 'test'],
+ toSearchableString: (data) => data,
+ },
+ ]);
+
+ expect(search('èşť')).toEqual([['ñèşťǒř']]);
+ });
+
+ it('should work with words containing "reserved special characters"', () => {
+ const {search} = FastSearch.createFastSearch([
+ {
+ data: ['ba|nana', 'te{st', 'he}llo'],
+ toSearchableString: (data) => data,
+ },
+ ]);
+
+ expect(search('st')).toEqual([['te{st']]);
+ expect(search('llo')).toEqual([['he}llo']]);
+ expect(search('nana')).toEqual([['ba|nana']]);
+ });
+
+ it('should be case insensitive', () => {
+ const {search} = FastSearch.createFastSearch([
+ {
+ data: ['banana', 'TeSt', 'TEST', 'X'],
+ toSearchableString: (data) => data,
+ },
+ ]);
+
+ expect(search('test')).toEqual([['TeSt', 'TEST']]);
+ });
+
+ it('should work with large random data sets', () => {
+ const data = Array.from({length: 1000}, () => {
+ return Array.from({length: Math.floor(Math.random() * 22 + 9)}, () => {
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789@-_.';
+ return alphabet.charAt(Math.floor(Math.random() * alphabet.length));
+ }).join('');
+ });
+
+ const {search} = FastSearch.createFastSearch([
+ {
+ data,
+ toSearchableString: (x) => x,
+ },
+ ]);
+
+ data.forEach((word) => {
+ expect(search(word)).toEqual([expect.arrayContaining([word])]);
+ });
+ });
+
+ it('should find email addresses without dots', () => {
+ const {search} = FastSearch.createFastSearch([
+ {
+ data: ['test.user@example.com', 'unrelated'],
+ toSearchableString: (data) => data,
+ },
+ ]);
+
+ expect(search('testuser')).toEqual([['test.user@example.com']]);
+ expect(search('test.user')).toEqual([['test.user@example.com']]);
+ expect(search('examplecom')).toEqual([['test.user@example.com']]);
+ });
+
+ it('should filter duplicate IDs', () => {
+ const {search} = FastSearch.createFastSearch([
+ {
+ data: [
+ {
+ text: 'qa.guide@team.expensify.com',
+ alternateText: 'qa.guide@team.expensify.com',
+ keyForList: '14365522',
+ isSelected: false,
+ isDisabled: false,
+ accountID: 14365522,
+ login: 'qa.guide@team.expensify.com',
+ icons: [
+ {
+ source: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_11.png',
+ type: 'avatar',
+ name: 'qa.guide@team.expensify.com',
+ id: 14365522,
+ },
+ ],
+ reportID: '',
+ },
+ {
+ text: 'qa.guide@team.expensify.com',
+ alternateText: 'qa.guide@team.expensify.com',
+ keyForList: '714749267',
+ isSelected: false,
+ isDisabled: false,
+ accountID: 714749267,
+ login: 'qa.guide@team.expensify.com',
+ icons: [
+ {
+ source: 'ƒ SvgFallbackAvatar(props)',
+ type: 'avatar',
+ name: 'qa.guide@team.expensify.com',
+ id: 714749267,
+ },
+ ],
+ reportID: '',
+ },
+ ],
+ toSearchableString: (data) => data.text,
+ uniqueId: (data) => data.login,
+ },
+ ]);
+
+ const [result] = search('qa.g');
+ // The both items are represented using the same string.
+ expect(result).toHaveLength(1);
+ });
+});
diff --git a/tests/unit/OnboardingSelectorsTest.ts b/tests/unit/OnboardingSelectorsTest.ts
index 1fc5846b2472..3cfd4d112ccc 100644
--- a/tests/unit/OnboardingSelectorsTest.ts
+++ b/tests/unit/OnboardingSelectorsTest.ts
@@ -7,11 +7,6 @@ describe('onboardingSelectors', () => {
// Not all users have this NVP defined as we did not run a migration to backfill it for existing accounts, hence we need to make sure
// the onboarding flow is only showed to the users with `hasCompletedGuidedSetupFlow` set to false
describe('hasCompletedGuidedSetupFlowSelector', () => {
- // It might be the case that backend returns an empty array if the NVP is not defined on this particular account
- it('Should return true if onboarding NVP is an array', () => {
- const onboarding = [] as OnyxValue;
- expect(hasCompletedGuidedSetupFlowSelector(onboarding)).toBe(true);
- });
it('Should return true if onboarding NVP is an empty object', () => {
const onboarding = {} as OnyxValue;
expect(hasCompletedGuidedSetupFlowSelector(onboarding)).toBe(true);
diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts
index 39406e6a0995..6c0ad88619cb 100644
--- a/tests/unit/OptionsListUtilsTest.ts
+++ b/tests/unit/OptionsListUtilsTest.ts
@@ -1005,11 +1005,33 @@ describe('OptionsListUtils', () => {
});
describe('canCreateOptimisticPersonalDetailOption', () => {
+ const VALID_EMAIL = 'valid@email.com';
+ it('should allow to create optimistic personal detail option if email is valid', () => {
+ const currentUserEmail = 'tonystark@expensify.com';
+ const canCreate = OptionsListUtils.canCreateOptimisticPersonalDetailOption({
+ searchValue: VALID_EMAIL,
+ currentUserOption: {
+ login: currentUserEmail,
+ } as ReportUtils.OptionData,
+ // Note: in the past this would check for the existence of the email in the personalDetails list, this has changed.
+ // We expect only filtered lists to be passed to this function, so we don't need to check for the existence of the email in the personalDetails list.
+ // This is a performance optimization.
+ personalDetailsOptions: [],
+ recentReportOptions: [],
+ });
+
+ expect(canCreate).toBe(true);
+ });
+
it('should not allow to create option if email is an email of current user', () => {
+ const currentUserEmail = 'tonystark@expensify.com';
const canCreate = OptionsListUtils.canCreateOptimisticPersonalDetailOption({
- recentReportOptions: OPTIONS.reports,
- personalDetailsOptions: OPTIONS.personalDetails,
- currentUserOption: null,
+ searchValue: currentUserEmail,
+ recentReportOptions: [],
+ personalDetailsOptions: [],
+ currentUserOption: {
+ login: currentUserEmail,
+ } as ReportUtils.OptionData,
});
expect(canCreate).toBe(false);
diff --git a/tests/unit/SuffixUkkonenTreeTest.ts b/tests/unit/SuffixUkkonenTreeTest.ts
new file mode 100644
index 000000000000..c0c556c16e14
--- /dev/null
+++ b/tests/unit/SuffixUkkonenTreeTest.ts
@@ -0,0 +1,63 @@
+import SuffixUkkonenTree from '@libs/SuffixUkkonenTree/index';
+
+describe('SuffixUkkonenTree', () => {
+ // The suffix tree doesn't take strings, but expects an array buffer, where strings have been separated by a delimiter.
+ function helperStringsToNumericForTree(strings: string[]) {
+ const numericLists = strings.map((s) => SuffixUkkonenTree.stringToNumeric(s, {clamp: true}));
+ const numericList = numericLists.reduce(
+ (acc, {numeric}) => {
+ acc.push(...numeric, SuffixUkkonenTree.DELIMITER_CHAR_CODE);
+ return acc;
+ },
+ // The value we pass to makeTree needs to be offset by one
+ [0],
+ );
+ numericList.push(SuffixUkkonenTree.END_CHAR_CODE);
+ return Uint8Array.from(numericList);
+ }
+
+ it('should insert, build, and find all occurrences', () => {
+ const strings = ['banana', 'pancake'];
+ const numericIntArray = helperStringsToNumericForTree(strings);
+
+ const tree = SuffixUkkonenTree.makeTree(numericIntArray);
+ tree.build();
+ const searchValue = SuffixUkkonenTree.stringToNumeric('an', {clamp: true}).numeric;
+ expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([2, 4, 9]));
+ });
+
+ it('should find by first character', () => {
+ const strings = ['pancake', 'banana'];
+ const numericIntArray = helperStringsToNumericForTree(strings);
+ const tree = SuffixUkkonenTree.makeTree(numericIntArray);
+ tree.build();
+ const searchValue = SuffixUkkonenTree.stringToNumeric('p', {clamp: true}).numeric;
+ expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([1]));
+ });
+
+ it('should handle identical words', () => {
+ const strings = ['banana', 'banana', 'x'];
+ const numericIntArray = helperStringsToNumericForTree(strings);
+ const tree = SuffixUkkonenTree.makeTree(numericIntArray);
+ tree.build();
+ const searchValue = SuffixUkkonenTree.stringToNumeric('an', {clamp: true}).numeric;
+ expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([2, 4, 9, 11]));
+ });
+
+ it('should convert string to numeric with a list of chars to skip', () => {
+ const {numeric} = SuffixUkkonenTree.stringToNumeric('abcabc', {
+ charSetToSkip: new Set(['b']),
+ clamp: true,
+ });
+ expect(Array.from(numeric)).toEqual([0, 2, 0, 2]);
+ });
+
+ it('should convert string outside of a-z to numeric with clamping', () => {
+ const {numeric} = SuffixUkkonenTree.stringToNumeric('2', {
+ clamp: true,
+ });
+
+ // "2" in ASCII is 50, so base26(50) = [0, 23]
+ expect(Array.from(numeric)).toEqual([SuffixUkkonenTree.SPECIAL_CHAR_CODE, 0, 23]);
+ });
+});
diff --git a/tests/unit/useFastSearchFromOptions.tsx b/tests/unit/useFastSearchFromOptions.tsx
new file mode 100644
index 000000000000..105f8a276e5b
--- /dev/null
+++ b/tests/unit/useFastSearchFromOptions.tsx
@@ -0,0 +1,49 @@
+import {renderHook} from '@testing-library/react-native';
+import useFastSearchFromOptions from '@hooks/useFastSearchFromOptions';
+import type {Options} from '@libs/OptionsListUtils';
+
+describe('useFastSearchFromOptions', () => {
+ it('should return sub word matches', () => {
+ const options = {
+ currentUserOption: null,
+ userToInvite: null,
+ personalDetails: [
+ {
+ text: 'Ahmed Gaber',
+ participantsList: [
+ {
+ displayName: 'Ahmed Gaber',
+ },
+ ],
+ },
+ {
+ text: 'Banana',
+ participantsList: [
+ {
+ displayName: 'Banana',
+ },
+ ],
+ },
+ ],
+ recentReports: [
+ {
+ text: 'Ahmed Gaber (Report)',
+ },
+ {
+ text: 'Something else',
+ },
+ {
+ // This starts with Ah as well, but should not match
+ text: 'Ahntony',
+ },
+ ],
+ } as Options;
+ const {result} = renderHook(() => useFastSearchFromOptions(options));
+ const search = result.current;
+
+ const {personalDetails, recentReports} = search('Ah Ga');
+
+ expect(personalDetails).toEqual([expect.objectContaining({text: 'Ahmed Gaber'})]);
+ expect(recentReports).toEqual([{text: 'Ahmed Gaber (Report)'}]);
+ });
+});