diff --git a/Mobile-Expensify b/Mobile-Expensify index f370c5f31cdf..768d69540612 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit f370c5f31cdfd750b0d42d75a471a9b8d30935ad +Subproject commit 768d695406126652ce222a46c95e643ba2e51e45 diff --git a/android/app/build.gradle b/android/app/build.gradle index 4c917b995331..09bf1473e841 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 1009007801 + versionName "9.0.78-1" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/assets/images/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/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 1e81fdedcaee..6a6d5bef5d7d 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.1 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 2291b6e19e37..c996883405b2 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.1 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index f94a9a34f558..4e4305e1cbab 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.1 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 51773c06935e..a1e8c86e2a78 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-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.77-6", + "version": "9.0.78-1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index c3f6d8e730d8..b2248ab75cc2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.77-6", + "version": "9.0.78-1", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/src/CONST.ts b/src/CONST.ts index e317c19d96d2..740ee2206876 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, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index a43f1622ec9a..86d4c90d67ed 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -890,8 +890,7 @@ type OnyxValuesMapping = { [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; 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/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index a70f4b13b94d..6210c8882136 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -12,6 +12,7 @@ import type {SearchQueryItem, SearchQueryListItemProps} from '@components/Select import type {SectionListDataType, SelectionListHandle, UserListItemProps} from '@components/SelectionList/types'; import UserListItem from '@components/SelectionList/UserListItem'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; +import useFastSearchFromOptions from '@hooks/useFastSearchFromOptions'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -382,21 +383,30 @@ function SearchRouterList( }; }); + /** + * Builds a suffix tree and returns a function to search in it. + */ + const filterOptions = useFastSearchFromOptions(searchOptions, {includeUserToInvite: true}); + const recentReportsOptions = useMemo(() => { 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/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/languages/en.ts b/src/languages/en.ts index 77e3b882b892..66b89b810d1b 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', @@ -2536,7 +2537,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 +4377,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`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 09a0394ea6a0..efba4bfd2f53 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', @@ -2558,7 +2559,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', @@ -4442,6 +4443,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`, 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 af66cd77168a..45ee00ac7823 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': @@ -600,7 +599,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, 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/DebugTabNavigator.tsx b/src/libs/Navigation/DebugTabNavigator.tsx new file mode 100644 index 000000000000..3b705596cb9b --- /dev/null +++ b/src/libs/Navigation/DebugTabNavigator.tsx @@ -0,0 +1,152 @@ +import type {EventMapCore, NavigationProp, NavigationState} from '@react-navigation/native'; +import {useNavigation} from '@react-navigation/native'; +import {createStackNavigator} from '@react-navigation/stack'; +import React, {useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import * as Expensicons from '@components/Icon/Expensicons'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import getBackgroundColor from '@components/TabSelector/getBackground'; +import getOpacity from '@components/TabSelector/getOpacity'; +import TabSelectorItem from '@components/TabSelector/TabSelectorItem'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type IconAndTitle = { + icon: IconAsset; + title: string; +}; + +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')}; + default: + throw new Error(`Route ${route} has no icon nor title set.`); + } +} + +const StackNavigator = createStackNavigator(); + +type DebugTabNavigatorRoute = { + name: string; + component: () => 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/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..97fb1fd817c6 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -47,14 +47,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 +55,5 @@ export default { canUseCombinedTrackSubmit, canUseCategoryAndTagApprovers, canUsePerDiem, - canUseWorkspaceDowngrade, shouldShowProductTrainingElements, }; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index f44f5605b5d8..7722744465e0 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -406,6 +406,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 +1097,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 | undefined) { +function getWorkspaceAccountID(policyID?: string) { const policy = getPolicy(policyID); if (!policy) { @@ -1165,6 +1169,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 +1274,7 @@ export { getApprovalWorkflow, getReimburserAccountID, isControlPolicy, + isCollectPolicy, isNetSuiteCustomSegmentRecord, getNameFromNetSuiteCustomField, isNetSuiteCustomFieldPropertyEditable, @@ -1288,6 +1298,7 @@ export { getUserFriendlyWorkspaceType, isPolicyAccessible, areAllGroupPoliciesExpenseChatDisabled, + hasOtherControlWorkspaces, getManagerAccountEmail, getRuleApprovers, }; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 455a125ad0c3..6431deb6d75b 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, }; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index e6511154fd13..0c2f4f318ce5 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -314,7 +314,6 @@ type OptimisticChatReport = Pick< | 'isOwnPolicyExpenseChat' | 'isPinned' | 'lastActorAccountID' - | 'lastMessageTranslationKey' | 'lastMessageHtml' | 'lastMessageText' | 'lastReadTime' @@ -737,7 +736,7 @@ Onyx.connect({ }, }); -let onboarding: OnyxEntry; +let onboarding: OnyxEntry; Onyx.connect({ key: ONYXKEYS.NVP_ONBOARDING, callback: (value) => (onboarding = value), @@ -5506,7 +5505,6 @@ function buildOptimisticChatReport( isOwnPolicyExpenseChat, isPinned: isNewlyCreatedWorkspaceChat, lastActorAccountID: 0, - lastMessageTranslationKey: '', lastMessageHtml: '', lastMessageText: undefined, lastReadTime: currentTime, @@ -6301,12 +6299,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 { @@ -8354,8 +8352,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 +8419,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, 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 62365bf111a2..240ab662f622 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1210,7 +1210,7 @@ function compareDuplicateTransactionFields( return {keep, change}; } -function getTransactionID(threadReportID: string | undefined): string | undefined { +function getTransactionID(threadReportID?: string): string | undefined { if (!threadReportID) { return; } diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index c8d6fb36f60d..46da4f283945 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}} : {}), }, @@ -8724,7 +8722,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 +8732,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/Policy.ts b/src/libs/actions/Policy/Policy.ts index a9026aedd131..37f7d09f3a9a 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -14,6 +14,7 @@ import type { DeleteWorkspaceAvatarParams, DeleteWorkspaceParams, DisablePolicyBillableModeParams, + DowngradeToTeamParams, EnablePolicyAutoApprovalOptionsParams, EnablePolicyAutoReimbursementLimitParams, EnablePolicyCompanyCardsParams, @@ -725,7 +726,10 @@ function clearWorkspaceReimbursementErrors(policyID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errorFields: {reimbursementChoice: null}}); } -function leaveWorkspace(policyID: string | undefined) { +function leaveWorkspace(policyID?: string) { + if (!policyID) { + return; + } const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; const workspaceChats = ReportUtils.getAllWorkspaceReports(policyID); @@ -3447,6 +3451,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: { + isPendingUpgrade: 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 +4771,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..5d390221822b 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -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, @@ -1555,19 +1552,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 +1769,6 @@ function editReportComment(reportID: string, originalReportAction: OnyxEntry = { - 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 +4169,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 +4243,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/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/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..fcb3916256d8 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; } 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 (