diff --git a/.eslintrc.changed.js b/.eslintrc.changed.js index a1c2c452273e..55472b10ea86 100644 --- a/.eslintrc.changed.js +++ b/.eslintrc.changed.js @@ -11,15 +11,14 @@ module.exports = { overrides: [ { files: [ - 'src/libs/ReportUtils.ts', 'src/libs/actions/IOU.ts', 'src/libs/actions/Report.ts', 'src/libs/actions/Task.ts', 'src/libs/OptionsListUtils.ts', - 'src/libs/ReportActionsUtils.ts', 'src/libs/TransactionUtils/index.ts', 'src/pages/home/ReportScreen.tsx', 'src/pages/workspace/WorkspaceInitialPage.tsx', + 'src/pages/home/report/PureReportActionItem.tsx', ], rules: { 'rulesdir/no-default-id-values': 'off', diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 43adedea1f2b..4f0c3b75c0ea 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -5,3 +5,4 @@ self-hosted-runner: - macos-13-large - macos-13-xlarge - ubuntu-latest-reassure-tests + - macos-12 diff --git a/.github/actions/composite/setupNode/action.yml b/.github/actions/composite/setupNode/action.yml index 8eb4f6474638..086a2a383d28 100644 --- a/.github/actions/composite/setupNode/action.yml +++ b/.github/actions/composite/setupNode/action.yml @@ -1,6 +1,12 @@ name: Set up Node description: Set up Node +inputs: + IS_HYBRID_BUILD: + description: "Indicates if node is set up for hybrid app" + required: false + default: 'false' + outputs: cache-hit: description: Was there a cache hit on the main node_modules? @@ -27,14 +33,26 @@ runs: path: node_modules key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json', 'patches/**') }} + - id: cache-old-dot-node-modules + if: inputs.IS_HYBRID_BUILD == 'true' + uses: actions/cache@v4 + with: + path: Mobile-Expensify/node_modules + key: ${{ runner.os }}-node-modules-${{ hashFiles('Mobile-Expensify/package-lock.json', 'Mobile-Expensify/patches/**') }} + - id: cache-desktop-node-modules uses: actions/cache@v4 with: path: desktop/node_modules key: ${{ runner.os }}-desktop-node-modules-${{ hashFiles('desktop/package-lock.json', 'desktop/patches/**') }} + - name: Remove ND node_modules if needed for hybrid app build + if: inputs.IS_HYBRID_BUILD == 'true' && steps.cache-node-modules.outputs.cache-hit == 'true' && steps.cache-old-dot-node-modules.outputs.cache-hit != 'true' + shell: bash + run: rm -rf node_modules + - name: Install root project node packages - if: steps.cache-node-modules.outputs.cache-hit != 'true' + if: steps.cache-node-modules.outputs.cache-hit != 'true' || (inputs.IS_HYBRID_BUILD == 'true' && steps.cache-old-dot-node-modules.outputs.cache-hit != 'true') uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 with: timeout_minutes: 30 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 354b78d437a3..86bfa195cd75 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -137,6 +137,8 @@ jobs: - name: Setup Node id: setup-node uses: ./.github/actions/composite/setupNode + with: + IS_HYBRID_BUILD: 'true' - name: Run grunt build run: | @@ -449,6 +451,8 @@ jobs: - name: Setup Node id: setup-node uses: ./.github/actions/composite/setupNode + with: + IS_HYBRID_BUILD: 'true' - name: Setup Ruby uses: ruby/setup-ruby@v1.190.0 diff --git a/.github/workflows/testBuildHybrid.yml b/.github/workflows/testBuildHybrid.yml index 42a5f15f8910..d9f22ecb9178 100644 --- a/.github/workflows/testBuildHybrid.yml +++ b/.github/workflows/testBuildHybrid.yml @@ -115,6 +115,8 @@ jobs: - name: Setup Node id: setup-node uses: ./.github/actions/composite/setupNode + with: + IS_HYBRID_BUILD: 'true' - name: Run grunt build run: | @@ -223,6 +225,8 @@ jobs: - name: Setup Node id: setup-node uses: ./.github/actions/composite/setupNode + with: + IS_HYBRID_BUILD: 'true' - name: Create .env.adhoc file based on staging and add PULL_REQUEST_NUMBER env to it run: | diff --git a/README.md b/README.md index 455f2f61197d..a3862d391518 100644 --- a/README.md +++ b/README.md @@ -463,6 +463,8 @@ You can only build HybridApp if you have been granted access to [`Mobile-Expensi [url "https://github.com/"] insteadOf = ssh://git@github.com/ ``` +3. The first time you build the app you will need to build YAPL (OldDot javascript logic). Simply run `npm run grunt:build:shared` from the `Mobile-Expensify` submodule + - The following runtime error often indicates that YAPL has not been built correctly: `undefined is not an object (evaluating'Store.ReportHistory.bindCacheClearingEvents')` At this point, the default behavior of some `npm` scripts will change to target HybridApp: - `npm run android` - build HybridApp for Android diff --git a/android/app/build.gradle b/android/app/build.gradle index 4c917b995331..a3506b187d72 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 1009008103 + versionName "9.0.81-3" // 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/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md index 328b7f2051bc..964664c1d519 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md @@ -13,14 +13,16 @@ Expensify makes it easy to receive bills in three simple ways: Share your Expensify billing email with vendors to receive bills automatically. - Set a Primary Contact under **Settings > Domains > Domain Admins**. -- Ask vendors to email bills to your billing address: `domainname@expensify.cash` (e.g., for *expensify.com*, use `expensify@expensify.cash`). +- Ask vendors to email bills to your billing address: `domainname@expensify.cash` (e.g., for *expensicorp.com*, use `expensicorp@expensify.cash`). - Once emailed, the bill is automatically created in Expensify, ready for payment. +![Setting the Primary Contact at Domain Admins > Primary Contact](https://help.expensify.com/assets/images/OldDot%20-%20Create%20%26%20Pay%20Bills%201.png){:width="100%"} + ### 2. Forwarding Emails Received a bill in your email? Forward it to Expensify. - Ensure your Primary Contact is set under **Settings > Domains > Domain Admins**. -- Forward bills to `domainname@expensify.cash`. Example: `domainname@expensify.cash` (e.g., for *expensify.com*, use `expensify@expensify.cash`). +- Forward bills to `domainname@expensify.cash`. Example: `domainname@expensify.cash` (e.g., for *expensicorp.com*, use `expensicorp@expensify.cash`). - Expensify will create a bill automatically, ready for payment. ### 3. Manual Upload @@ -31,6 +33,7 @@ Got a paper bill? Create a bill manually in [Expensify](https://www.expensify.co 3. Enter the invoice details: sender’s email, merchant name, amount, and date. 4. Upload the invoice as a receipt. +![Manually Create a Bill](https://help.expensify.com/assets/images/OldDot%20-%20Create%20%26%20Pay%20Bills%202.png){:width="100%"} # Paying Bills in Expensify @@ -44,6 +47,8 @@ Expensify makes it easy to manage and pay vendor bills with a straightforward wo 4. **Approval Workflow**: Once reviewed, the bill follows your workspace’s approval process. The final approver handles the payment. 5. **Accounting Integration**: During approval, the bill is coded with the correct GL codes from your connected accounting software. Once approved, it can be exported back to your accounting system. +![Paying a Bill](https://help.expensify.com/assets/images/OldDot%20-%20Create%20%26%20Pay%20Bills%203.png){:width="100%"} + ## Payment Methods Expensify offers several ways to pay bills. Choose the method that works best for you: @@ -92,20 +97,27 @@ If you prefer to pay outside Expensify, you can still track the payment within t 3. Select **Mark as Paid** to update its status. **Fees:** None. + {% include faq-begin.md %} ## Who receives vendor bills in Expensify? -bills are sent to the Primary Contact listed under **Settings > Domains > [Domain Name] > Domain Admins**. + +Bills are sent to the Primary Contact listed under **Settings > Domains > [Domain Name] > Domain Admins**. ## Who can view and pay a bill? + Only the primary domain contact can view and pay a bill. ## How can others access bills? + The primary contact can share bills or grant Copilot access for others to manage payments. ## Is bill Pay supported internationally? + Currently, payments are only supported in USD. ## What's the difference between a bill and an Invoice in Expensify? + A bill represents a payable amount owed to a vendor, while an Invoice is a receivable amount owed to you. + {% include faq-end.md %} diff --git a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md index f94e692f5e56..1398e02a7a03 100644 --- a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md +++ b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md @@ -3,97 +3,156 @@ title: Troubleshooting description: How to troubleshoot company card importing in Expensify --- # Overview -Whether you're encountering issues related to company cards, require assistance with company card account access, or have questions about company card import features, you've come to the right place. +This guide helps you troubleshoot common issues with company cards in Expensify, including connection errors, missing transactions, and account setup problems. -## How to add company cards to Expensify -You can add company credit cards under the Domain settings in your Expensify account by navigating to *Settings* > *Domain* > _Domain Name_ > *Company Cards* and clicking *Import Card/Bank* and following the prompts. +## Adding company cards to Expensify +To add company credit cards: -## To Locate Missing Card Transactions in Expensify -1. **Wait for Posting**: Bank transactions may take up to 24 hours to import into Expensify after they have "posted" at your bank. Ensure sufficient time has passed for transactions to appear. -2. **Update Company Cards**: Go to Settings > Domains > Company Cards. Click on the card in question and click "Update" to refresh the card feed. -3. **Reconcile Cards**: Navigate to the Reconciliation section under Settings > Domains > Company Cards. Refer to the detailed guide on how to use the [Reconciliation Dashboard](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Reconciliation#identifying-outstanding-unapproved-expenses-using-the-reconciliation-dashboard). -4. **Review Transactions**: Use the Reconciliation Dashboard to view all transactions within a specific timeframe. Transactions will display on the Expenses page based on their "Posted Date". If needed, uncheck the "use posted date" checkbox near the filters to view transactions based on their "Transaction Date" instead. -5. **Address Gaps**: If there is a significant gap in transactions or if transactions are still missing, contact Expensify's Concierge or your Account Manager. They can initiate a historical data update on your card feed to ensure all transactions are properly imported. +1. Go to **Settings** > **Domain** > _[Domain Name]_ > **Company Cards**. +2. Click **Import Card/Bank** and follow the prompts. -Following these steps should help you identify and resolve any issues with missing card transactions in Expensify. +{% include info.html %} +Only Domain Admins can connect and assign company cards in Expensify. If you're not a Domain Admin and want to connect your own credit card, follow the steps [here](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards) to connect it as a personal card. +{% include end-info.html %} -## Known issues importing transactions -The first step should always be to "Update" your card, either from Settings > Your Account > Credit Card Import or Settings > Domain > [Domain Name] > Company Cards for centrally managed cards. If a "Fix" or "Fix card" option appears, follow the steps to fix the connection. If this fails to import your missing transactions, there is a known issue whereby some transactions will not import for certain API-based company card connections. So far this has been reported on American Express, Chase and Wells Fargo. This can be temporarily resolved by creating the expenses manually instead: +## Best practices for establishing the initial card connection +To ensure a successful initial card connection in Expensify, follow these best practices: -- [Manually add the expenses](https://help.expensify.com/articles/expensify-classic/expenses/expenses/Add-an-expense) -- [Upload the expenses via CSV](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import) +- **Import in the Correct Location**: For company cards, navigate to **Settings** > **Domains** > _[Domain Name]_ > **Company Cards** > **Import Card** to establish the connection. For personal or individual card accounts, refer to the instructions [here](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards). +- **Select the Appropriate Bank Connection**: Ensure you’re selecting the appropriate bank connection for your cards. +- **Use Master or Parent Administrative Credentials**: For company cards, always use the master administrative credentials to import the entire set of cards. +- **Disable Two-Factor Authentication (2FA)**: Expensify cannot bypass bank-imposed 2FA requirements. To maintain a stable connection, temporarily disable 2FA on your bank account before attempting to connect. -# Errors connecting company cards +By following these steps, you can avoid common issues and establish a stable card connection with Expensify. + +# Resolving missing card transactions + +Here are some common steps to resolve issues with missing imported expenses: + +1. **Wait for posting.** Bank transactions may take up to 24 hours to import into Expensify after they have posted at your bank. Ensure sufficient time has passed for transactions to appear. +2. **Update company cards.** Go to **Settings** > **Domains** > _[Domain Name]_ > **Company Cards**. Click on the card in question and select **Update** to refresh the card feed. +3. **Reconcile cards.** Navigate to the **Reconciliation** section under **Settings** > **Domains** > _[Domain Name]_ > **Company Cards**. Refer to the detailed guide on how to use the [Reconciliation Dashboard](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Reconciliation#identifying-outstanding-unapproved-expenses-using-the-reconciliation-dashboard). +4. **Review transactions.** Use the [Reconciliation Dashboard](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Reconciliation#identifying-outstanding-unapproved-expenses-using-the-reconciliation-dashboard) to view all transactions within a specific timeframe. Transactions will display on the **Expenses** page based on their posted date. If needed, uncheck the Use Posted Date checkbox near the filters to view transactions based on their Transaction Date instead. +5. **Address gaps.** If there is a significant gap in transactions or if transactions are still missing, contact Concierge or your Account Manager. They can initiate a historical data update on your card feed to ensure all transactions are properly imported. + +# General troubleshooting + +## Common import problems + +If company cards seem to be disconnected or not working as expected, troubleshoot by: +- Clicking **Update Card** under: + - **Settings** > **Account** > **Credit Card Import** for personal cards, or + - **Settings** > **Domains** > _[Domain Name]_ > **Company Cards** for company cards. +- If a **Fix** option appears, click on it and follow the steps to fix the connection. + +## Alternative workarounds +For persistent issues with API-based connections (e.g., American Express, Chase, Wells Fargo), the alternative option is to [manually add expenses](https://help.expensify.com/articles/expensify-classic/expenses/expenses/Add-an-expense), or [upload expenses via CSV](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import). + +## The connection is established but there are no cards to assign +When establishing the connection, you must assign cards during the same session. It isn't possible to create the connection, log out, and assign the cards later, as the connection will not stick and will require you to reattempt the connection. + +# Addressing duplicate expenses + +If a workspace member is experiencing duplicated expenses, this is typically due to: + + - A cardholder having accidentally imported the card as a personal credit card, in addition to being assigned the company card by a Domain Admin. + - To troubleshoot, have the employee navigate to **Settings** > **Account** > **Credit Card Import** and confirm that their card is only listed once. + + - The card was reassigned to the cardholder without the appropriate transaction start date being selected, resulting in a period of overlap. + - To troubleshoot, ensure expenses on the new card assignment have not been submitted. Then unassign the card and reassign it with a more appropriate start date. This action will delete all unsubmitted expenses from the new card feed. + +{% include info.html %} +Deleting a duplicate card will remove all Unapproved and Open expenses linked to that card. However, transactions associated with the remaining assigned card will remain unaffected. Any receipts attached to the deleted transactions will still appear on the Expenses page and can be reattached to the corresponding imported expense on the remaining assigned card. +{% include end-info.html %} + +# Tips for stable bank connections + +## Causes for connection breaks +Banks frequently update their APIs to enhance the security of financial information. However, for security reasons, they may not notify third-party services like Expensify in advance of these changes. Expensify's engineering team works diligently to minimize interruptions by monitoring bank connections and collaborating with banks to address updates promptly. + +## Resolving connection issues +Expensify's API-based banking connections rely on the online banking login credentials to maintain the connection. If your online banking username, password, security questions, login authentication, or card numbers change, the connection may need to be reestablished. Domain Admins can update this information in Expensify and manually reestablish the connection via **Settings** > **Domains** > _[Domain Name]_ > **Company Cards** > **Fix**. The Domain Admin will be prompted to enter the new credentials or updated information, which should reestablish the connection. + +# Common errors and resolutions + +Here are some errors that can occur when working with bank connections, and steps for resolving them: ## Error: Too many attempts -If you've been locked out while trying to import a new card, you'll need to wait a full 24 hours before trying again. This lock happens when incorrect online banking credentials are entered multiple times, and it's there for your security — it can't be removed. To avoid this, make sure your online banking credentials are correct before attempting to import your card again. - -## Error: Invalid credentials/Login failed -Verify your ability to log into your online banking portal by attempting to log into your bank account via the banking website. -Check for any potential temporary outages on your bank's end that may affect third-party connections like Expensify. -For specific card types: -- *Chase Card*: Confirm your password meets their new 8-32 character requirement. -- *Wells Fargo Card*: Ensure your password is under 14 characters. Reset it if necessary before importing your card to Expensify. If your card is already imported, update it and use the "Fix Card" option to reestablish the connection. -- *SVB Card*: Enable Direct Connect from the SVB website and use your online banking username and Direct Connect PIN instead of your password when connecting an SVB card. If connecting via *Settings* > *Domain* > _[Domain Name]_ > *Company Cards*, contact SVB for CDF feed setup. +If you've been locked out while trying to import a new card, you will need to wait a full 24 hours before trying again. This lock happens when incorrect online banking credentials are entered multiple times, and it cannot be bypassed. To avoid this, make sure your online banking credentials are correct before attempting to import your card again. + +## Error: Invalid credentials/login failed +Verify the online banking login details by accessing your bank's website directly. +- Some known bank-specific requirements are: + - **Chase**. Password must meet their 8-32 character requirement. + - **Wells Fargo**. Password must be under 14 characters. + - **SVB**. Enable Direct Connect and use the Direct Connect PIN for login. ## Error: Direct Connect not enabled -Direct Connect will need to be enabled in your account for your bank/credit card provider before you can import your card to Expensify. Please reach out to your bank to confirm if this option is available for your account, as well as get instructions on how to get this setup. +Direct Connect needs to be enabled on the bank account by your bank or credit card provider before it can be connected to Expensify. Please reach out to your bank to confirm if this option is available for your account and get instructions on how to enable it. -## Error: Account Setup -This error message typically indicates that there's something you need to do on your bank account's end. Please visit your online banking portal and check if there are any pending actions required. Once you've addressed those, you can try connecting your card again. -For Amex cardholders with multiple card programs in your Amex US Business account: To import multiple card programs into Expensify, you'll need to contact Amex and request that they separate the multiple card programs into distinct logins. For instance, you'll want to have your _Business Platinum_ cards under *"username1/password1"* and _Business Gold_ cards under *"username2/password2."* This ensures smooth integration with Expensify. +## Error: Account setup +This error message indicates that there is something you need to do on your bank account's end. Please visit your online banking portal and check if there are any pending actions required before attempting to connect your card again. -## Error: Account type not supported -If Expensify doesn't have a direct connection to your bank/credit card provider, we can still support the connection via spreadsheet import, which you can learn more about [here](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import). If the cards you're trying to import are company cards, it’s possible that you might be able to obtain a commercial feed directly from your bank. Please find more information on this [here](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Commercial-Card-Feeds). +# Troubleshooting American Express connections -## Error: Username/Password/Questions out of date -Your company card connection is broken because we're missing some answers to some security questions. Please head to *Settings* > *Domain* > _[Domain Name]_ > *Company Cards* and click _Fix Card_. -This will require you to answer your bank's security questions. You will need to do this for each security question you have with your bank; so if you have 3 security questions, you will need to do this 3 times. +## Account roles and permissions +When connecting American Express cards to Expensify, you must use the Amex login credentials of the Primary/Basic account holder. Using other credentials, such as Supplemental Cardmember or Authorized Account Manager, will fail to load card data or may result in an error. -## Error: Account not found/Card number changed -This error message appears when you have been issued a new card, or if there's been a significant change to the account in some other way (password and/or card number change). -When your online bank/card account password has been changed, you may need to update the details on the Expensify end as well. To do this, navigate to *Settings* > *Domain* > _[Domain Name]_ > *Company Cards* and click _Fix Card_. -If there’s been a recent change to the card number, you’ll have to remove the card with the previous number and re-import the card using the new number. A Domain Admin will have to re-assign the card via *Settings* > *Domain* > _Domain Name_ > *Company Cards*. Before removing the card, please ensure *all Open reports have been submitted*, as removing the card will remove all imported transactions from the account that are associated with that card. +{% include info.html %} +In American Express, the Primary/Basic Account Holder is typically the person who applied for the American Express Business card, owns the account, manages its finances, and controls card issuance and account management. They can see all charges made by other cardmembers on their account. -## Error: General connection error -This error message states that your bank or credit card provider is under maintenance and is unavailable at this time. Try waiting a few hours before trying to import your credit card again. Check out our [status page](https://status.expensify.com/) for updates on bank/credit card connections, or you can also choose to subscribe to updates for your specific account type. +By contrast, a Supplemental Cardmember or Employee Cardmember is typically an employee on American Express accounts with access to their own card and payments. An Authorized Account Manager (AAM) has management privileges allowing them to manage the account and Supplemental Cardmembers' accounts. These roles do not have sufficient permissions in American Express to authorize the connection to Expensify, and therefore only the Primary/Basic Account Holder credentials can be used. +{% include end-info.html %} -## Error: Not seeing cards listed after a successful login -The card will only appear in the drop-down list for assignment once it’s activated and there are transactions that have been incurred and posted on the card. If not, the card won't be available to assign to the card holder until then. +## Importing multiple card programs +If you have multiple American Express card programs, contact Amex and request that they separate the multiple card programs into distinct logins. For example, you can have your _Business Platinum_ cards under *"username1/password1"* and _Business Gold_ cards under *"username2/password2"*. This ensures smooth integration with Expensify. -# Troubleshooting issues assigning company cards +## Connecting multiple company card programs under the same credentials +If you have multiple company card programs using the same credentials, you can import all programs together, which will display them under a single dropdown. Be sure to select all relevant cards each time you add cards from any program. -## Why do bank connections break? -Banks often make changes to safeguard your confidential information, and when they do, we need to update the connection between Expensify and the bank. We have a team of engineers who work closely with banks to monitor this and update our software accordingly when this happens. -The first step is to check if there have been any changes to your bank information. Have you recently changed your banking password without updating it in Expensify? Has your banking username or card number been updated? Did you update your security questions for your bank? -If you've answered "yes" to any of these questions, a Domain Admins need to update this information in Expensify and manually re-establish the connection by heading to *Settings* > *Domains* > _Domain Name_ > *Company Cards* > *Fix*. The Domain Admin will be prompted to enter the new credentials/updated information and this should reestablish the connection. +If you prefer to manage card programs separately, you can import them one at a time, ensuring you select all cards within the specific program during each import. After authorizing the account, you will be guided back to Expensify to assign the cards as needed. -## How do I resolve errors while I’m trying to import my card?* -Make sure you're importing your card in the correct spot in Expensify and selecting the right bank connection. For company cards, use the master administrative credentials to import your set of cards at *Settings* > *Domains* > _Domain Name_ > *Company Cards* > *Import Card*. -Please note there are some things that cannot be bypassed within Expensify, including two-factor authentication being enabled within your bank account. This will prevent the connection from remaining stable and will need to be turned off on the bank side. +*Important Reminder*: Whenever you need to access the connection to assign a new card, you must still choose all card programs. For example, if you have a new employee with a card under your Business Gold Rewards Card program, you will still need to authorize all the cards in that program or all the programs if you have only one dropdown menu. -## Why Can’t I See the Transactions Before a Certain Date? -When importing a card into Expensify, the platform typically retrieves 30-90 days of historical transactions, depending on the card or account type. For commercial feeds, transactions cannot be imported before the bank starts sending data. If needed, banks can send backdated files, and Expensify can run a historical update upon request. +## Adding cards under different programs with different logins +If you have multiple card programs with different credentials, you will need another Domain Admin account to add each card program from their own account. Once all Domain Admins have connected and assigned the cards they are the Primary account holder for, all cards will be listed under one *American Express (New and Upgraded)* list on the Domain Company Card page. -Additionally, Expensify does not import transactions dated before the "start date" you specify when assigning the card. Unless transitioning from an old card to a new one to avoid duplicates, it's advisable to set the start date to "earliest possible" or leave it blank. +## Amex error: Username, password, or security questions out of date +Your company card connection is broken because Expensify is missing answers to your security questions. Go to **Settings** > **Domain** > _[Domain Name]_ > **Company Cards** and click **Fix**. Answer your bank's security questions to restore the connection. Repeat this process for each security question your bank requires. -For historical expenses that cannot be imported automatically, consider using Expensify's [company card](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/CSV-Import) or [personal card](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards#importing-expenses-via-a-spreadsheet) spreadsheet import method. This allows you to manually input missing transactions into the system. +## Amex error: Account not found or card number changed +This error occurs when you have been issued a new card or if there has been a significant change to the account, such as a password or card number update. -## Why Am I / Why Is My Employee Seeing Duplicates? -If an employee is seeing duplicate expenses, they may have accidentally imported the card as a personal credit card as well as having the Domain Admin assign them a company card. +To update the connection: +1. Go to **Settings** > **Domain** > _[Domain Name]_ > **Company Cards** and click **Fix**. +2. If there has been a card number change, remove the card with the previous number and re-import the card with the new number. +3. Before removing the card, ensure all open reports have been submitted. Removing the card will delete all imported transactions associated with that card. A Domain Admin will need to re-assign the card after re-importing it. -To troubleshoot: -- Have the employee navigate to their Settings > Your Account > Credit Card Import and confirm that their card is only listed once. -- If the card is listed twice, delete the entry without the "padlock" icon. +## Amex error: General connection error +This error indicates that your bank or credit card provider is under maintenance and unavailable. Wait a few hours before trying to import your credit card again. Check Expensify's [status page](https://status.expensify.com/) for updates on bank or credit card connections, or subscribe to updates for your account type. + +## Amex error: Session has expired +If you see an error stating "Your session has expired. Please return to Expensify and try again," this means you are using incorrect Amex credentials. Use the Primary/Basic account holder credentials. If you are unsure which credentials to use, contact American Express for guidance. + +## Amex error: Card isn't eligible +This error occurs when the account is not a business account or the credentials used are not for the Primary account holder. Verify the account type and credentials before attempting to connect again. -**Important:** Deleting a duplicate card will delete all unapproved expenses from that transaction feed. Transactions associated with the remaining card will not be affected. If receipts were attached to those transactions, they will still be on the Expenses page, and the employee can click to SmartScan them again. +# Troubleshooting Chase connections -Duplicate expenses might also occur if you recently unassigned and reassigned a company card with an overlapping start date. If this is the case and expenses on the “new” copy have not been submitted, you can unassign the card again and reassign it with a more appropriate start date. This action will delete all unsubmitted expenses from the new card feed. +## Resetting Chase access to Expensify +If you are experiencing issues with your Chase connection in Expensify, resetting access can often resolve the problem. Follow these steps to troubleshoot: -## What are the most reliable bank connections in Expensify?* -All bank connections listed below are extremely reliable, but we recommend transacting with the Expensify Visa® Commercial Card. It also offers daily and monthly settlement, unapproved expense limits, realtime compliance for secure and efficient spending, and cash back on all US purchases. [Click here to learn more about the Expensify Card](https://use.expensify.com/company-credit-card). +1. Log in to your Chase account portal and visit the [Linked Apps & Websites](https://www.chase.com/digital/data-sharing) page in the Security Center. +2. Locate Expensify in the Linked Apps & Websites list. +3. Select **Stop sharing data** to disconnect Expensify's access to your Chase account. +4. After resetting access, follow the instructions [here](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting#how-to-add-company-cards-to-expensify) to reestablish the connection to Chase. -We've also teamed up with major banks worldwide to ensure a smooth import of credit card transactions into your accounts: +{% include faq-begin.md %} + +## What bank connections does Expensify offer? +Expensify offers highly reliable bank connections, but we recommend using the Expensify Visa® Commercial Card. It provides daily and monthly settlement, unapproved expense limits, real-time compliance for secure and efficient spending, and cash back on all US purchases. [Click here to learn more about the Expensify Card](https://use.expensify.com/company-credit-card). + +Alternatively, Expensify has partnered with major banks worldwide to ensure a smooth import of credit card transactions into your accounts, including: - American Express - Bank of America - Brex @@ -103,33 +162,13 @@ We've also teamed up with major banks worldwide to ensure a smooth import of cre - Stripe - Wells Fargo -Commercial feeds for company cards are the dependable connections in Expensify. If you have a corporate or commercial card account, you might have access to a daily transaction feed where expenses from Visa, Mastercard, and American Express are automatically sent to Expensify. Reach out to your banking relationship manager to check if your card program qualifies for this feature. +## What are the most stable bank connections? +Commercial feeds for company cards are the most dependable connections in Expensify and are considered more stable than API-based connections. If you have a corporate or commercial card account, you might have access to a daily transaction feed where expenses from Visa, Mastercard, and American Express are automatically sent to Expensify. Contact your banking relationship manager to check if your card program qualifies for this feature. -# Troubleshooting American Express Business +## Why can’t I see the transactions before a certain date? +When importing a card into Expensify, the bank typically provides 30-90 days of historical transactions, depending on the card or account type. For commercial feeds, transactions cannot be imported before the bank starts sending data, however banks can send backdated files if historical transactions are needed. -## Amex account roles -American Express provides three different roles for accessing accounts on their website. When connecting Amex cards to Expensify, it's crucial to use the credentials of the Primary/Basic account holder. Here's what each role means: -- *Primary/Basic Account Holder*: The person who applied for the American Express Business card, owns the account, manages its finances, and controls card issuance and account management. They can view all charges by other cardmembers on their account. They can see all charges made by other cardmembers on their account. -- *Supplemental Cardmember (Employee Cardmember)*: Chosen by the Primary Card Member (typically an employee on business accounts), they can access their own card info and make payments but can't see other account details. -- *Authorized Account Manager (AAM)*: Chosen by the Primary Card Member, AAMs can manage the account online or by phone, but they can't link cards to services like Expensify. They have admin rights, including adding cards, making payments, canceling cards, and setting limits. To connect cards to Expensify, use the Primary Card Holder's credentials for full access. - -## The connection is established but there are no cards to assign +Additionally, Expensify does not import transactions dated before the "start date" you specify when assigning the card. Unless transitioning from an old card to a new one to avoid duplicates, it is advisable to set the start date to "earliest possible" or leave it blank. For historical expenses that cannot be imported automatically, consider using Expensify's [company card](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/CSV-Import) or [personal card](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards#importing-expenses-via-a-spreadsheet) spreadsheet import method to manually input missing transactions into the system. -When establishing the connection, you must assign cards during the same session. It isn't possible to create the connection, log out, and assign the cards later, as the connection will not stick, and require you to reattempt the connection again. +{% include faq-end.md %} -## Amex error: Card isn't eligible -This error comes directly from American Express and is typically related to an account that is not a business account or using credentials that are not the primary account holder credentials. - -## Amex error: Session has expired -If you get an error stating an American Express Business Card “Your session has expired. Please return to Expensify and try again, this always means that you are using the incorrect credentials. Remember, you need to use primary/basic cardholder credentials. If you are not sure which credentials you should use, reach out to American Express for guidance. - -## Connect multiple company card programs under the same credentials -If you have multiple company card programs with the same credentials, you can select ALL programs at once. With this, all programs will be under one dropdown. Make sure to select all cards each time you are adding any cards from any program. -If you would like your card programs listed under separate dropdowns, you can select only that group making sure to select all cards from that group each time you are adding a new card. -Once you have authorized the account, you’ll be guided back to Expensify where you’ll assign all necessary cards across all programs. -This will store all cards under the same American Express Business connection dropdown and allow all cards to be added to Expensify for you to assign to users. -*Important Reminder*: Whenever you need to access the connection to assign a new card, you must still choose "ALL card programs." For instance, if you have a new employee with a card under your Business Gold Rewards Card program, you'll still need to authorize all the cards in that program or all the programs if you have only one dropdown menu! - -## Add cards under different programs with different logins -If you have multiple card programs with different credentials, you will need to have another Domain Admin account add each card program from their own account. -Once all Domain Admins have connected and assigned the cards that they are the Primary account holder for, all cards will be listed under one *American Express (New and Upgraded)* list in the Domain Company Card page. diff --git a/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md b/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md index 68bca5228913..ec3d45b3ac08 100644 --- a/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md +++ b/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md @@ -39,14 +39,14 @@ The three options for the date your report will export with are: ## Accounting Method This dictates when reimbursable expenses will export, according to your preferred accounting method: -- Accrual: Out of pocket expenses will export immediately when the report is final approved -- Cash: Out of pocket expenses will export when paid via Expensify or marked as Reimbursed +- Accrual: Out-of-pocket expenses will export immediately when the report is final approved +- Cash: Out-of-pocket expenses will export when paid via Expensify or marked as Reimbursed ## Export Settings for Reimbursable Expenses **Expense Reports:** Expensify transactions will export reimbursable expenses as expense reports by default, which will be posted to the payables account designated in NetSuite. -**Vendor Bills:** Expensify transactions export as vendor bills in NetSuite and will be mapped to the subsidiary associated with the corresponding workspace. Each report will be posted as payable to the vendor associated with the employee who submitted the report. You can also set an approval level in NetSuite for vendor bills. +**Vendor Bills:** Expensify transactions export as vendor bills in NetSuite and are mapped to the subsidiary associated with the corresponding workspace. Each report is posted as payable to the vendor associated with the employee who submitted it. You can also set an approval level in NetSuite for vendor bills. **Journal Entries:** Expensify transactions that are set to export as journal entries in NetSuite will be mapped to the subsidiary associated with this workspace. All the transactions will be posted to the payable account specified in the workspace. You can also set an approval level in NetSuite for the journal entries. @@ -63,7 +63,7 @@ This dictates when reimbursable expenses will export, according to your preferre - Journal entry forms do not contain a customer column, so it is not possible to export customers or projects with this export option - The credit line and header level classifications are pulled from the employee record -**Expense Reports:** To use the expense report option for your corporate card expenses, you will need to set up your default corporate cards in NetSuite. +**Expense Reports:** To use the expense report option for your corporate card expenses, you must set up your default corporate cards in NetSuite. To use a default corporate card for non-reimbursable expenses, you must select the correct card on the employee records (for individual accounts) or the subsidiary record (If you use a non-one world account, the default is found in your accounting preferences). @@ -87,6 +87,8 @@ When selecting the option to export non-reimbursable expenses as vendor bills, t The Coding tab is where NetSuite information is configured in Expensify, which allows employees to code expenses and reports accurately. There are several coding options in NetSuite. Let’s go over each of those below. +![Insert alt text for accessibility here]({{site.url}}/assets/images/NetSuite_Configure_08.png){:width="100%"} + ## Expense Categories Expensify's integration with NetSuite automatically imports NetSuite Expense Categories as Categories in Expensify. @@ -225,6 +227,8 @@ From there, you should see the values for the Custom Lists under the Tag or Repo The NetSuite integration’s advanced configuration settings are accessed under **Settings > Workspaces > Group > _[Workspace Name]_ > Connections > NetSuite > Configure > Advanced tab**. +![Insert alt text for accessibility here]({{site.url}}/assets/images/NetSuite_Configure_09.png){:width="100%"} + Let’s review the different advanced settings and how they interact with the integration. ## Auto Sync diff --git a/docs/articles/expensify-classic/domains/SAML-SSO.md b/docs/articles/expensify-classic/domains/SAML-SSO.md index da4bd5639120..df73cf5d54c0 100644 --- a/docs/articles/expensify-classic/domains/SAML-SSO.md +++ b/docs/articles/expensify-classic/domains/SAML-SSO.md @@ -17,7 +17,7 @@ Once the domain is verified, you can access the SSO settings by navigating to Se **Below are instructions for setting up Expensify for specific SSO providers:** - [Amazon Web Services (AWS SSO)](https://static.global.sso.amazonaws.com/app-202a715cb67cddd9/instructions/index.htm) - [Google SAML](https://support.google.com/a/answer/7371682) (for GSuite, not Google SSO) -- [Microsoft Azure Active Directory](https://azure.microsoft.com/en-us/documentation/articles/active-directory-saas-expensify-tutorial/) +- [Microsoft Entra ID (formerly Azure Active Directory)](https://learn.microsoft.com/en-us/entra/identity/saas-apps/expensify-tutorial) - [Okta](https://saml-doc.okta.com/SAML_Docs/How-to-Configure-SAML-2.0-for-Expensify.html) - [OneLogin](https://onelogin.service-now.com/support?id=kb_article&sys_id=e44c9e52db187410fe39dde7489619ba) - [Oracle Identity Cloud Service](https://docs.oracle.com/en/cloud/paas/identity-cloud/idcsc/expensify.html#Expensify) @@ -39,13 +39,13 @@ The entityID for Expensify is https://expensify.com. Remember not to copy and pa ## Can you have multiple domains with only one entity ID? Yes. Please send a message to the Concierge or your account manager, and we will enable the use of the same entity ID with multiple domains. -## How can I update the Microsoft Azure SSO Certificate? +## How can I update the Microsoft Entra ID SSO Certificate? Expensify's SAML configuration doesn't support multiple active certificates. This means that if you create the new certification ahead of time without first removing the old one, the respective IDP will include two unique x509 certificates instead of one, and the connection will break. Should you need to access Expensify, switching back to the old certificate will continue to allow access while that certificate is still valid. -**To transfer from one Microsoft Azure certificate to another, please follow the below steps:** -1. In Azure Directory, create your new certificate. -2. In Azure Director, remove the old, expiring certificate. -3. In Azure Directory, activate the remaining certificate and get a new IDP for Expensify from it. +**To transfer from one Microsoft Entra certificate to another, please follow the below steps:** +1. In Microsoft Entra, create your new certificate. +2. In Microsoft Entra, remove the old, expiring certificate. +3. In Microsoft Entra, activate the remaining certificate and get a new IDP for Expensify from it. 4. In Expensify, replace the previous IDP with the new IDP. 5. Log in via SSO. If login continues to fail, write to Concierge for assistance. diff --git a/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md b/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md index 1b1702c6fcc7..2157e05aa377 100644 --- a/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md +++ b/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md @@ -51,10 +51,10 @@ When an expense is submitted to a workspace, your approver will receive an email {% include end-selector.html %} -![Click Global Create]({{site.url}}/assets/images/ExpensifyHelp-CreateExpense-1.png){:width="100%"} -![Click Submit expense]({{site.url}}/assets/images/ExpensifyHelp-CreateExpense-2.png){:width="100%"} -![Click Scan]({{site.url}}/assets/images/ExpensifyHelp-CreateExpense-3.png){:width="100%"} -![Enter workspace or individual's name]({{site.url}}/assets/images/ExpensifyHelp-CreateExpense-4.png){:width="100%"} +![Click Global Create]({{site.url}}/assets/images/ExpensifyHelp-CreateExpenseUpdate-1.png){:width="100%"} +![Click Create Expense]({{site.url}}/assets/images/ExpensifyHelp-CreateExpenseUpdate-2.png){:width="100%"} +![Click Scan]({{site.url}}/assets/images/ExpensifyHelp-CreateExpenseUpdate-3.png){:width="100%"} +![Enter workspace or individual's name]({{site.url}}/assets/images/ExpensifyHelp-CreateExpenseUpdate-4.png){:width="100%"} {% include info.html %} You can also forward receipts to receipts@expensify.com using your primary or secondary email address. SmartScan will automatically extract all the details from the receipt and add them to your expenses. diff --git a/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-1.png b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-1.png new file mode 100644 index 000000000000..18318f782466 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-1.png differ diff --git a/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-2.png b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-2.png new file mode 100644 index 000000000000..641c32a6a6b6 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-2.png differ diff --git a/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-3.png b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-3.png new file mode 100644 index 000000000000..48c6f12fb75c Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-3.png differ diff --git a/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-4.png b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-4.png new file mode 100644 index 000000000000..5f8af1e46ac4 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-4.png differ diff --git a/docs/assets/images/OldDot - Create & Pay Bills 1.png b/docs/assets/images/OldDot - Create & Pay Bills 1.png new file mode 100644 index 000000000000..a880e012408a Binary files /dev/null and b/docs/assets/images/OldDot - Create & Pay Bills 1.png differ diff --git a/docs/assets/images/OldDot - Create & Pay Bills 2.png b/docs/assets/images/OldDot - Create & Pay Bills 2.png new file mode 100644 index 000000000000..ce022a95c6a1 Binary files /dev/null and b/docs/assets/images/OldDot - Create & Pay Bills 2.png differ diff --git a/docs/assets/images/OldDot - Create & Pay Bills 3.png b/docs/assets/images/OldDot - Create & Pay Bills 3.png new file mode 100644 index 000000000000..071bcc997934 Binary files /dev/null and b/docs/assets/images/OldDot - Create & Pay Bills 3.png differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 1e81fdedcaee..5503225ae275 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.77 + 9.0.81 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.77.6 + 9.0.81.3 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 2291b6e19e37..02227e71584b 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.77 + 9.0.81 CFBundleSignature ???? CFBundleVersion - 9.0.77.6 + 9.0.81.3 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index f94a9a34f558..2344ff93d184 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.77 + 9.0.81 CFBundleVersion - 9.0.77.6 + 9.0.81.3 NSExtension NSExtensionPointIdentifier diff --git a/jest/setupAfterEnv.ts b/jest/setupAfterEnv.ts index d59495874588..4d436316e8c0 100644 --- a/jest/setupAfterEnv.ts +++ b/jest/setupAfterEnv.ts @@ -1,4 +1,3 @@ -// This is required in order for jest to recognize custom matchers like toBeDisabled. This can be removed once testing-library/react-native version is bumped to v12.4 or later -import '@testing-library/jest-native/extend-expect'; +import '@testing-library/react-native/extend-expect'; jest.useRealTimers(); diff --git a/package-lock.json b/package-lock.json index 51773c06935e..f51122b8421c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "new.expensify", - "version": "9.0.77-6", + "version": "9.0.81-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.77-6", + "version": "9.0.81-3", "hasInstallScript": true, "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.209", + "@expensify/react-native-live-markdown": "0.1.210", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -169,8 +169,7 @@ "@storybook/react-webpack5": "^8.4.0", "@storybook/theming": "^8.1.10", "@svgr/webpack": "^6.0.0", - "@testing-library/jest-native": "5.4.1", - "@testing-library/react-native": "11.5.1", + "@testing-library/react-native": "12.8.1", "@trivago/prettier-plugin-sort-imports": "^4.2.0", "@types/base-64": "^1.0.2", "@types/canvas-size": "^1.2.2", @@ -3498,9 +3497,9 @@ } }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.209", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.209.tgz", - "integrity": "sha512-u+RRY+Jog/llEu9T1v0okSLgRhG5jGlX9H1Je0A8HWv0439XFLnAWSvN2eQ2T7bvT8Yjdj5CcC0hkgJiB9oCQw==", + "version": "0.1.210", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.210.tgz", + "integrity": "sha512-CW9DY2yN/QJrqkD6+74s+kWQ9bhWQwd2jT+x5RCgyy5N2SdcoE8G8DGQQvmo6q94KcRkHIr/HsTVOyzACQ/nrw==", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -12934,93 +12933,16 @@ "node": ">=10" } }, - "node_modules/@testing-library/jest-native": { - "version": "5.4.1", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2", - "jest-diff": "^29.0.1", - "jest-matcher-utils": "^29.0.1", - "pretty-format": "^29.0.3", - "redent": "^3.0.0" - }, - "peerDependencies": { - "react": ">=16.0.0", - "react-native": ">=0.59", - "react-test-renderer": ">=16.0.0" - } - }, - "node_modules/@testing-library/jest-native/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@testing-library/jest-native/node_modules/chalk": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@testing-library/jest-native/node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@testing-library/jest-native/node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/jest-native/node_modules/has-flag": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/jest-native/node_modules/supports-color": { - "version": "7.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@testing-library/react-native": { - "version": "11.5.1", + "version": "12.8.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-12.8.1.tgz", + "integrity": "sha512-/7PIFCpeqAD3j7nzKQhZtm1T6RR/O/tB1We7JHtYP5RpTBj8rPitEpt6xGrD8R0ymOh+DxDKK7Zovfv5uDSRWg==", "dev": true, "license": "MIT", "dependencies": { - "pretty-format": "^29.4.0" + "jest-matcher-utils": "^29.7.0", + "pretty-format": "^29.7.0", + "redent": "^3.0.0" }, "peerDependencies": { "jest": ">=28.0.0", diff --git a/package.json b/package.json index c3f6d8e730d8..73641dc0d517 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.77-6", + "version": "9.0.81-3", "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.", @@ -76,7 +76,7 @@ }, "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.209", + "@expensify/react-native-live-markdown": "0.1.210", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -234,8 +234,7 @@ "@storybook/react-webpack5": "^8.4.0", "@storybook/theming": "^8.1.10", "@svgr/webpack": "^6.0.0", - "@testing-library/jest-native": "5.4.1", - "@testing-library/react-native": "11.5.1", + "@testing-library/react-native": "12.8.1", "@trivago/prettier-plugin-sort-imports": "^4.2.0", "@types/base-64": "^1.0.2", "@types/canvas-size": "^1.2.2", diff --git a/src/App.tsx b/src/App.tsx index 52904e0a06c4..cc824b78fa4c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import KeyboardProvider from './components/KeyboardProvider'; import {LocaleContextProvider} from './components/LocaleContextProvider'; import OnyxProvider from './components/OnyxProvider'; import PopoverContextProvider from './components/PopoverProvider'; +import {ProductTrainingContextProvider} from './components/ProductTrainingContext'; import SafeArea from './components/SafeArea'; import ScrollOffsetContextProvider from './components/ScrollOffsetContextProvider'; import {SearchRouterContextProvider} from './components/Search/SearchRouter/SearchRouterContext'; @@ -95,6 +96,7 @@ function App({url}: AppProps) { VideoPopoverMenuContextProvider, KeyboardProvider, SearchRouterContextProvider, + ProductTrainingContextProvider, ]} > diff --git a/src/CONFIG.ts b/src/CONFIG.ts index e5e9a9d1540a..72f98c0ee106 100644 --- a/src/CONFIG.ts +++ b/src/CONFIG.ts @@ -104,4 +104,5 @@ export default { // to read more about StrictMode see: contributingGuides/STRICT_MODE.md USE_REACT_STRICT_MODE_IN_DEV: false, ELECTRON_DISABLE_SECURITY_WARNINGS: 'true', + IS_TEST_ENV: process.env.NODE_ENV === 'test', } as const; diff --git a/src/CONST.ts b/src/CONST.ts index 04933537d294..34f75ab633f0 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -125,21 +125,6 @@ const onboardingEmployerOrSubmitMessage: OnboardingMessage = { '\n' + 'Then, send your request and wait for that sweet “Cha-ching!” when it’s complete.', }, - { - type: 'addBankAccount', - autoCompleted: false, - title: 'Add personal bank account', - description: - 'You’ll need to add your personal bank account to get paid back. Don’t worry, it’s easy!\n' + - '\n' + - 'Here’s how to set up your bank account:\n' + - '\n' + - '1. Click the settings tab.\n' + - '2. Click *Wallet* > *Bank accounts* > *+ Add bank account*.\n' + - '3. Connect your bank account.\n' + - '\n' + - 'Once that’s done, you can request money from anyone and get paid back right into your personal bank account.', - }, ], }; @@ -164,21 +149,6 @@ const combinedTrackSubmitOnboardingEmployerOrSubmitMessage: OnboardingMessage = '\n' + 'And you’re done! Now wait for that sweet “Cha-ching!” when it’s complete.', }, - { - type: 'addBankAccount', - autoCompleted: false, - title: 'Add personal bank account', - description: - 'You’ll need to add your personal bank account to get paid back. Don’t worry, it’s easy!\n' + - '\n' + - 'Here’s how to set up your bank account:\n' + - '\n' + - '1. Click the settings tab.\n' + - '2. Click *Wallet* > *Bank accounts* > *+ Add bank account*.\n' + - '3. Connect your bank account.\n' + - '\n' + - 'Once that’s done, you can request money from anyone and get paid back right into your personal bank account.', - }, ], }; @@ -666,6 +636,7 @@ const CONST = { HANG_TIGHT: 4, }, }, + BANK_INFO_STEP_ACCOUNT_HOLDER_KEY_PREFIX: 'accountHolder', }, INCORPORATION_TYPES: { LLC: 'LLC', @@ -687,7 +658,7 @@ const CONST = { COMBINED_TRACK_SUBMIT: 'combinedTrackSubmit', CATEGORY_AND_TAG_APPROVERS: 'categoryAndTagApprovers', PER_DIEM: 'newDotPerDiem', - PRODUCT_TRAINING: 'productTraining', + NEWDOT_MERGE_ACCOUNTS: 'newDotMergeAccounts', }, BUTTON_STATES: { DEFAULT: 'default', @@ -731,6 +702,9 @@ const CONST = { SHIFT: { DEFAULT: 'shift', }, + ENTER: { + DEFAULT: 'enter', + }, }, KEYBOARD_SHORTCUTS: { SEARCH: { @@ -860,6 +834,11 @@ const CONST = { [PLATFORM_IOS]: {input: 'd', modifierFlags: keyModifierCommand}, }, }, + BACKSPACE: { + descriptionKey: null, + shortcutKey: 'Backspace', + modifiers: [], + }, }, KEYBOARD_SHORTCUTS_TYPES: { NAVIGATION_SHORTCUT: KEYBOARD_SHORTCUT_NAVIGATION_TYPE, @@ -902,7 +881,7 @@ const CONST = { DEEP_DIVE_EXPENSIFY_CARD: 'https://community.expensify.com/discussion/4848/deep-dive-expensify-card-and-quickbooks-online-auto-reconciliation-how-it-works', DEEP_DIVE_ERECEIPTS: 'https://community.expensify.com/discussion/5542/deep-dive-what-are-ereceipts/', DEEP_DIVE_PER_DIEM: 'https://community.expensify.com/discussion/4772/how-to-add-a-single-rate-per-diem', - SET_NOTIFICATION_LINK: 'https://community.expensify.com/discussion/5651/deep-dive--practices-when-youre-running-into-trouble-receiving-emails-from-expensify', + SET_NOTIFICATION_LINK: 'https://community.expensify.com/discussion/5651/deep-dive-best-practices-when-youre-running-into-trouble-receiving-emails-from-expensify', GITHUB_URL: 'https://github.com/Expensify/App', HELP_LINK_URL: `${USE_EXPENSIFY_URL}/usa-patriot-act`, ELECTRONIC_DISCLOSURES_URL: `${USE_EXPENSIFY_URL}/esignagreement`, @@ -1153,6 +1132,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', @@ -1336,6 +1316,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, @@ -2031,76 +2014,82 @@ const CONST = { * Should mirror the list on the OldDot. */ NETSUITE_TAX_COUNTRIES: [ - '_canada', - '_unitedKingdomGB', - '_unitedKingdom', + '_argentina', '_australia', - '_southAfrica', - '_india', - '_france', - '_netherlands', - '_germany', - '_singapore', - '_spain', - '_ireland', - '_denmark', + '_austria', + '_azerbaijan', + '_belgium', '_brazil', - '_japan', - '_philippines', + '_bulgaria', + '_canada', '_china', - '_argentina', - '_newZealand', - '_switzerland', - '_sweden', - '_portugal', - '_mexico', - '_israel', - '_thailand', + '_costaRica', + '_croatia', + '_croatiaHrvatska', + '_cyprus', '_czechRepublic', + '_denmark', '_egypt', + '_estonia', + '_finland', + '_france', + '_georgia', + '_germany', '_ghana', + '_greece', + '_hongKong', + '_hungary', + '_india', '_indonesia', '_iranIslamicRepublicOf', + '_ireland', + '_israel', + '_italy', + '_japan', '_jordan', '_kenya', + '_koreaRepublicOf', + '_koreaTheRepublicOf', '_kuwait', + '_latvia', '_lebanon', + '_lithuania', + '_luxembourg', '_malaysia', + '_malta', + '_mexico', '_morocco', '_myanmar', + '_netherlands', + '_newZealand', '_nigeria', + '_norway', '_pakistan', + '_philippines', + '_poland', + '_portugal', + '_romania', '_saudiArabia', + '_serbia', + '_singapore', + '_slovakRepublic', + '_slovakia', + '_slovenia', + '_southAfrica', + '_spain', '_sriLanka', + '_sweden', + '_switzerland', + '_taiwan', + '_thailand', + '_turkey', + '_turkiye', + '_ukraine', '_unitedArabEmirates', + '_unitedKingdom', + '_unitedKingdomGB', '_vietnam', - '_austria', - '_bulgaria', - '_greece', - '_cyprus', - '_norway', - '_romania', - '_poland', - '_hongKong', - '_luxembourg', - '_lithuania', - '_malta', - '_finland', - '_koreaRepublicOf', - '_italy', - '_georgia', - '_hungary', - '_latvia', - '_estonia', - '_slovenia', - '_serbia', - '_croatiaHrvatska', - '_belgium', - '_turkey', - '_taiwan', - '_azerbaijan', - '_slovakRepublic', - '_costaRica', + '_vietNam', ] as string[], QUICKBOOKS_EXPORT_DATE: { @@ -2355,6 +2344,7 @@ const CONST = { DISTANCE: 'distance', MANUAL: 'manual', SCAN: 'scan', + PER_DIEM: 'per-diem', }, REPORT_ACTION_TYPE: { PAY: 'pay', @@ -2975,6 +2965,50 @@ const CONST = { PAYPERUSE: 'monthly2018', }, }, + get SUBSCRIPTION_PRICES() { + return { + [this.PAYMENT_CARD_CURRENCY.USD]: { + [this.POLICY.TYPE.CORPORATE]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 900, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 1800, + }, + [this.POLICY.TYPE.TEAM]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 500, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 1000, + }, + }, + [this.PAYMENT_CARD_CURRENCY.AUD]: { + [this.POLICY.TYPE.CORPORATE]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 1500, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 3000, + }, + [this.POLICY.TYPE.TEAM]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 700, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 1400, + }, + }, + [this.PAYMENT_CARD_CURRENCY.GBP]: { + [this.POLICY.TYPE.CORPORATE]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 700, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 1400, + }, + [this.POLICY.TYPE.TEAM]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 400, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 800, + }, + }, + [this.PAYMENT_CARD_CURRENCY.NZD]: { + [this.POLICY.TYPE.CORPORATE]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 1600, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 3200, + }, + [this.POLICY.TYPE.TEAM]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 800, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 1600, + }, + }, + }; + }, REGEX: { SPECIAL_CHARS_WITHOUT_NEWLINE: /((?!\n)[()-\s\t])/g, DIGITS_AND_PLUS: /^\+?[0-9]*$/, @@ -3162,6 +3196,7 @@ const CONST = { REPORT_NAME_LIMIT: 100, TITLE_CHARACTER_LIMIT: 100, DESCRIPTION_LIMIT: 1000, + SEARCH_QUERY_LIMIT: 1000, WORKSPACE_NAME_CHARACTER_LIMIT: 80, STATE_CHARACTER_LIMIT: 32, @@ -3199,6 +3234,7 @@ const CONST = { CANCEL_PAYMENT: 'cancelPayment', UNAPPROVE: 'unapprove', DEBUG: 'debug', + GO_TO_WORKSPACE: 'goToWorkspace', }, EDIT_REQUEST_FIELD: { AMOUNT: 'amount', @@ -4608,6 +4644,7 @@ const CONST = { MANUAL: 'manual', SCAN: 'scan', DISTANCE: 'distance', + PER_DIEM: 'per-diem', }, STATUS_TEXT_MAX_LENGTH: 100, @@ -5135,21 +5172,6 @@ const CONST = { '\n' + 'Feel free to add more details if you want, or just send it off. Let’s get you paid back!', }, - { - type: 'addBankAccount', - autoCompleted: false, - title: 'Add personal bank account', - description: - 'You’ll need to add your personal bank account to get paid back. Don’t worry, it’s easy!\n' + - '\n' + - 'Here’s how to set up your bank account:\n' + - '\n' + - '1. Click the settings tab.\n' + - '2. Click *Wallet* > *Bank accounts* > *+ Add bank account*.\n' + - '3. Connect your bank account.\n' + - '\n' + - 'Once that’s done, you can request money from anyone and get paid back right into your personal bank account.', - }, ], }, [onboardingChoices.ADMIN]: { @@ -6441,6 +6463,19 @@ const CONST = { }, MIGRATED_USER_WELCOME_MODAL: 'migratedUserWelcomeModal', + + BASE_LIST_ITEM_TEST_ID: 'base-list-item-', + PRODUCT_TRAINING_TOOLTIP_NAMES: { + CONCEIRGE_LHN_GBR: 'conciergeLHNGBR', + RENAME_SAVED_SEARCH: 'renameSavedSearch', + QUICK_ACTION_BUTTON: 'quickActionButton', + WORKSAPCE_CHAT_CREATE: 'workspaceChatCreate', + SEARCH_FILTER_BUTTON_TOOLTIP: 'filterButtonTooltip', + BOTTOM_NAV_INBOX_TOOLTIP: 'bottomNavInboxTooltip', + LHN_WORKSPACE_CHAT_TOOLTIP: 'workspaceChatLHNTooltip', + GLOBAL_CREATE_TOOLTIP: 'globalCreateTooltip', + }, + SMART_BANNER_HEIGHT: 152, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index a43f1622ec9a..020eb5262200 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -117,9 +117,6 @@ const ONYXKEYS = { /** NVP keys */ - /** Boolean flag only true when first set */ - NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER: 'nvp_isFirstTimeNewExpensifyUser', - /** This NVP contains list of at most 5 recent attendees */ NVP_RECENT_ATTENDEES: 'nvp_expensify_recentAttendees', @@ -222,18 +219,9 @@ const ONYXKEYS = { /** The end date (epoch timestamp) of the workspace owner’s grace period after the free trial ends. */ NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END: 'nvp_private_billingGracePeriodEnd', - /** The NVP containing all information related to educational tooltip in workspace chat */ - NVP_WORKSPACE_TOOLTIP: 'workspaceTooltip', - /** The NVP containing the target url to navigate to when deleting a transaction */ NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL: 'nvp_deleteTransactionNavigateBackURL', - /** Whether to show save search rename tooltip */ - SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP: 'shouldShowSavedSearchRenameTooltip', - - /** Whether to hide gbr tooltip */ - NVP_SHOULD_HIDE_GBR_TOOLTIP: 'nvp_should_hide_gbr_tooltip', - /** Does this user have push notifications enabled for this device? */ PUSH_NOTIFICATIONS_ENABLED: 'pushNotificationsEnabled', @@ -470,6 +458,9 @@ const ONYXKEYS = { /** The user's Concierge reportID */ CONCIERGE_REPORT_ID: 'conciergeReportID', + /* Corpay fieds to be used in the bank account creation setup */ + CORPAY_FIELDS: 'corpayFields', + /** The user's session that will be preserved when using imported state */ PRESERVED_USER_SESSION: 'preservedUserSession', @@ -484,6 +475,7 @@ const ONYXKEYS = { POLICY_RECENTLY_USED_CATEGORIES: 'policyRecentlyUsedCategories_', POLICY_TAGS: 'policyTags_', POLICY_RECENTLY_USED_TAGS: 'nvp_recentlyUsedTags_', + POLICY_RECENTLY_USED_DESTINATIONS: 'nvp_recentlyUsedDestinations_', // Whether the policy's connection data was attempted to be fetched in // the current user session. As this state only exists client-side, it // should not be included as part of the policy object. The policy @@ -629,6 +621,10 @@ const ONYXKEYS = { MONEY_REQUEST_HOLD_FORM_DRAFT: 'moneyHoldReasonFormDraft', MONEY_REQUEST_COMPANY_INFO_FORM: 'moneyRequestCompanyInfoForm', MONEY_REQUEST_COMPANY_INFO_FORM_DRAFT: 'moneyRequestCompanyInfoFormDraft', + MONEY_REQUEST_TIME_FORM: 'moneyRequestTimeForm', + MONEY_REQUEST_TIME_FORM_DRAFT: 'moneyRequestTimeFormDraft', + MONEY_REQUEST_SUBRATE_FORM: 'moneyRequestSubrateForm', + MONEY_REQUEST_SUBRATE_FORM_DRAFT: 'moneyRequestSubrateFormDraft', NEW_CONTACT_METHOD_FORM: 'newContactMethodForm', NEW_CONTACT_METHOD_FORM_DRAFT: 'newContactMethodFormDraft', WAYPOINT_FORM: 'waypointForm', @@ -774,6 +770,8 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: FormTypes.MoneyRequestMerchantForm; [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: FormTypes.MoneyRequestAmountForm; [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: FormTypes.MoneyRequestDateForm; + [ONYXKEYS.FORMS.MONEY_REQUEST_TIME_FORM]: FormTypes.MoneyRequestTimeForm; + [ONYXKEYS.FORMS.MONEY_REQUEST_SUBRATE_FORM]: FormTypes.MoneyRequestSubrateForm; [ONYXKEYS.FORMS.MONEY_REQUEST_HOLD_FORM]: FormTypes.MoneyRequestHoldReasonForm; [ONYXKEYS.FORMS.MONEY_REQUEST_COMPANY_INFO_FORM]: FormTypes.MoneyRequestCompanyInfoForm; [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: FormTypes.NewContactMethodForm; @@ -843,6 +841,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT]: OnyxTypes.PolicyCategories; [ONYXKEYS.COLLECTION.POLICY_TAGS]: OnyxTypes.PolicyTagLists; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; + [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_DESTINATIONS]: OnyxTypes.RecentlyUsedCategories; [ONYXKEYS.COLLECTION.POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED]: boolean; [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyEmployeeList; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: OnyxTypes.InvitedEmailsToAccountIDs; @@ -888,10 +887,8 @@ type OnyxCollectionValuesMapping = { type OnyxValuesMapping = { [ONYXKEYS.ACCOUNT]: OnyxTypes.Account; [ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID]: string; - [ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER]: boolean; - // NVP_ONBOARDING is an array for old users. - [ONYXKEYS.NVP_ONBOARDING]: Onboarding | []; + [ONYXKEYS.NVP_ONBOARDING]: Onboarding; // ONYXKEYS.NVP_TRYNEWDOT is HybridApp onboarding data [ONYXKEYS.NVP_TRYNEWDOT]: OnyxTypes.TryNewDot; @@ -1031,9 +1028,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_BILLING_FUND_ID]: number; [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: number; [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: number; - [ONYXKEYS.NVP_WORKSPACE_TOOLTIP]: OnyxTypes.WorkspaceTooltip; [ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL]: string | undefined; - [ONYXKEYS.NVP_SHOULD_HIDE_GBR_TOOLTIP]: boolean; [ONYXKEYS.NVP_PRIVATE_CANCELLATION_DETAILS]: OnyxTypes.CancellationDetails[]; [ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE]: string; [ONYXKEYS.APPROVAL_WORKFLOW]: OnyxTypes.ApprovalWorkflowOnyx; @@ -1041,9 +1036,9 @@ type OnyxValuesMapping = { [ONYXKEYS.LAST_ROUTE]: string; [ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY]: boolean | undefined; [ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean; - [ONYXKEYS.SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP]: boolean; [ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record; [ONYXKEYS.CONCIERGE_REPORT_ID]: string; + [ONYXKEYS.CORPAY_FIELDS]: OnyxTypes.CorpayFields; [ONYXKEYS.PRESERVED_USER_SESSION]: OnyxTypes.Session; [ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING]: OnyxTypes.DismissedProductTraining; }; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 58d28a46a7b8..c8e8ae582a5b 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -485,6 +485,36 @@ const ROUTES = { getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => getUrlWithBackToParam(`${action as string}/${iouType as string}/upgrade/${transactionID}/${reportID}`, backTo), }, + MONEY_REQUEST_STEP_DESTINATION: { + route: ':action/:iouType/destination/:transactionID/:reportID', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/destination/${transactionID}/${reportID}`, backTo), + }, + MONEY_REQUEST_STEP_TIME: { + route: ':action/:iouType/time/:transactionID/:reportID', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/time/${transactionID}/${reportID}`, backTo), + }, + MONEY_REQUEST_STEP_SUBRATE: { + route: ':action/:iouType/subrate/:transactionID/:reportID/:pageIndex', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/subrate/${transactionID}/${reportID}/0`, backTo), + }, + MONEY_REQUEST_STEP_DESTINATION_EDIT: { + route: ':action/:iouType/destination/:transactionID/:reportID/edit', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/destination/${transactionID}/${reportID}/edit`, backTo), + }, + MONEY_REQUEST_STEP_TIME_EDIT: { + route: ':action/:iouType/time/:transactionID/:reportID/edit', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/time/${transactionID}/${reportID}/edit`, backTo), + }, + MONEY_REQUEST_STEP_SUBRATE_EDIT: { + route: ':action/:iouType/subrate/:transactionID/:reportID/edit/:pageIndex', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, pageIndex = 0, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/subrate/${transactionID}/${reportID}/edit/${pageIndex}`, backTo), + }, SETTINGS_TAGS_ROOT: { route: 'settings/:policyID/tags', getRoute: (policyID: string, backTo = '') => getUrlWithBackToParam(`settings/${policyID}/tags`, backTo), @@ -645,6 +675,10 @@ const ROUTES = { route: ':action/:iouType/start/:transactionID/:reportID/scan', getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string) => `create/${iouType as string}/start/${transactionID}/${reportID}/scan` as const, }, + MONEY_REQUEST_CREATE_TAB_PER_DIEM: { + route: ':action/:iouType/start/:transactionID/:reportID/per-diem', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string) => `create/${iouType as string}/start/${transactionID}/${reportID}/per-diem` as const, + }, MONEY_REQUEST_STATE_SELECTOR: { route: 'submit/state', @@ -702,11 +736,11 @@ const ROUTES = { }, WORKSPACE_INVITE: { route: 'settings/workspaces/:policyID/invite', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/invite` as const, + getRoute: (policyID: string, backTo?: string) => `${getUrlWithBackToParam(`settings/workspaces/${policyID}/invite`, backTo)}` as const, }, WORKSPACE_INVITE_MESSAGE: { route: 'settings/workspaces/:policyID/invite-message', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/invite-message` as const, + getRoute: (policyID: string, backTo?: string) => `${getUrlWithBackToParam(`settings/workspaces/${policyID}/invite-message`, backTo)}` as const, }, WORKSPACE_PROFILE: { route: 'settings/workspaces/:policyID/profile', @@ -730,7 +764,7 @@ const ROUTES = { }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT: { route: 'settings/workspaces/:policyID/accounting/quickbooks-online/export', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/export` as const, + getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/accounting/quickbooks-online/export` as const, backTo), }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT: { route: 'settings/workspaces/:policyID/accounting/quickbooks-online/export/company-card-expense-account', @@ -1211,7 +1245,8 @@ const ROUTES = { }, WORKSPACE_COMPANY_CARD_EXPORT: { route: 'settings/workspaces/:policyID/company-cards/:bank/:cardID/edit/export', - getRoute: (policyID: string, cardID: string, bank: string) => `settings/workspaces/${policyID}/company-cards/${bank}/${cardID}/edit/export` as const, + getRoute: (policyID: string, cardID: string, bank: string, backTo?: string) => + getUrlWithBackToParam(`settings/workspaces/${policyID}/company-cards/${bank}/${cardID}/edit/export`, backTo), }, WORKSPACE_EXPENSIFY_CARD: { route: 'settings/workspaces/:policyID/expensify-card', @@ -1503,7 +1538,7 @@ const ROUTES = { }, POLICY_ACCOUNTING_XERO_EXPORT: { route: 'settings/workspaces/:policyID/accounting/xero/export', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/export` as const, + getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/accounting/xero/export` as const, backTo), }, POLICY_ACCOUNTING_XERO_PREFERRED_EXPORTER_SELECT: { route: 'settings/workspaces/:policyID/connections/xero/export/preferred-exporter/select', @@ -1628,7 +1663,7 @@ const ROUTES = { }, POLICY_ACCOUNTING_NETSUITE_EXPORT: { route: 'settings/workspaces/:policyID/connections/netsuite/export/', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/connections/netsuite/export/` as const, + getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/connections/netsuite/export/` as const, backTo), }, POLICY_ACCOUNTING_NETSUITE_PREFERRED_EXPORTER_SELECT: { route: 'settings/workspaces/:policyID/connections/netsuite/export/preferred-exporter/select', @@ -1766,7 +1801,7 @@ const ROUTES = { }, POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT: { route: 'settings/workspaces/:policyID/accounting/sage-intacct/export', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/export` as const, + getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/accounting/sage-intacct/export`, backTo), }, POLICY_ACCOUNTING_SAGE_INTACCT_PREFERRED_EXPORTER: { route: 'settings/workspaces/:policyID/accounting/sage-intacct/export/preferred-exporter', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 6274be1044b4..2359324c9b90 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -230,6 +230,12 @@ const SCREENS = { RECEIPT: 'Money_Request_Receipt', STATE_SELECTOR: 'Money_Request_State_Selector', STEP_ATTENDEES: 'Money_Request_Attendee', + STEP_DESTINATION: 'Money_Request_Destination', + STEP_TIME: 'Money_Request_Time', + STEP_SUBRATE: 'Money_Request_SubRate', + STEP_DESTINATION_EDIT: 'Money_Request_Destination_Edit', + STEP_TIME_EDIT: 'Money_Request_Time_Edit', + STEP_SUBRATE_EDIT: 'Money_Request_SubRate_Edit', }, TRANSACTION_DUPLICATE: { diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index 6be2b43c09d7..12189d22dba0 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -39,7 +39,7 @@ type AmountTextInputProps = { /** Hide the focus styles on TextInput */ hideFocusedState?: boolean; -} & Pick; +} & Pick; function AmountTextInput( { diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index 1281c017308d..17596bea9af0 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -14,6 +14,7 @@ import Text from '@components/Text'; import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useStyledSafeAreaInsets from '@hooks/useStyledSafeAreaInsets'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -111,6 +112,7 @@ function AttachmentView({ const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); const theme = useTheme(); + const {safeAreaPaddingBottomStyle} = useStyledSafeAreaInsets(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const [loadComplete, setLoadComplete] = useState(false); @@ -281,7 +283,7 @@ function AttachmentView({ }} /> - {isHighResolution && } + {isHighResolution && } ); } diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 84767c6347e7..d943886982e4 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -365,6 +365,10 @@ function Button( if (shouldEnableHapticFeedback) { HapticFeedback.press(); } + + if (isDisabled || isLoading) { + return; // Prevent the onPress from being triggered when the button is disabled or in a loading state + } return onPress(event); }} onLongPress={(event) => { diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index 7cf752a61214..46c3ad18a635 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -44,13 +44,16 @@ function ButtonWithDropdownMenu({ shouldUseStyleUtilityForAnchorPosition = false, defaultSelectedIndex = 0, shouldShowSelectedItemCheck = false, + testID, }: ButtonWithDropdownMenuProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const [selectedItemIndex, setSelectedItemIndex] = useState(defaultSelectedIndex); const [isMenuVisible, setIsMenuVisible] = useState(false); - const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(null); + // In tests, skip the popover anchor position calculation. The default values are needed for popover menu to be rendered in tests. + const defaultPopoverAnchorPosition = process.env.NODE_ENV === 'test' ? {horizontal: 100, vertical: 100} : null; + const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(defaultPopoverAnchorPosition); const {windowWidth, windowHeight} = useWindowDimensions(); const dropdownAnchor = useRef(null); // eslint-disable-next-line react-compiler/react-compiler @@ -139,6 +142,7 @@ function ButtonWithDropdownMenu({ iconRight={Expensicons.DownArrow} shouldShowRightIcon={!isSplitButton} isSplitButton={isSplitButton} + testID={testID} /> {isSplitButton && ( diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 766c0df950b4..dbafbc497105 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -108,6 +108,9 @@ type ButtonWithDropdownMenuProps = { /** Whether selected items should be marked as selected */ shouldShowSelectedItemCheck?: boolean; + + /** Used to locate the component in the tests */ + testID?: string; }; export type { diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 0cddb32f5aeb..a02767d24c87 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -1,7 +1,7 @@ import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; import mimeDb from 'mime-db'; import type {ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {NativeSyntheticEvent, TextInput, TextInputChangeEventData, TextInputPasteEventData} from 'react-native'; import {StyleSheet} from 'react-native'; import type {FileObject} from '@components/AttachmentModal'; @@ -9,6 +9,7 @@ import type {ComposerProps} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useKeyboardState from '@hooks/useKeyboardState'; import useMarkdownStyle from '@hooks/useMarkdownStyle'; import useResetComposerFocus from '@hooks/useResetComposerFocus'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -37,6 +38,7 @@ function Composer( selection, value, isGroupPolicyReport = false, + showSoftInputOnFocus = true, ...props }: ComposerProps, ref: ForwardedRef, @@ -49,7 +51,11 @@ function Composer( const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const [contextMenuHidden, setContextMenuHidden] = useState(true); + const {inputCallbackRef, inputRef: autoFocusInputRef} = useAutoFocusInput(); + const keyboardState = useKeyboardState(); + const isKeyboardShown = keyboardState?.isKeyboardShown ?? false; useEffect(() => { if (autoFocus === !!autoFocusInputRef.current) { @@ -58,6 +64,13 @@ function Composer( inputCallbackRef(autoFocus ? textInput.current : null); }, [autoFocus, inputCallbackRef, autoFocusInputRef]); + useEffect(() => { + if (!showSoftInputOnFocus || !isKeyboardShown) { + return; + } + setContextMenuHidden(false); + }, [showSoftInputOnFocus, isKeyboardShown]); + useEffect(() => { if (!textInput.current || !textInput.current.setSelection || !selection || isComposerFullSize) { return; @@ -158,6 +171,8 @@ function Composer( props?.onBlur?.(e); }} onClear={onClear} + showSoftInputOnFocus={showSoftInputOnFocus} + contextMenuHidden={contextMenuHidden} /> ); } diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index 5af76a2406b5..9171132964f6 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -5,7 +5,7 @@ import type {BaseSyntheticEvent, ForwardedRef} from 'react'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; // eslint-disable-next-line no-restricted-imports import type {NativeSyntheticEvent, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData} from 'react-native'; -import {DeviceEventEmitter, StyleSheet} from 'react-native'; +import {DeviceEventEmitter, InteractionManager, StyleSheet} from 'react-native'; import type {ComposerProps} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; @@ -50,6 +50,7 @@ function Composer( isComposerFullSize = false, shouldContainScroll = true, isGroupPolicyReport = false, + showSoftInputOnFocus = true, ...props }: ComposerProps, ref: ForwardedRef, @@ -74,6 +75,11 @@ function Composer( }); const [hasMultipleLines, setHasMultipleLines] = useState(false); const [isRendered, setIsRendered] = useState(false); + + // On mobile safari, the cursor will move from right to left with inputMode set to none during report transition + // To avoid that we should hide the cursor util the transition is finished + const [shouldTransparentCursor, setShouldTransparentCursor] = useState(!showSoftInputOnFocus && Browser.isMobileSafari()); + const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? ''); const [prevScroll, setPrevScroll] = useState(); const [prevHeight, setPrevHeight] = useState(); @@ -260,6 +266,15 @@ function Composer( setIsRendered(true); }, []); + useEffect(() => { + if (!shouldTransparentCursor) { + return; + } + InteractionManager.runAfterInteractions(() => { + setShouldTransparentCursor(false); + }); + }, [shouldTransparentCursor]); + const clear = useCallback(() => { if (!textInput.current) { return; @@ -347,11 +362,12 @@ function Composer( placeholderTextColor={theme.placeholderText} ref={(el) => (textInput.current = el)} selection={selection} - style={[inputStyleMemo]} + style={[inputStyleMemo, shouldTransparentCursor ? {caretColor: 'transparent'} : undefined]} markdownStyle={markdownStyle} value={value} defaultValue={defaultValue} autoFocus={autoFocus} + inputMode={showSoftInputOnFocus ? 'text' : 'none'} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...props} onSelectionChange={addCursorPositionToSelectionChange} diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index 3df5508f1dd7..6ea3bdb2f824 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -68,6 +68,9 @@ type ComposerProps = Omit & { /** Indicates whether the composer is in a group policy report. Used for disabling report mentioning style in markdown input */ isGroupPolicyReport?: boolean; + + /** Whether to show the keyboard on focus */ + showSoftInputOnFocus?: boolean; }; export type {TextSelection, ComposerProps, CustomSelectionChangeEvent}; diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx index cb0fc6e8e8cb..3bfb5a146d05 100644 --- a/src/components/ConfirmContent.tsx +++ b/src/components/ConfirmContent.tsx @@ -207,6 +207,7 @@ function ConfirmContent({ isPressOnEnterActive={isVisible} large text={confirmText || translate('common.yes')} + accessibilityLabel={confirmText || translate('common.yes')} isDisabled={isOffline && shouldDisableConfirmButtonWhenOffline} /> {shouldShowCancelButton && !shouldReverseStackedButtons && ( diff --git a/src/components/ConnectToNetSuiteFlow/index.tsx b/src/components/ConnectToNetSuiteFlow/index.tsx index 1d33eb07df4f..7957896d4006 100644 --- a/src/components/ConnectToNetSuiteFlow/index.tsx +++ b/src/components/ConnectToNetSuiteFlow/index.tsx @@ -59,10 +59,11 @@ function ConnectToNetSuiteFlow({policyID}: ConnectToNetSuiteFlowProps) { if (threeDotsMenuContainerRef) { if (!shouldUseNarrowLayout) { threeDotsMenuContainerRef.current?.measureInWindow((x, y, width, height) => { - setReuseConnectionPopoverPosition({ - horizontal: x + width, - vertical: y + height, - }); + const horizontal = x + width; + const vertical = y + height; + if (reuseConnectionPopoverPosition.horizontal !== horizontal || reuseConnectionPopoverPosition.vertical !== vertical) { + setReuseConnectionPopoverPosition({horizontal, vertical}); + } }); } diff --git a/src/components/ConnectToSageIntacctFlow/index.tsx b/src/components/ConnectToSageIntacctFlow/index.tsx index f93fce9c668a..807082365042 100644 --- a/src/components/ConnectToSageIntacctFlow/index.tsx +++ b/src/components/ConnectToSageIntacctFlow/index.tsx @@ -64,10 +64,11 @@ function ConnectToSageIntacctFlow({policyID}: ConnectToSageIntacctFlowProps) { if (threeDotsMenuContainerRef) { if (!shouldUseNarrowLayout) { threeDotsMenuContainerRef.current?.measureInWindow((x, y, width, height) => { - setReuseConnectionPopoverPosition({ - horizontal: x + width, - vertical: y + height, - }); + const horizontal = x + width; + const vertical = y + height; + if (reuseConnectionPopoverPosition.horizontal !== horizontal || reuseConnectionPopoverPosition.vertical !== vertical) { + setReuseConnectionPopoverPosition({horizontal, vertical}); + } }); } diff --git a/src/components/ConnectionLayout.tsx b/src/components/ConnectionLayout.tsx index 538ee3559580..9a232e83fb97 100644 --- a/src/components/ConnectionLayout.tsx +++ b/src/components/ConnectionLayout.tsx @@ -32,7 +32,7 @@ type ConnectionLayoutProps = { title?: TranslationPaths; /** The current policyID */ - policyID: string; + policyID?: string; /** Defines which types of access should be verified */ accessVariants?: AccessVariant[]; diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 4e1fef2d827e..abe86509630b 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -225,7 +225,7 @@ function CalendarPicker({ const isDisabled = !day || isBeforeMinDate || isAfterMaxDate; const isSelected = !!day && isSameDay(parseISO(value.toString()), new Date(currentYearView, currentMonthView, day)); const handleOnPress = () => { - if (!day) { + if (!day || isDisabled) { return; } diff --git a/src/components/DestinationPicker.tsx b/src/components/DestinationPicker.tsx new file mode 100644 index 000000000000..ea0acf4a1c58 --- /dev/null +++ b/src/components/DestinationPicker.tsx @@ -0,0 +1,90 @@ +import React, {useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as PerDiemRequestUtils from '@libs/PerDiemRequestUtils'; +import type {Destination} from '@libs/PerDiemRequestUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SelectionList from './SelectionList'; +import RadioListItem from './SelectionList/RadioListItem'; +import type {ListItem} from './SelectionList/types'; + +type DestinationPickerProps = { + policyID: string; + selectedDestination?: string; + onSubmit: (item: ListItem & {currency: string}) => void; +}; + +function DestinationPicker({selectedDestination, policyID, onSubmit}: DestinationPickerProps) { + const policy = usePolicy(policyID); + const customUnit = PolicyUtils.getPerDiemCustomUnit(policy); + const [policyRecentlyUsedDestinations] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_DESTINATIONS}${policyID}`); + + const {translate} = useLocalize(); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + + const selectedOptions = useMemo((): Destination[] => { + if (!selectedDestination) { + return []; + } + + const selectedRate = customUnit?.rates?.[selectedDestination]; + + if (!selectedRate?.customUnitRateID) { + return []; + } + + return [ + { + rateID: selectedRate.customUnitRateID, + name: selectedRate?.name ?? '', + currency: selectedRate?.currency ?? CONST.CURRENCY.USD, + isSelected: true, + }, + ]; + }, [customUnit?.rates, selectedDestination]); + + const [sections, headerMessage, shouldShowTextInput] = useMemo(() => { + const destinationOptions = PerDiemRequestUtils.getDestinationListSections({ + searchValue: debouncedSearchValue, + selectedOptions, + destinations: Object.values(customUnit?.rates ?? {}), + recentlyUsedDestinations: policyRecentlyUsedDestinations, + }); + + const destinationData = destinationOptions?.at(0)?.data ?? []; + const header = OptionsListUtils.getHeaderMessageForNonUserList(destinationData.length > 0, debouncedSearchValue); + const destinationsCount = Object.values(customUnit?.rates ?? {}).length; + const isDestinationsCountBelowThreshold = destinationsCount < CONST.STANDARD_LIST_ITEM_LIMIT; + const showInput = !isDestinationsCountBelowThreshold; + + return [destinationOptions, header, showInput]; + }, [debouncedSearchValue, selectedOptions, customUnit?.rates, policyRecentlyUsedDestinations]); + + const selectedOptionKey = useMemo( + () => (sections?.at(0)?.data ?? []).filter((destination) => destination.keyForList === selectedDestination).at(0)?.keyForList, + [sections, selectedDestination], + ); + + return ( + + ); +} + +DestinationPicker.displayName = 'DestinationPicker'; + +export default DestinationPicker; diff --git a/src/components/EmptySelectionListContent.tsx b/src/components/EmptySelectionListContent.tsx index 67a9a2fc83f3..313b5d620f42 100644 --- a/src/components/EmptySelectionListContent.tsx +++ b/src/components/EmptySelectionListContent.tsx @@ -3,8 +3,10 @@ import {View} from 'react-native'; import type {TupleToUnion} from 'type-fest'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; import BlockingView from './BlockingViews/BlockingView'; import * as Illustrations from './Icon/Illustrations'; import ScrollView from './ScrollView'; @@ -34,13 +36,19 @@ function EmptySelectionListContent({contentType}: EmptySelectionListContentProps const EmptySubtitle = ( {translate(`emptyList.${contentType}.subtitleText1`)} - {translate(`emptyList.${contentType}.subtitleText2`)} + { + Navigation.navigate(ROUTES.REFERRAL_DETAILS_MODAL.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SUBMIT_EXPENSE, Navigation.getActiveRouteWithoutParams())); + }} + > + {translate(`emptyList.${contentType}.subtitleText2`)} + {translate(`emptyList.${contentType}.subtitleText3`)} ); return ( - + (null); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const platform = getPlatform(); + const isNarrowScreenOnWeb = shouldUseNarrowLayout && platform === CONST.PLATFORM.WEB; + const isFocused = useBottomTabIsFocused(); + const [isSidebarLoaded] = useOnyx(ONYXKEYS.IS_SIDEBAR_LOADED, {initialValue: false}); + const isActiveRouteHome = useIsCurrentRouteHome(); + const {renderProductTrainingTooltip, shouldShowProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext( + CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.GLOBAL_CREATE_TOOLTIP, + // On Home screen, We need to wait for the sidebar to load before showing the tooltip because there is the Concierge tooltip which is higher priority + isFocused && (!isActiveRouteHome || isSidebarLoaded), + ); const sharedValue = useSharedValue(isActive ? 1 : 0); const buttonRef = ref; @@ -91,38 +111,51 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: Flo ); const toggleFabAction = (event: GestureResponderEvent | KeyboardEvent | undefined) => { + hideProductTrainingTooltip(); // Drop focus to avoid blue focus ring. fabPressable.current?.blur(); onPress(event); }; return ( - { - fabPressable.current = el ?? null; - if (buttonRef && 'current' in buttonRef) { - buttonRef.current = el ?? null; - } + {}} - role={role} - shouldUseHapticsOnLongPress={false} + shiftHorizontal={isNarrowScreenOnWeb ? 0 : variables.fabTooltipShiftHorizontal} + renderTooltipContent={renderProductTrainingTooltip} + wrapperStyle={styles.productTrainingTooltipWrapper} + shouldHideOnNavigate={false} > - - - - - - + { + fabPressable.current = el ?? null; + if (buttonRef && 'current' in buttonRef) { + buttonRef.current = el ?? null; + } + }} + style={[styles.h100, styles.bottomTabBarItem]} + accessibilityLabel={accessibilityLabel} + onPress={toggleFabAction} + onLongPress={() => {}} + role={role} + shouldUseHapticsOnLongPress={false} + > + + + + + + + ); } diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index ab9260a6b5d9..d6bcc28e09bf 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -23,6 +23,7 @@ import type StatePicker from '@components/StatePicker'; import type StateSelector from '@components/StateSelector'; import type TextInput from '@components/TextInput'; import type TextPicker from '@components/TextPicker'; +import type TimeModalPicker from '@components/TimeModalPicker'; import type UploadFile from '@components/UploadFile'; import type ValuePicker from '@components/ValuePicker'; import type ConstantSelector from '@pages/Debug/ConstantSelector'; @@ -69,7 +70,8 @@ type ValidInputs = | typeof StatePicker | typeof ConstantSelector | typeof UploadFile - | typeof PushRowWithModal; + | typeof PushRowWithModal + | typeof TimeModalPicker; type ValueTypeKey = 'string' | 'boolean' | 'date' | 'country' | 'reportFields' | 'disabledListValues' | 'entityChart'; type ValueTypeMap = { diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index b4d097e90994..363fe238e9f4 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -182,7 +182,7 @@ function HeaderWithBackButton({ width={iconWidth ?? variables.iconHeader} height={iconHeight ?? variables.iconHeader} additionalStyles={[styles.mr2, iconStyles]} - fill={iconFill ?? theme.icon} + fill={iconFill} /> )} {!!policyAvatar && ( diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 4093b44743fe..02a6843dc11f 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -32,6 +32,7 @@ import Box from '@assets/images/box.svg'; import Briefcase from '@assets/images/briefcase.svg'; import Bug from '@assets/images/bug.svg'; import Building from '@assets/images/building.svg'; +import Buildings from '@assets/images/buildings.svg'; import CalendarSolid from '@assets/images/calendar-solid.svg'; import Calendar from '@assets/images/calendar.svg'; import Camera from '@assets/images/camera.svg'; @@ -235,6 +236,7 @@ export { Briefcase, Bug, Building, + Buildings, Calendar, Camera, Car, diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 4379142619ff..0debd4585e7b 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -74,6 +74,8 @@ import BankArrow from '@assets/images/simple-illustrations/simple-illustration__ import BigRocket from '@assets/images/simple-illustrations/simple-illustration__bigrocket.svg'; import PinkBill from '@assets/images/simple-illustrations/simple-illustration__bill.svg'; import Binoculars from '@assets/images/simple-illustrations/simple-illustration__binoculars.svg'; +import Building from '@assets/images/simple-illustrations/simple-illustration__building.svg'; +import Buildings from '@assets/images/simple-illustrations/simple-illustration__buildings.svg'; import CarIce from '@assets/images/simple-illustrations/simple-illustration__car-ice.svg'; import Car from '@assets/images/simple-illustrations/simple-illustration__car.svg'; import ChatBubbles from '@assets/images/simple-illustrations/simple-illustration__chatbubbles.svg'; @@ -228,6 +230,8 @@ export { PendingBank, ThreeLeggedLaptopWoman, House, + Building, + Buildings, Alert, TeachersUnite, Abacus, diff --git a/src/components/ImportSpreadsheet.tsx b/src/components/ImportSpreadsheet.tsx index 18a2bb34ee53..a6a27e2d5bcb 100644 --- a/src/components/ImportSpreadsheet.tsx +++ b/src/components/ImportSpreadsheet.tsx @@ -27,7 +27,7 @@ import Text from './Text'; type ImportSpreedsheetProps = { // The route to navigate to when the back button is pressed. - backTo: Routes; + backTo?: Routes; // The route to navigate to after the file import is completed. goTo: Routes; diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index c423d3101d92..cf30aa8ff96a 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -1,5 +1,5 @@ import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useRef, useState} from 'react'; +import React, {useCallback, useMemo, useRef, useState} from 'react'; import type {GestureResponderEvent, ViewStyle} from 'react-native'; import {StyleSheet, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; @@ -11,10 +11,12 @@ import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {useSession} from '@components/OnyxProvider'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; +import {useProductTrainingContext} from '@components/ProductTrainingContext'; import SubscriptAvatar from '@components/SubscriptAvatar'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; +import useIsCurrentRouteHome from '@hooks/useIsCurrentRouteHome'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -22,7 +24,6 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; import DomUtils from '@libs/DomUtils'; -import {hasCompletedGuidedSetupFlowSelector} from '@libs/onboardingSelectors'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import Performance from '@libs/Performance'; @@ -32,7 +33,6 @@ import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportA import FreeTrial from '@pages/settings/Subscription/FreeTrial'; import variables from '@styles/variables'; import Timing from '@userActions/Timing'; -import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -48,17 +48,24 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${optionItem?.reportID || -1}`); - const [isFirstTimeNewExpensifyUser] = useOnyx(ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER); - const [isOnboardingCompleted = true] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { - selector: hasCompletedGuidedSetupFlowSelector, - }); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const session = useSession(); - - // Guides are assigned for the MANAGE_TEAM onboarding action, except for emails that have a '+'. + const shouldShowWokspaceChatTooltip = ReportUtils.isPolicyExpenseChat(report) && activePolicyID === report?.policyID && session?.accountID === report?.ownerAccountID; const isOnboardingGuideAssigned = introSelected?.choice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM && !session?.email?.includes('+'); - const shouldShowToooltipOnThisReport = isOnboardingGuideAssigned ? ReportUtils.isAdminRoom(report) : ReportUtils.isConciergeChatReport(report); - const [shouldHideGBRTooltip] = useOnyx(ONYXKEYS.NVP_SHOULD_HIDE_GBR_TOOLTIP, {initialValue: true}); + const shouldShowGetStartedTooltip = isOnboardingGuideAssigned ? ReportUtils.isAdminRoom(report) : ReportUtils.isConciergeChatReport(report); + const isActiveRouteHome = useIsCurrentRouteHome(); + + const {tooltipToRender, shouldShowTooltip} = useMemo(() => { + const tooltip = shouldShowGetStartedTooltip ? CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONCEIRGE_LHN_GBR : CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.LHN_WORKSPACE_CHAT_TOOLTIP; + const shouldShowTooltips = shouldShowWokspaceChatTooltip || shouldShowGetStartedTooltip; + const shouldTooltipBeVisible = shouldUseNarrowLayout ? isScreenFocused && isActiveRouteHome : isActiveRouteHome; + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return {tooltipToRender: tooltip, shouldShowTooltip: shouldShowTooltips && shouldTooltipBeVisible}; + }, [shouldShowGetStartedTooltip, shouldShowWokspaceChatTooltip, isScreenFocused, shouldUseNarrowLayout, isActiveRouteHome]); + + const {shouldShowProductTrainingTooltip, renderProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext(tooltipToRender, shouldShowTooltip); const {translate} = useLocalize(); const [isContextMenuActive, setIsContextMenuActive] = useState(false); @@ -72,30 +79,6 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti }, []), ); - const renderGBRTooltip = useCallback( - () => ( - - - {translate('sidebarScreen.tooltip')} - - ), - [ - styles.alignItemsCenter, - styles.flexRow, - styles.justifyContentCenter, - styles.flexWrap, - styles.textAlignCenter, - styles.gap1, - styles.quickActionTooltipSubtitle, - theme.tooltipHighlightText, - translate, - ], - ); - const isInFocusMode = viewMode === CONST.OPTION_MODE.COMPACT; const sidebarInnerRowStyle = StyleSheet.flatten( isInFocusMode @@ -180,17 +163,16 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti needsOffscreenAlphaCompositing > @@ -204,6 +186,7 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti event?.preventDefault(); // Enable Composer to focus on clicking the same chat after opening the context menu. ReportActionComposeFocusManager.focus(); + hideProductTrainingTooltip(); onSelectRow(optionItem, popoverAnchor); }} onMouseDown={(event) => { diff --git a/src/components/MagicCodeInput.tsx b/src/components/MagicCodeInput.tsx index 4100b877e2da..951471fdf76c 100644 --- a/src/components/MagicCodeInput.tsx +++ b/src/components/MagicCodeInput.tsx @@ -114,36 +114,44 @@ function MagicCodeInput( ) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const inputRefs = useRef(); + const inputRef = useRef(); const [input, setInput] = useState(TEXT_INPUT_EMPTY_STATE); const [focusedIndex, setFocusedIndex] = useState(0); - const [editIndex, setEditIndex] = useState(0); + const editIndex = useRef(0); const [wasSubmitted, setWasSubmitted] = useState(false); const shouldFocusLast = useRef(false); const inputWidth = useRef(0); const lastFocusedIndex = useRef(0); const lastValue = useRef(TEXT_INPUT_EMPTY_STATE); + const valueRef = useRef(value); useEffect(() => { lastValue.current = input.length; }, [input]); + useEffect(() => { + // Note: there are circumstances where the value state isn't updated yet + // when e.g. onChangeText gets called the next time. In those cases its safer to access the value from a ref + // to not have outdated values. + valueRef.current = value; + }, [value]); + const blurMagicCodeInput = () => { - inputRefs.current?.blur(); + inputRef.current?.blur(); setFocusedIndex(undefined); }; const focusMagicCodeInput = () => { setFocusedIndex(0); lastFocusedIndex.current = 0; - setEditIndex(0); - inputRefs.current?.focus(); + editIndex.current = 0; + inputRef.current?.focus(); }; const setInputAndIndex = (index: number) => { setInput(TEXT_INPUT_EMPTY_STATE); setFocusedIndex(index); - setEditIndex(index); + editIndex.current = index; }; useImperativeHandle(ref, () => ({ @@ -151,7 +159,7 @@ function MagicCodeInput( focusMagicCodeInput(); }, focusLastSelected() { - inputRefs.current?.focus(); + inputRef.current?.focus(); }, resetFocus() { setInput(TEXT_INPUT_EMPTY_STATE); @@ -160,7 +168,7 @@ function MagicCodeInput( clear() { lastFocusedIndex.current = 0; setInputAndIndex(0); - inputRefs.current?.focus(); + inputRef.current?.focus(); onChangeTextProp(''); }, blur() { @@ -219,7 +227,7 @@ function MagicCodeInput( // TapGestureHandler works differently on mobile web and native app // On web gesture handler doesn't block interactions with textInput below so there is no need to run `focus()` manually if (!Browser.isMobileChrome() && !Browser.isMobileSafari()) { - inputRefs.current?.focus(); + inputRef.current?.focus(); } setInputAndIndex(index); lastFocusedIndex.current = index; @@ -231,6 +239,12 @@ function MagicCodeInput( * the focused input on the next empty one, if exists. * It handles both fast typing and only one digit at a time * in a specific position. + * + * Note: this works under the assumption that the backing text input will always have a cleared text, + * and entering text will exactly call onChangeText with one new character/digit. + * When the OS is inserting one time passwords for example it will call this method successively with one more digit each time. + * Thus, this method relies on an internal value ref to make sure to always use the latest value (as state updates are async, and + * might happen later than the next call to onChangeText). */ const onChangeText = (textValue?: string) => { if (!textValue?.length || !ValidationUtils.isNumeric(textValue)) { @@ -249,16 +263,17 @@ function MagicCodeInput( const numbersArr = addedValue .trim() .split('') - .slice(0, maxLength - editIndex); - const updatedFocusedIndex = Math.min(editIndex + (numbersArr.length - 1) + 1, maxLength - 1); + .slice(0, maxLength - editIndex.current); + const updatedFocusedIndex = Math.min(editIndex.current + (numbersArr.length - 1) + 1, maxLength - 1); - let numbers = decomposeString(value, maxLength); - numbers = [...numbers.slice(0, editIndex), ...numbersArr, ...numbers.slice(numbersArr.length + editIndex, maxLength)]; + let numbers = decomposeString(valueRef.current, maxLength); + numbers = [...numbers.slice(0, editIndex.current), ...numbersArr, ...numbers.slice(numbersArr.length + editIndex.current, maxLength)]; setInputAndIndex(updatedFocusedIndex); const finalInput = composeToString(numbers); onChangeTextProp(finalInput); + valueRef.current = finalInput; }; /** @@ -275,12 +290,13 @@ function MagicCodeInput( // If keyboard is disabled and no input is focused we need to remove // the last entered digit and focus on the correct input if (isDisableKeyboard && focusedIndex === undefined) { - const indexBeforeLastEditIndex = editIndex === 0 ? editIndex : editIndex - 1; + const curEditIndex = editIndex.current; + const indexBeforeLastEditIndex = curEditIndex === 0 ? curEditIndex : curEditIndex - 1; - const indexToFocus = numbers.at(editIndex) === CONST.MAGIC_CODE_EMPTY_CHAR ? indexBeforeLastEditIndex : editIndex; + const indexToFocus = numbers.at(curEditIndex) === CONST.MAGIC_CODE_EMPTY_CHAR ? indexBeforeLastEditIndex : curEditIndex; if (indexToFocus !== undefined) { lastFocusedIndex.current = indexToFocus; - inputRefs.current?.focus(); + inputRef.current?.focus(); } onChangeTextProp(value.substring(0, indexToFocus)); @@ -292,7 +308,7 @@ function MagicCodeInput( if (focusedIndex !== undefined && numbers?.at(focusedIndex) !== CONST.MAGIC_CODE_EMPTY_CHAR) { setInput(TEXT_INPUT_EMPTY_STATE); numbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, maxLength)]; - setEditIndex(focusedIndex); + editIndex.current = focusedIndex; onChangeTextProp(composeToString(numbers)); return; } @@ -318,17 +334,17 @@ function MagicCodeInput( if (newFocusedIndex !== undefined) { lastFocusedIndex.current = newFocusedIndex; - inputRefs.current?.focus(); + inputRef.current?.focus(); } } if (keyValue === 'ArrowLeft' && focusedIndex !== undefined) { const newFocusedIndex = Math.max(0, focusedIndex - 1); setInputAndIndex(newFocusedIndex); - inputRefs.current?.focus(); + inputRef.current?.focus(); } else if (keyValue === 'ArrowRight' && focusedIndex !== undefined) { const newFocusedIndex = Math.min(focusedIndex + 1, maxLength - 1); setInputAndIndex(newFocusedIndex); - inputRefs.current?.focus(); + inputRef.current?.focus(); } else if (keyValue === 'Enter') { // We should prevent users from submitting when it's offline. if (isOffline) { @@ -340,7 +356,7 @@ function MagicCodeInput( const newFocusedIndex = (event as unknown as KeyboardEvent).shiftKey ? focusedIndex - 1 : focusedIndex + 1; if (newFocusedIndex >= 0 && newFocusedIndex < maxLength) { setInputAndIndex(newFocusedIndex); - inputRefs.current?.focus(); + inputRef.current?.focus(); if (event?.preventDefault) { event.preventDefault(); } @@ -383,7 +399,7 @@ function MagicCodeInput( onLayout={(e) => { inputWidth.current = e.nativeEvent.layout.width; }} - ref={(inputRef) => (inputRefs.current = inputRef)} + ref={(newRef) => (inputRef.current = newRef)} autoFocus={autoFocus} inputMode="numeric" textContentType="oneTimeCode" diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 19703f7a3c92..8ae468262cca 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -340,14 +340,17 @@ type MenuItemBaseProps = { /** Should selected item be marked with checkmark */ shouldShowSelectedItemCheck?: boolean; - /** Handles what to do when hiding the tooltip */ - onHideTooltip?: () => void; - /** Should use auto width for the icon container. */ shouldIconUseAutoWidthStyle?: boolean; /** Should break word for room title */ shouldBreakWord?: boolean; + + /** Pressable component Test ID. Used to locate the component in tests. */ + pressableTestID?: string; + + /** Whether to teleport the portal to the modal layer */ + shouldTeleportPortalToModalLayer?: boolean; }; type MenuItemProps = (IconProps | AvatarProps | NoIcon) & MenuItemBaseProps; @@ -458,9 +461,10 @@ function MenuItem( renderTooltipContent, additionalIconStyles, shouldShowSelectedItemCheck = false, - onHideTooltip, shouldIconUseAutoWidthStyle = false, shouldBreakWord = false, + pressableTestID, + shouldTeleportPortalToModalLayer, }: MenuItemProps, ref: PressableRef, ) { @@ -596,8 +600,7 @@ function MenuItem( wrapperStyle={tooltipWrapperStyle} shiftHorizontal={tooltipShiftHorizontal} shiftVertical={tooltipShiftVertical} - shouldAutoDismiss - onHideTooltip={onHideTooltip} + shouldTeleportPortalToModalLayer={shouldTeleportPortalToModalLayer} > @@ -610,6 +613,7 @@ function MenuItem( wrapperStyle={outerWrapperStyle} activeOpacity={variables.pressDimValue} opacityAnimationDuration={0} + testID={pressableTestID} style={({pressed}) => [ containerStyle, diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index e1c5a7c48b86..9db744b49aa7 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -1,13 +1,14 @@ import {PortalHost} from '@gorhom/portal'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef} from 'react'; -import {View} from 'react-native'; +import {Animated, View} from 'react-native'; +import {useKeyboardAnimation} from 'react-native-keyboard-controller'; import ReactNativeModal from 'react-native-modal'; import ColorSchemeWrapper from '@components/ColorSchemeWrapper'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; -import useKeyboardState from '@hooks/useKeyboardState'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useStyledSafeAreaInsets from '@hooks/useStyledSafeAreaInsets'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -64,7 +65,6 @@ function BaseModal( // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply correct modal width // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); - const keyboardStateContextValue = useKeyboardState(); const safeAreaInsets = useSafeAreaInsets(); @@ -194,7 +194,7 @@ function BaseModal( safeAreaPaddingRight, shouldAddBottomSafeAreaMargin, shouldAddTopSafeAreaMargin, - shouldAddBottomSafeAreaPadding: (!avoidKeyboard || !keyboardStateContextValue?.isKeyboardShown) && shouldAddBottomSafeAreaPadding, + shouldAddBottomSafeAreaPadding, shouldAddTopSafeAreaPadding, modalContainerStyleMarginTop: modalContainerStyle.marginTop, modalContainerStyleMarginBottom: modalContainerStyle.marginBottom, @@ -214,6 +214,13 @@ function BaseModal( }), [isVisible, type], ); + const {height, progress} = useKeyboardAnimation(); + const {unmodifiedPaddings} = useStyledSafeAreaInsets(); + const removeSafeAreaPadding = progress.interpolate({ + inputRange: [0, 1], + outputRange: [0, unmodifiedPaddings.bottom], + extrapolate: 'clamp', + }); return ( @@ -260,7 +267,6 @@ function BaseModal( statusBarTranslucent={statusBarTranslucent} navigationBarTranslucent={navigationBarTranslucent} onLayout={onLayout} - avoidKeyboard={avoidKeyboard} customBackdrop={shouldUseCustomBackdrop ? : undefined} > - {children} - + diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx index 9ef33900bb00..717659c16fd3 100644 --- a/src/components/MoneyRequestAmountInput.tsx +++ b/src/components/MoneyRequestAmountInput.tsx @@ -12,6 +12,7 @@ import CONST from '@src/CONST'; import isTextInputFocused from './TextInput/BaseTextInput/isTextInputFocused'; import type {BaseTextInputRef} from './TextInput/BaseTextInput/types'; import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol'; +import type {TextInputWithCurrencySymbolProps} from './TextInputWithCurrencySymbol/types'; type CurrentMoney = {amount: string; currency: string}; @@ -91,7 +92,7 @@ type MoneyRequestAmountInputProps = { /** The width of inner content */ contentWidth?: number; -}; +} & Pick; type Selection = { start: number; @@ -126,6 +127,7 @@ function MoneyRequestAmountInput( hideFocusedState = true, shouldKeepUserInput = false, autoGrow = true, + autoGrowExtraSpace, contentWidth, ...props }: MoneyRequestAmountInputProps, @@ -289,6 +291,7 @@ function MoneyRequestAmountInput( return ( PolicyUtils.getTagLists(policyTags), [policyTags]); - const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest); + const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest) && !isPerDiemRequest; const previousTransactionAmount = usePrevious(transaction?.amount); const previousTransactionCurrency = usePrevious(transaction?.currency); @@ -428,7 +432,7 @@ function MoneyRequestConfirmationList({ text = translate('iou.trackExpense'); } else if (isTypeSplit && iouAmount === 0) { text = translate('iou.splitExpense'); - } else if ((receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) { + } else if ((receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute || isPerDiemRequest) { text = translate('iou.submitExpense'); if (iouAmount !== 0) { text = translate('iou.submitAmount', {amount: formattedAmount}); @@ -443,7 +447,20 @@ function MoneyRequestConfirmationList({ value: iouType, }, ]; - }, [isTypeTrackExpense, isTypeSplit, iouAmount, receiptPath, isTypeRequest, policy, isDistanceRequestWithPendingRoute, iouType, translate, formattedAmount, isTypeInvoice]); + }, [ + isTypeInvoice, + isTypeTrackExpense, + isTypeSplit, + iouAmount, + receiptPath, + isTypeRequest, + isDistanceRequestWithPendingRoute, + isPerDiemRequest, + iouType, + policy, + translate, + formattedAmount, + ]); const onSplitShareChange = useCallback( (accountID: number, value: number) => { @@ -762,6 +779,11 @@ function MoneyRequestConfirmationList({ return; } + if (isPerDiemRequest && (transaction.comment?.customUnit?.subRates ?? []).length === 0) { + setFormError('iou.error.invalidSubrateLength'); + return; + } + if (iouType !== CONST.IOU.TYPE.PAY) { // validate the amount for distance expenses const decimals = CurrencyUtils.getCurrencyDecimals(iouCurrencyCode); @@ -809,6 +831,7 @@ function MoneyRequestConfirmationList({ onSendMoney, iouCurrencyCode, isDistanceRequest, + isPerDiemRequest, isDistanceRequestWithPendingRoute, iouAmount, onConfirm, @@ -916,6 +939,7 @@ function MoneyRequestConfirmationList({ iouType={iouType} isCategoryRequired={isCategoryRequired} isDistanceRequest={isDistanceRequest} + isPerDiemRequest={isPerDiemRequest} isEditingSplitBill={isEditingSplitBill} isMerchantEmpty={isMerchantEmpty} isMerchantRequired={isMerchantRequired} @@ -937,6 +961,7 @@ function MoneyRequestConfirmationList({ shouldShowCategories={shouldShowCategories} shouldShowMerchant={shouldShowMerchant} shouldShowSmartScanFields={shouldShowSmartScanFields} + shouldShowAmountField={!isPerDiemRequest} shouldShowTax={shouldShowTax} transaction={transaction} transactionID={transactionID} diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 51cb2a6d6f39..644e00378f28 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -13,6 +13,7 @@ import * as CurrencyUtils from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as PerDiemRequestUtils from '@libs/PerDiemRequestUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; @@ -27,8 +28,10 @@ import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type {Unit} from '@src/types/onyx/Policy'; +import Badge from './Badge'; import ConfirmedRoute from './ConfirmedRoute'; import MentionReportContext from './HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; +import * as Expensicons from './Icon/Expensicons'; import MenuItem from './MenuItem'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import PDFThumbnail from './PDFThumbnail'; @@ -93,6 +96,9 @@ type MoneyRequestConfirmationListFooterProps = { /** Flag indicating if it is a distance request */ isDistanceRequest: boolean; + /** Flag indicating if it is a per diem request */ + isPerDiemRequest: boolean; + /** Flag indicating if it is editing a split bill */ isEditingSplitBill: boolean | undefined; @@ -156,6 +162,9 @@ type MoneyRequestConfirmationListFooterProps = { /** Flag indicating if the smart scan fields should be shown */ shouldShowSmartScanFields: boolean; + /** Flag indicating if the amount field should be shown */ + shouldShowAmountField?: boolean; + /** Flag indicating if the tax should be shown */ shouldShowTax: boolean; @@ -188,6 +197,7 @@ function MoneyRequestConfirmationListFooter({ iouType, isCategoryRequired, isDistanceRequest, + isPerDiemRequest, isEditingSplitBill, isMerchantEmpty, isMerchantRequired, @@ -209,6 +219,7 @@ function MoneyRequestConfirmationListFooter({ shouldShowCategories, shouldShowMerchant, shouldShowSmartScanFields, + shouldShowAmountField = true, shouldShowTax, transaction, transactionID, @@ -245,11 +256,11 @@ function MoneyRequestConfirmationListFooter({ // Determines whether the tax fields can be modified. // The tax fields can only be modified if the component is not in read-only mode // and it is not a distance request. - const canModifyTaxFields = !isReadOnly && !isDistanceRequest; + const canModifyTaxFields = !isReadOnly && !isDistanceRequest && !isPerDiemRequest; // A flag for showing the billable field const shouldShowBillable = policy?.disabledFields?.defaultBillable === false; // Do not hide fields in case of paying someone - const shouldShowAllFields = !!isDistanceRequest || shouldExpandFields || !shouldShowSmartScanFields || isTypeSend || !!isEditingSplitBill; + const shouldShowAllFields = !!isPerDiemRequest || !!isDistanceRequest || shouldExpandFields || !shouldShowSmartScanFields || isTypeSend || !!isEditingSplitBill; // Calculate the formatted tax amount based on the transaction's tax amount and the IOU currency code const taxAmount = TransactionUtils.getTaxAmount(transaction, false); const formattedTaxAmount = CurrencyUtils.convertToDisplayString(taxAmount, iouCurrencyCode); @@ -258,7 +269,9 @@ function MoneyRequestConfirmationListFooter({ // Determine if the merchant error should be displayed const shouldDisplayMerchantError = isMerchantRequired && (shouldDisplayFieldError || formError === 'iou.error.invalidMerchant') && isMerchantEmpty; // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") - const shouldShowReceiptEmptyState = iouType === CONST.IOU.TYPE.SUBMIT && PolicyUtils.isPaidGroupPolicy(policy); + const shouldShowReceiptEmptyState = iouType === CONST.IOU.TYPE.SUBMIT && PolicyUtils.isPaidGroupPolicy(policy) && !isPerDiemRequest; + // The per diem custom unit + const perDiemCustomUnit = PolicyUtils.getPerDiemCustomUnit(policy); const { image: receiptImage, thumbnail: receiptThumbnail, @@ -308,7 +321,7 @@ function MoneyRequestConfirmationListFooter({ errorText={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? translate('common.error.enterAmount') : ''} /> ), - shouldShow: shouldShowSmartScanFields, + shouldShow: shouldShowSmartScanFields && shouldShowAmountField, isSupplementary: false, }, { @@ -591,6 +604,65 @@ function MoneyRequestConfirmationListFooter({ }, ]; + const subRates = PerDiemRequestUtils.getSubratesFields(perDiemCustomUnit, transaction); + const shouldDisplaySubrateError = + isPerDiemRequest && (shouldDisplayFieldError || formError === 'iou.error.invalidSubrateLength') && (subRates.length === 0 || (subRates.length === 1 && !subRates.at(0))); + + const subRateFields = subRates.map((field, index) => ( + { + if (!transactionID) { + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SUBRATE_EDIT.getRoute(action, iouType, transactionID, reportID, index, Navigation.getActiveRouteWithoutParams())); + }} + disabled={didConfirm} + interactive={!isReadOnly} + brickRoadIndicator={index === 0 && shouldDisplaySubrateError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={index === 0 && shouldDisplaySubrateError ? translate('common.error.fieldRequired') : ''} + /> + )); + + const {firstDay, tripDays, lastDay} = PerDiemRequestUtils.getTimeDifferenceIntervals(transaction); + + const badgeElements = useMemo(() => { + const badges: React.JSX.Element[] = []; + if (firstDay) { + badges.push( + , + ); + } + if (tripDays) { + badges.push( + , + ); + } + if (lastDay) { + badges.push( + , + ); + } + return badges; + }, [firstDay, lastDay, translate, tripDays]); + const primaryFields: React.JSX.Element[] = []; const supplementaryFields: React.JSX.Element[] = []; @@ -703,6 +775,46 @@ function MoneyRequestConfirmationListFooter({ )} + {isPerDiemRequest && ( + <> + { + if (!transactionID) { + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DESTINATION_EDIT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + }} + disabled={didConfirm} + interactive={!isReadOnly} + /> + + { + if (!transactionID) { + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TIME_EDIT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + }} + disabled={didConfirm} + interactive={!isReadOnly} + numberOfLinesTitle={2} + /> + {badgeElements} + + {subRateFields} + + + )} {!isDistanceRequest && // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing (receiptImage || receiptThumbnail diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 7432c683e0a7..b8dc71aef515 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -130,6 +130,9 @@ type PopoverMenuProps = Partial & { /** Should we apply padding style in modal itself. If this value is false, we will handle it in ScreenWrapper */ shouldUseModalPaddingStyle?: boolean; + + /** Used to locate the component in the tests */ + testID?: string; }; const renderWithConditionalWrapper = (shouldUseScrollView: boolean, contentContainerStyle: StyleProp, children: ReactNode): React.JSX.Element => { @@ -174,6 +177,7 @@ function PopoverMenu({ shouldUseScrollView = false, shouldUpdateFocusedIndex = true, shouldUseModalPaddingStyle, + testID, }: PopoverMenuProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -261,6 +265,7 @@ function PopoverMenu({ selectItem(menuIndex)} focused={focusedIndex === menuIndex} @@ -357,6 +362,7 @@ function PopoverMenu({ restoreFocusType={restoreFocusType} innerContainerStyle={innerContainerStyle} shouldUseModalPaddingStyle={shouldUseModalPaddingStyle} + testID={testID} > diff --git a/src/components/ProductTrainingContext/TOOLTIPS.ts b/src/components/ProductTrainingContext/TOOLTIPS.ts new file mode 100644 index 000000000000..1e0fa28e87e4 --- /dev/null +++ b/src/components/ProductTrainingContext/TOOLTIPS.ts @@ -0,0 +1,119 @@ +import type {ValueOf} from 'type-fest'; +import {dismissProductTraining} from '@libs/actions/Welcome'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; + +const { + CONCEIRGE_LHN_GBR, + RENAME_SAVED_SEARCH, + WORKSAPCE_CHAT_CREATE, + QUICK_ACTION_BUTTON, + SEARCH_FILTER_BUTTON_TOOLTIP, + BOTTOM_NAV_INBOX_TOOLTIP, + LHN_WORKSPACE_CHAT_TOOLTIP, + GLOBAL_CREATE_TOOLTIP, +} = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES; + +type ProductTrainingTooltipName = ValueOf; + +type ShouldShowConditionProps = { + shouldUseNarrowLayout?: boolean; +}; + +type TooltipData = { + content: Array<{text: TranslationPaths; isBold: boolean}>; + onHideTooltip: () => void; + name: ProductTrainingTooltipName; + priority: number; + shouldShow: (props: ShouldShowConditionProps) => boolean; +}; + +const TOOLTIPS: Record = { + [CONCEIRGE_LHN_GBR]: { + content: [ + {text: 'productTrainingTooltip.conciergeLHNGBR.part1', isBold: false}, + {text: 'productTrainingTooltip.conciergeLHNGBR.part2', isBold: true}, + ], + onHideTooltip: () => dismissProductTraining(CONCEIRGE_LHN_GBR), + name: CONCEIRGE_LHN_GBR, + priority: 1300, + shouldShow: ({shouldUseNarrowLayout}) => !!shouldUseNarrowLayout, + }, + [RENAME_SAVED_SEARCH]: { + content: [ + {text: 'productTrainingTooltip.saveSearchTooltip.part1', isBold: true}, + {text: 'productTrainingTooltip.saveSearchTooltip.part2', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(RENAME_SAVED_SEARCH), + name: RENAME_SAVED_SEARCH, + priority: 1250, + shouldShow: ({shouldUseNarrowLayout}) => !shouldUseNarrowLayout, + }, + [GLOBAL_CREATE_TOOLTIP]: { + content: [ + {text: 'productTrainingTooltip.globalCreateTooltip.part1', isBold: true}, + {text: 'productTrainingTooltip.globalCreateTooltip.part2', isBold: false}, + {text: 'productTrainingTooltip.globalCreateTooltip.part3', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(GLOBAL_CREATE_TOOLTIP), + name: GLOBAL_CREATE_TOOLTIP, + priority: 1200, + shouldShow: () => true, + }, + [QUICK_ACTION_BUTTON]: { + content: [ + {text: 'productTrainingTooltip.quickActionButton.part1', isBold: true}, + {text: 'productTrainingTooltip.quickActionButton.part2', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(QUICK_ACTION_BUTTON), + name: QUICK_ACTION_BUTTON, + priority: 1150, + shouldShow: () => true, + }, + [WORKSAPCE_CHAT_CREATE]: { + content: [ + {text: 'productTrainingTooltip.workspaceChatCreate.part1', isBold: false}, + {text: 'productTrainingTooltip.workspaceChatCreate.part2', isBold: true}, + {text: 'productTrainingTooltip.workspaceChatCreate.part3', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(WORKSAPCE_CHAT_CREATE), + name: WORKSAPCE_CHAT_CREATE, + priority: 1100, + shouldShow: () => true, + }, + [SEARCH_FILTER_BUTTON_TOOLTIP]: { + content: [ + {text: 'productTrainingTooltip.searchFilterButtonTooltip.part1', isBold: true}, + {text: 'productTrainingTooltip.searchFilterButtonTooltip.part2', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(SEARCH_FILTER_BUTTON_TOOLTIP), + name: SEARCH_FILTER_BUTTON_TOOLTIP, + priority: 1000, + shouldShow: () => true, + }, + [BOTTOM_NAV_INBOX_TOOLTIP]: { + content: [ + {text: 'productTrainingTooltip.bottomNavInboxTooltip.part1', isBold: true}, + {text: 'productTrainingTooltip.bottomNavInboxTooltip.part2', isBold: false}, + {text: 'productTrainingTooltip.bottomNavInboxTooltip.part3', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(BOTTOM_NAV_INBOX_TOOLTIP), + name: BOTTOM_NAV_INBOX_TOOLTIP, + priority: 900, + shouldShow: () => true, + }, + [LHN_WORKSPACE_CHAT_TOOLTIP]: { + content: [ + {text: 'productTrainingTooltip.workspaceChatTooltip.part1', isBold: true}, + {text: 'productTrainingTooltip.workspaceChatTooltip.part2', isBold: false}, + {text: 'productTrainingTooltip.workspaceChatTooltip.part3', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(LHN_WORKSPACE_CHAT_TOOLTIP), + name: LHN_WORKSPACE_CHAT_TOOLTIP, + priority: 800, + shouldShow: () => true, + }, +}; + +export default TOOLTIPS; +export type {ProductTrainingTooltipName}; diff --git a/src/components/ProductTrainingContext/index.tsx b/src/components/ProductTrainingContext/index.tsx new file mode 100644 index 000000000000..a7806574126d --- /dev/null +++ b/src/components/ProductTrainingContext/index.tsx @@ -0,0 +1,237 @@ +import React, {createContext, useCallback, useContext, useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {hasCompletedGuidedSetupFlowSelector} from '@libs/onboardingSelectors'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import type {ProductTrainingTooltipName} from './TOOLTIPS'; +import TOOLTIPS from './TOOLTIPS'; + +type ProductTrainingContextType = { + shouldRenderTooltip: (tooltipName: ProductTrainingTooltipName) => boolean; + registerTooltip: (tooltipName: ProductTrainingTooltipName) => void; + unregisterTooltip: (tooltipName: ProductTrainingTooltipName) => void; +}; + +const ProductTrainingContext = createContext({ + shouldRenderTooltip: () => false, + registerTooltip: () => {}, + unregisterTooltip: () => {}, +}); + +function ProductTrainingContextProvider({children}: ChildrenProps) { + const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {initialValue: true}); + const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRYNEWDOT); + const hasBeenAddedToNudgeMigration = !!tryNewDot?.nudgeMigration?.timestamp; + const [isOnboardingCompleted = true, isOnboardingCompletedMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { + selector: hasCompletedGuidedSetupFlowSelector, + }); + + const [dismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const [modal] = useOnyx(ONYXKEYS.MODAL); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const isModalVisible = modal?.isVisible || modal?.willAlertModalBecomeVisible; + + const [activeTooltips, setActiveTooltips] = useState>(new Set()); + + const unregisterTooltip = useCallback( + (tooltipName: ProductTrainingTooltipName) => { + setActiveTooltips((prev) => { + const next = new Set(prev); + next.delete(tooltipName); + return next; + }); + }, + [setActiveTooltips], + ); + + const determineVisibleTooltip = useCallback(() => { + if (activeTooltips.size === 0) { + return null; + } + + const sortedTooltips = Array.from(activeTooltips) + .map((name) => ({ + name, + priority: TOOLTIPS[name]?.priority ?? 0, + })) + .sort((a, b) => b.priority - a.priority); + + const highestPriorityTooltip = sortedTooltips.at(0); + + if (!highestPriorityTooltip) { + return null; + } + + return highestPriorityTooltip.name; + }, [activeTooltips]); + + const shouldTooltipBeVisible = useCallback( + (tooltipName: ProductTrainingTooltipName) => { + if (isLoadingOnyxValue(isOnboardingCompletedMetadata) || isLoadingApp) { + return false; + } + + const isDismissed = !!dismissedProductTraining?.[tooltipName]; + + if (isDismissed) { + return false; + } + const tooltipConfig = TOOLTIPS[tooltipName]; + + // if hasBeenAddedToNudgeMigration is true, and welcome modal is not dismissed, don't show tooltip + if (hasBeenAddedToNudgeMigration && !dismissedProductTraining?.[CONST.MIGRATED_USER_WELCOME_MODAL]) { + return false; + } + if (isOnboardingCompleted === false) { + return false; + } + + // We need to make an exception for the QAB tooltip because it is shown in a modal, otherwise it would be hidden if a modal is visible + if (tooltipName !== CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.QUICK_ACTION_BUTTON && isModalVisible) { + return false; + } + + return tooltipConfig.shouldShow({ + shouldUseNarrowLayout, + }); + }, + [dismissedProductTraining, hasBeenAddedToNudgeMigration, isOnboardingCompleted, isOnboardingCompletedMetadata, shouldUseNarrowLayout, isModalVisible, isLoadingApp], + ); + + const registerTooltip = useCallback( + (tooltipName: ProductTrainingTooltipName) => { + const shouldRegister = shouldTooltipBeVisible(tooltipName); + if (!shouldRegister) { + return; + } + setActiveTooltips((prev) => new Set([...prev, tooltipName])); + }, + [shouldTooltipBeVisible], + ); + + const shouldRenderTooltip = useCallback( + (tooltipName: ProductTrainingTooltipName) => { + // First check base conditions + const shouldShow = shouldTooltipBeVisible(tooltipName); + if (!shouldShow) { + return false; + } + const visibleTooltip = determineVisibleTooltip(); + + // If this is the highest priority visible tooltip, show it + if (tooltipName === visibleTooltip) { + return true; + } + + return false; + }, + [shouldTooltipBeVisible, determineVisibleTooltip], + ); + + const contextValue = useMemo( + () => ({ + shouldRenderTooltip, + registerTooltip, + unregisterTooltip, + }), + [shouldRenderTooltip, registerTooltip, unregisterTooltip], + ); + + return {children}; +} + +const useProductTrainingContext = (tooltipName: ProductTrainingTooltipName, shouldShow = true) => { + const context = useContext(ProductTrainingContext); + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + + if (!context) { + throw new Error('useProductTourContext must be used within a ProductTourProvider'); + } + + const {shouldRenderTooltip, registerTooltip, unregisterTooltip} = context; + + useEffect(() => { + if (shouldShow) { + registerTooltip(tooltipName); + } + return () => { + unregisterTooltip(tooltipName); + }; + }, [tooltipName, registerTooltip, unregisterTooltip, shouldShow]); + + const renderProductTrainingTooltip = useCallback(() => { + const tooltip = TOOLTIPS[tooltipName]; + return ( + + + + {tooltip.content.map(({text, isBold}) => { + const translatedText = translate(text); + return ( + + {translatedText} + + ); + })} + + + ); + }, [ + styles.alignItemsCenter, + styles.flexRow, + styles.flexWrap, + styles.gap3, + styles.justifyContentCenter, + styles.mw100, + styles.p2, + styles.productTrainingTooltipText, + styles.textAlignCenter, + styles.textBold, + styles.textWrap, + theme.tooltipHighlightText, + tooltipName, + translate, + ]); + + const shouldShowProductTrainingTooltip = useMemo(() => { + return shouldShow && shouldRenderTooltip(tooltipName); + }, [shouldRenderTooltip, tooltipName, shouldShow]); + + const hideProductTrainingTooltip = useCallback(() => { + if (!shouldShowProductTrainingTooltip) { + return; + } + const tooltip = TOOLTIPS[tooltipName]; + tooltip.onHideTooltip(); + unregisterTooltip(tooltipName); + }, [tooltipName, shouldShowProductTrainingTooltip, unregisterTooltip]); + + return { + renderProductTrainingTooltip, + hideProductTrainingTooltip, + shouldShowProductTrainingTooltip, + }; +}; + +export {ProductTrainingContextProvider, useProductTrainingContext}; diff --git a/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx b/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx index a288396ad204..5cdbd04798cc 100644 --- a/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx +++ b/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx @@ -1,7 +1,6 @@ import React, {useCallback, useEffect, useState} from 'react'; import DeviceInfo from 'react-native-device-info'; -import {withOnyx} from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import {startProfiling, stopProfiling} from 'react-native-release-profiler'; import Button from '@components/Button'; import Switch from '@components/Switch'; @@ -13,6 +12,7 @@ import toggleProfileTool from '@libs/actions/ProfilingTool'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; import {Memoize} from '@libs/memoize'; +import Performance from '@libs/Performance'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -20,10 +20,6 @@ import pkg from '../../../package.json'; import RNFS from './RNFS'; import Share from './Share'; -type BaseProfilingToolMenuOnyxProps = { - isProfilingInProgress: OnyxEntry; -}; - type BaseProfilingToolMenuProps = { /** Path used to save the file */ pathToBeUsed: string; @@ -31,7 +27,7 @@ type BaseProfilingToolMenuProps = { displayPath: string; /** Whether to show the share button */ showShareButton?: boolean; -} & BaseProfilingToolMenuOnyxProps; +}; function formatBytes(bytes: number, decimals = 2) { if (!+bytes) { @@ -50,7 +46,8 @@ function formatBytes(bytes: number, decimals = 2) { // WARNING: When changing this name make sure that the "scripts/symbolicate-profile.ts" script is still working! const newFileName = `Profile_trace_for_${pkg.version}.cpuprofile`; -function BaseProfilingToolMenu({isProfilingInProgress = false, showShareButton = false, pathToBeUsed, displayPath}: BaseProfilingToolMenuProps) { +function BaseProfilingToolMenu({showShareButton = false, pathToBeUsed, displayPath}: BaseProfilingToolMenuProps) { + const [isProfilingInProgress] = useOnyx(ONYXKEYS.APP_PROFILING_IN_PROGRESS); const styles = useThemeStyles(); const [filePath, setFilePath] = useState(''); const [sharePath, setSharePath] = useState(''); @@ -69,12 +66,14 @@ function BaseProfilingToolMenu({isProfilingInProgress = false, showShareButton = setTotalMemory(amountOfTotalMemory); setUsedMemory(amountOfUsedMemory); setMemoizeStats(Memoize.stopMonitoring()); + Performance.disableMonitoring(); }, []); const onToggleProfiling = useCallback(() => { const shouldProfiling = !isProfilingInProgress; if (shouldProfiling) { Memoize.startMonitoring(); + Performance.enableMonitoring(); startProfiling(); } else { stop(); @@ -94,6 +93,7 @@ function BaseProfilingToolMenu({isProfilingInProgress = false, showShareButton = totalMemory: formatBytes(totalMemory, 2), usedMemory: formatBytes(usedMemory, 2), memoizeStats, + performance: Performance.getPerformanceMeasures(), }), [memoizeStats, totalMemory, usedMemory], ); @@ -183,8 +183,4 @@ function BaseProfilingToolMenu({isProfilingInProgress = false, showShareButton = BaseProfilingToolMenu.displayName = 'BaseProfilingToolMenu'; -export default withOnyx({ - isProfilingInProgress: { - key: ONYXKEYS.APP_PROFILING_IN_PROGRESS, - }, -})(BaseProfilingToolMenu); +export default BaseProfilingToolMenu; diff --git a/src/components/ReportActionItem/ExportWithDropdownMenu.tsx b/src/components/ReportActionItem/ExportWithDropdownMenu.tsx index 50e753b25755..b676c581da98 100644 --- a/src/components/ReportActionItem/ExportWithDropdownMenu.tsx +++ b/src/components/ReportActionItem/ExportWithDropdownMenu.tsx @@ -1,4 +1,6 @@ import React, {useCallback, useMemo, useState} from 'react'; +import {StyleSheet} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; @@ -27,6 +29,8 @@ type ExportWithDropdownMenuProps = { connectionName: ConnectionName; dropdownAnchorAlignment?: AnchorAlignment; + + wrapperStyle?: StyleProp; }; function ExportWithDropdownMenu({ @@ -37,6 +41,7 @@ function ExportWithDropdownMenu({ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, }, + wrapperStyle, }: ExportWithDropdownMenuProps) { const reportID = report?.reportID; const styles = useThemeStyles(); @@ -50,6 +55,7 @@ function ExportWithDropdownMenu({ const canBeExported = ReportUtils.canBeExported(report); const isExported = ReportUtils.isExported(reportActions); const hasIntegrationAutoSync = PolicyUtils.hasIntegrationAutoSync(policy, connectionName); + const flattenedWrapperStyle = StyleSheet.flatten([styles.flex1, wrapperStyle]); const dropdownOptions: Array> = useMemo(() => { const optionTemplate = { @@ -72,7 +78,7 @@ function ExportWithDropdownMenu({ ...optionTemplate, }, ]; - const exportMethod = exportMethods?.[report?.policyID ?? ''] ?? null; + const exportMethod = report?.policyID ? exportMethods?.[report.policyID] : null; if (exportMethod) { options.sort((method) => (method.value === exportMethod ? -1 : 0)); } @@ -124,7 +130,7 @@ function ExportWithDropdownMenu({ onOptionSelected={({value}) => savePreferredExportMethod(value)} options={dropdownOptions} style={[shouldUseNarrowLayout && styles.flexGrow1]} - wrapperStyle={styles.flex1} + wrapperStyle={flattenedWrapperStyle} buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} /> >(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID || '-1'}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID || CONST.DEFAULT_NUMBER_ID}`); const [session] = useOnyx(ONYXKEYS.SESSION); - const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID || '-1'}`); + const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID || CONST.DEFAULT_NUMBER_ID}`); const policy = PolicyUtils.getPolicy(iouReport?.policyID); const isMoneyRequestAction = ReportActionsUtils.isMoneyRequestAction(action); - const transactionID = isMoneyRequestAction ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID : '-1'; + const transactionID = isMoneyRequestAction ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID : undefined; const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const sessionAccountID = session?.accountID; - const managerID = iouReport?.managerID ?? -1; - const ownerAccountID = iouReport?.ownerAccountID ?? -1; + const managerID = iouReport?.managerID ?? CONST.DEFAULT_NUMBER_ID; + const ownerAccountID = iouReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID; const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport); const participantAccountIDs = @@ -117,9 +117,9 @@ function MoneyRequestPreviewContent({ const isOnHold = TransactionUtils.isOnHold(transaction); const isSettlementOrApprovalPartial = !!iouReport?.pendingFields?.partial; const isPartialHold = isSettlementOrApprovalPartial && isOnHold; - const hasViolations = TransactionUtils.hasViolation(transaction?.transactionID ?? '-1', transactionViolations, true); - const hasNoticeTypeViolations = TransactionUtils.hasNoticeTypeViolation(transaction?.transactionID ?? '-1', transactionViolations, true) && ReportUtils.isPaidGroupPolicy(iouReport); - const hasWarningTypeViolations = TransactionUtils.hasWarningTypeViolation(transaction?.transactionID ?? '-1', transactionViolations, true); + const hasViolations = TransactionUtils.hasViolation(transaction?.transactionID, transactionViolations, true); + const hasNoticeTypeViolations = TransactionUtils.hasNoticeTypeViolation(transaction?.transactionID, transactionViolations, true) && ReportUtils.isPaidGroupPolicy(iouReport); + const hasWarningTypeViolations = TransactionUtils.hasWarningTypeViolation(transaction?.transactionID, transactionViolations, true); const hasFieldErrors = TransactionUtils.hasMissingSmartscanFields(transaction); const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction); const isFetchingWaypointsFromServer = TransactionUtils.isFetchingWaypointsFromServer(transaction); @@ -155,8 +155,8 @@ function MoneyRequestPreviewContent({ const shouldShowHoldMessage = !(isSettled && !isSettlementOrApprovalPartial) && !!transaction?.comment?.hold; const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params?.threadReportID}`); - const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '', report?.parentReportActionID ?? ''); - const reviewingTransactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID ?? '-1' : '-1'; + const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID, report?.parentReportActionID); + const reviewingTransactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID : undefined; /* Show the merchant for IOUs and expenses only if: @@ -253,10 +253,10 @@ function MoneyRequestPreviewContent({ if (TransactionUtils.isPending(transaction)) { return {shouldShow: true, messageIcon: Expensicons.CreditCardHourglass, messageDescription: translate('iou.transactionPending')}; } - if (TransactionUtils.shouldShowBrokenConnectionViolation(transaction?.transactionID ?? '-1', iouReport, policy)) { + if (TransactionUtils.shouldShowBrokenConnectionViolation(transaction?.transactionID, iouReport, policy)) { return {shouldShow: true, messageIcon: Expensicons.Hourglass, messageDescription: translate('violations.brokenConnection530Error')}; } - if (TransactionUtils.hasPendingUI(transaction, TransactionUtils.getTransactionViolations(transaction?.transactionID ?? '-1', transactionViolations))) { + if (TransactionUtils.hasPendingUI(transaction, TransactionUtils.getTransactionViolations(transaction?.transactionID, transactionViolations))) { return {shouldShow: true, messageIcon: Expensicons.Hourglass, messageDescription: translate('iou.pendingMatchWithCreditCard')}; } return {shouldShow: false}; @@ -266,7 +266,7 @@ function MoneyRequestPreviewContent({ const getDisplayAmountText = (): string => { if (isScanning) { - return translate('iou.receiptScanning'); + return translate('iou.receiptScanning', {count: 1}); } if (isFetchingWaypointsFromServer && !requestAmount) { @@ -301,12 +301,8 @@ function MoneyRequestPreviewContent({ // Clear the draft before selecting a different expense to prevent merging fields from the previous expense // (e.g., category, tag, tax) that may be not enabled/available in the new expense's policy. Transaction.abandonReviewDuplicateTransactions(); - const comparisonResult = TransactionUtils.compareDuplicateTransactionFields( - reviewingTransactionID, - transaction?.reportID ?? '', - transaction?.transactionID ?? reviewingTransactionID, - ); - Transaction.setReviewDuplicatesKey({...comparisonResult.keep, duplicates, transactionID: transaction?.transactionID ?? '', reportID: transaction?.reportID}); + const comparisonResult = TransactionUtils.compareDuplicateTransactionFields(reviewingTransactionID, transaction?.reportID, transaction?.transactionID ?? reviewingTransactionID); + Transaction.setReviewDuplicatesKey({...comparisonResult.keep, duplicates, transactionID: transaction?.transactionID, reportID: transaction?.reportID}); if ('merchant' in comparisonResult.change) { Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE.getRoute(route.params?.threadReportID, backTo)); @@ -349,11 +345,13 @@ function MoneyRequestPreviewContent({ !onPreviewPressed ? [styles.moneyRequestPreviewBox, containerStyles] : {}, ]} > - + {!isDeleted && ( + + )} {isEmptyObject(transaction) && !ReportActionsUtils.isMessageDeleted(action) && action.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? ( ) : ( diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 79497e5fab88..ea8f033a3688 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -103,7 +103,7 @@ function ReportPreview({ const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET); const [invoiceReceiverPolicy] = useOnyx( - `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : -1}`, + `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : CONST.DEFAULT_NUMBER_ID}`, ); const theme = useTheme(); const styles = useThemeStyles(); @@ -144,10 +144,10 @@ function ReportPreview({ const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(iouReport); const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = ReportUtils.getNonHeldAndFullAmount(iouReport, shouldShowPayButton); - const hasOnlyHeldExpenses = ReportUtils.hasOnlyHeldExpenses(iouReport?.reportID ?? ''); - const hasHeldExpenses = ReportUtils.hasHeldExpenses(iouReport?.reportID ?? ''); + const hasOnlyHeldExpenses = ReportUtils.hasOnlyHeldExpenses(iouReport?.reportID); + const hasHeldExpenses = ReportUtils.hasHeldExpenses(iouReport?.reportID); - const managerID = iouReport?.managerID ?? action.childManagerAccountID ?? 0; + const managerID = iouReport?.managerID ?? action.childManagerAccountID ?? CONST.DEFAULT_NUMBER_ID; const {totalDisplaySpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(iouReport); const iouSettled = ReportUtils.isSettled(iouReportID) || action?.childStatusNum === CONST.REPORT.STATUS_NUM.REIMBURSED; @@ -189,9 +189,8 @@ function ReportPreview({ const lastThreeReceipts = lastThreeTransactions.map((transaction) => ({...ReceiptUtils.getThumbnailAndImageURIs(transaction), transaction})); const showRTERViolationMessage = numberOfRequests === 1 && - TransactionUtils.hasPendingUI(allTransactions.at(0), TransactionUtils.getTransactionViolations(allTransactions.at(0)?.transactionID ?? '-1', transactionViolations)); - const shouldShowBrokenConnectionViolation = - numberOfRequests === 1 && TransactionUtils.shouldShowBrokenConnectionViolation(allTransactions.at(0)?.transactionID ?? '-1', iouReport, policy); + TransactionUtils.hasPendingUI(allTransactions.at(0), TransactionUtils.getTransactionViolations(allTransactions.at(0)?.transactionID, transactionViolations)); + const shouldShowBrokenConnectionViolation = numberOfRequests === 1 && TransactionUtils.shouldShowBrokenConnectionViolation(allTransactions.at(0)?.transactionID, iouReport, policy); let formattedMerchant = numberOfRequests === 1 ? TransactionUtils.getMerchant(allTransactions.at(0)) : null; const formattedDescription = numberOfRequests === 1 ? TransactionUtils.getDescription(allTransactions.at(0)) : null; @@ -287,7 +286,7 @@ function ReportPreview({ return CurrencyUtils.convertToDisplayString(totalDisplaySpend, iouReport?.currency); } if (isScanning) { - return translate('iou.receiptScanning'); + return translate('iou.receiptScanning', {count: numberOfScanningReceipts}); } if (hasOnlyTransactionsWithPendingRoutes) { return translate('iou.fieldPending'); @@ -500,11 +499,13 @@ function ReportPreview({ accessibilityLabel={translate('iou.viewDetails')} > - + {lastThreeReceipts.length > 0 && ( + + )} @@ -606,6 +607,7 @@ function ReportPreview({ policy={policy} report={iouReport} connectionName={connectedIntegration} + wrapperStyle={styles.flexReset} dropdownAnchorAlignment={{ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, diff --git a/src/components/ReportActionItem/TaskPreview.tsx b/src/components/ReportActionItem/TaskPreview.tsx index f6f436cbd51e..2ea295d16143 100644 --- a/src/components/ReportActionItem/TaskPreview.tsx +++ b/src/components/ReportActionItem/TaskPreview.tsx @@ -75,7 +75,8 @@ function TaskPreview({taskReportID, action, contextMenuAnchor, chatReportID, che ? taskReport?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && taskReport.statusNum === CONST.REPORT.STATUS_NUM.APPROVED : action?.childStateNum === CONST.REPORT.STATE_NUM.APPROVED && action?.childStatusNum === CONST.REPORT.STATUS_NUM.APPROVED; const taskTitle = Str.htmlEncode(TaskUtils.getTaskTitleFromReport(taskReport, action?.childReportName ?? '')); - const taskAssigneeAccountID = Task.getTaskAssigneeAccountID(taskReport) ?? action?.childManagerAccountID ?? -1; + const taskAssigneeAccountID = Task.getTaskAssigneeAccountID(taskReport) ?? action?.childManagerAccountID ?? CONST.DEFAULT_NUMBER_ID; + const taskOwnerAccountID = taskReport?.ownerAccountID ?? action?.actorAccountID ?? CONST.DEFAULT_NUMBER_ID; const hasAssignee = taskAssigneeAccountID > 0; const personalDetails = usePersonalDetails(); const avatar = personalDetails?.[taskAssigneeAccountID]?.avatar ?? Expensicons.FallbackAvatar; @@ -106,12 +107,12 @@ function TaskPreview({taskReportID, action, contextMenuAnchor, chatReportID, che { if (isTaskCompleted) { - Task.reopenTask(taskReport); + Task.reopenTask(taskReport, taskReportID); } else { - Task.completeTask(taskReport); + Task.completeTask(taskReport, taskReportID); } })} accessibilityLabel={translate('task.task')} diff --git a/src/components/ReportActionItem/TaskView.tsx b/src/components/ReportActionItem/TaskView.tsx index 2b0dc9387927..7901426b33e0 100644 --- a/src/components/ReportActionItem/TaskView.tsx +++ b/src/components/ReportActionItem/TaskView.tsx @@ -10,8 +10,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxProvider'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; import Text from '@components/Text'; -import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; -import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -27,27 +26,28 @@ import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {Report} from '@src/types/onyx'; -type TaskViewProps = WithCurrentUserPersonalDetailsProps & { +type TaskViewProps = { /** The report currently being looked at */ report: Report; }; -function TaskView({report, ...props}: TaskViewProps) { +function TaskView({report}: TaskViewProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const personalDetails = usePersonalDetails(); useEffect(() => { Task.setTaskReport(report); }, [report]); - const personalDetails = usePersonalDetails(); const taskTitle = convertToLTR(report.reportName ?? ''); const assigneeTooltipDetails = ReportUtils.getDisplayNamesWithTooltips( OptionsListUtils.getPersonalDetailsForAccountIDs(report.managerID ? [report.managerID] : [], personalDetails), false, ); - const isCompleted = ReportUtils.isCompletedTaskReport(report); const isOpen = ReportUtils.isOpenTaskReport(report); - const canModifyTask = Task.canModifyTask(report, props.currentUserPersonalDetails.accountID); - const canActionTask = Task.canActionTask(report, props.currentUserPersonalDetails.accountID); + const isCompleted = ReportUtils.isCompletedTaskReport(report); + const canModifyTask = Task.canModifyTask(report, currentUserPersonalDetails.accountID); + const canActionTask = Task.canActionTask(report, currentUserPersonalDetails.accountID); const disableState = !canModifyTask; const isDisableInteractive = !canModifyTask || !isOpen; const {translate} = useLocalize(); @@ -77,10 +77,10 @@ function TaskView({report, ...props}: TaskViewProps) { styles.ph5, styles.pv2, StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed, false, disableState, !isDisableInteractive), true), - isDisableInteractive && !disableState && styles.cursorDefault, + isDisableInteractive && styles.cursorDefault, ]} - disabled={disableState} accessibilityLabel={taskTitle || translate('task.task')} + disabled={isDisableInteractive} > {({pressed}) => ( @@ -104,7 +104,7 @@ function TaskView({report, ...props}: TaskViewProps) { containerBorderRadius={8} caretSize={16} accessibilityLabel={taskTitle || translate('task.task')} - disabled={!canModifyTask || !canActionTask} + disabled={!canActionTask} /> - {isOpen && ( + {!isDisableInteractive && ( Navigation.navigate(ROUTES.REPORT_DESCRIPTION.getRoute(report.reportID, Navigation.getReportRHPActiveRoute()))} - shouldShowRightIcon={isOpen} + shouldShowRightIcon={!isDisableInteractive} disabled={disableState} wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} shouldGreyOutWhenDisabled={false} numberOfLinesTitle={0} interactive={!isDisableInteractive} + shouldUseDefaultCursorWhenDisabled /> @@ -153,23 +154,25 @@ function TaskView({report, ...props}: TaskViewProps) { avatarSize={CONST.AVATAR_SIZE.SMALLER} titleStyle={styles.assigneeTextStyle} onPress={() => Navigation.navigate(ROUTES.TASK_ASSIGNEE.getRoute(report.reportID, Navigation.getReportRHPActiveRoute()))} - shouldShowRightIcon={isOpen} + shouldShowRightIcon={!isDisableInteractive} disabled={disableState} wrapperStyle={[styles.pv2]} isSmallAvatarSubscriptMenu shouldGreyOutWhenDisabled={false} interactive={!isDisableInteractive} titleWithTooltips={assigneeTooltipDetails} + shouldUseDefaultCursorWhenDisabled /> ) : ( Navigation.navigate(ROUTES.TASK_ASSIGNEE.getRoute(report.reportID, Navigation.getReportRHPActiveRoute()))} - shouldShowRightIcon={isOpen} + shouldShowRightIcon={!isDisableInteractive} disabled={disableState} wrapperStyle={[styles.pv2]} shouldGreyOutWhenDisabled={false} interactive={!isDisableInteractive} + shouldUseDefaultCursorWhenDisabled /> )} @@ -180,4 +183,4 @@ function TaskView({report, ...props}: TaskViewProps) { TaskView.displayName = 'TaskView'; -export default withCurrentUserPersonalDetails(TaskView); +export default TaskView; diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index bb20b4abae11..464509b0a947 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -7,7 +7,6 @@ import {PickerAvoidingView} from 'react-native-picker-select'; import type {EdgeInsets} from 'react-native-safe-area-context'; import useEnvironment from '@hooks/useEnvironment'; import useInitialDimensions from '@hooks/useInitialWindowDimensions'; -import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyledSafeAreaInsets from '@hooks/useStyledSafeAreaInsets'; import useTackInputFocus from '@hooks/useTackInputFocus'; @@ -158,7 +157,6 @@ function ScreenWrapper( const {initialHeight} = useInitialDimensions(); const styles = useThemeStyles(); const {isDevelopment} = useEnvironment(); - const {isOffline} = useNetwork(); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); const maxHeight = shouldEnableMaxHeight ? windowHeight : undefined; const minHeight = shouldEnableMinHeight && !Browser.isSafari() ? initialHeight : undefined; @@ -244,18 +242,17 @@ function ScreenWrapper( } // We always need the safe area padding bottom if we're showing the offline indicator since it is bottom-docked. - const isSafeAreaBottomPaddingApplied = includeSafeAreaPaddingBottom || (isOffline && shouldShowOfflineIndicator); - if (isSafeAreaBottomPaddingApplied) { + if (includeSafeAreaPaddingBottom) { paddingStyle.paddingBottom = paddingBottom; } - if (isSafeAreaBottomPaddingApplied && ignoreInsetsConsumption) { + if (includeSafeAreaPaddingBottom && ignoreInsetsConsumption) { paddingStyle.paddingBottom = unmodifiedPaddings.bottom; } const isAvoidingViewportScroll = useTackInputFocus(isFocused && shouldEnableMaxHeight && shouldAvoidScrollOnVirtualViewport && Browser.isMobileWebKit()); const contextValue = useMemo( - () => ({didScreenTransitionEnd, isSafeAreaTopPaddingApplied, isSafeAreaBottomPaddingApplied}), - [didScreenTransitionEnd, isSafeAreaBottomPaddingApplied, isSafeAreaTopPaddingApplied], + () => ({didScreenTransitionEnd, isSafeAreaTopPaddingApplied, isSafeAreaBottomPaddingApplied: includeSafeAreaPaddingBottom}), + [didScreenTransitionEnd, includeSafeAreaPaddingBottom, isSafeAreaTopPaddingApplied], ); return ( @@ -297,7 +294,14 @@ function ScreenWrapper( } {isSmallScreenWidth && shouldShowOfflineIndicator && ( <> - + {/* Since import state is tightly coupled to the offline state, it is safe to display it when showing offline indicator */} diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index a78845f126d2..fe7b49314ae0 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -1,4 +1,5 @@ -import React, {useMemo, useState} from 'react'; +import {useFocusEffect} from '@react-navigation/native'; +import React, {useCallback, useMemo, useState} from 'react'; import {InteractionManager, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -8,6 +9,8 @@ import ConfirmModal from '@components/ConfirmModal'; import DecisionModal from '@components/DecisionModal'; import * as Expensicons from '@components/Icon/Expensicons'; import {usePersonalDetails} from '@components/OnyxProvider'; +import {useProductTrainingContext} from '@components/ProductTrainingContext'; +import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -56,8 +59,24 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); + const [isScreenFocused, setIsScreenFocused] = useState(false); + + const {renderProductTrainingTooltip, shouldShowProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext( + CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.SEARCH_FILTER_BUTTON_TOOLTIP, + isScreenFocused, + ); + const {status, hash} = queryJSON; + useFocusEffect( + useCallback(() => { + setIsScreenFocused(true); + return () => { + setIsScreenFocused(false); + }; + }, []), + ); + const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); const handleDeleteExpenses = () => { @@ -326,6 +345,7 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { } const onFiltersButtonPress = () => { + hideProductTrainingTooltip(); const filterFormValues = SearchQueryUtils.buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTagsLists, currencyList, personalDetails, cardList, reports, taxRates); SearchActions.updateAdvancedFilters(filterFormValues); @@ -348,12 +368,23 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { shouldUseStyleUtilityForAnchorPosition /> ) : ( -