diff --git a/.eslintrc.changed.js b/.eslintrc.changed.js index ae82e38070e7..1f17c73f5813 100644 --- a/.eslintrc.changed.js +++ b/.eslintrc.changed.js @@ -21,7 +21,7 @@ module.exports = { }, overrides: [ { - files: ['src/pages/workspace/WorkspaceInitialPage.tsx', 'src/pages/home/report/PureReportActionItem.tsx', 'src/libs/SidebarUtils.ts'], + files: ['src/pages/workspace/WorkspaceInitialPage.tsx', 'src/libs/SidebarUtils.ts'], rules: { 'rulesdir/no-default-id-values': 'off', }, diff --git a/.eslintrc.js b/.eslintrc.js index 650692c54eb7..aa98b7bdc464 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -87,6 +87,10 @@ const restrictedImportPaths = [ importNames: ['memoize'], message: "Please use '@src/libs/memoize' instead.", }, + { + name: 'react-native-animatable', + message: "Please use 'react-native-reanimated' instead.", + }, ]; const restrictedImportPatterns = [ diff --git a/.github/actions/composite/setupNode/action.yml b/.github/actions/composite/setupNode/action.yml index 086a2a383d28..cfa3f9fc191e 100644 --- a/.github/actions/composite/setupNode/action.yml +++ b/.github/actions/composite/setupNode/action.yml @@ -31,8 +31,8 @@ runs: uses: actions/cache@v4 with: path: node_modules - key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json', 'patches/**') }} - + key: ${{ inputs.IS_HYBRID_BUILD == 'true' && format('{0}-node-modules-{1}', runner.os, hashFiles('package-lock.json', 'patches/**', 'Mobile-Expensify/patches/**')) || format('{0}-node-modules-{1}', runner.os, hashFiles('package-lock.json', 'patches/**'))}} + - id: cache-old-dot-node-modules if: inputs.IS_HYBRID_BUILD == 'true' uses: actions/cache@v4 diff --git a/Mobile-Expensify b/Mobile-Expensify index 33f71a1b24e1..10beb38304d3 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 33f71a1b24e15527a8207281344ed9fa57203c69 +Subproject commit 10beb38304d35ee86b948f64afcdf49d51ab3d4a diff --git a/android/app/build.gradle b/android/app/build.gradle index 23de9fc95273..bc4558882674 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 1009008902 - versionName "9.0.89-2" + versionCode 1009008906 + versionName "9.0.89-6" // 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/arrows-leftright.svg b/assets/images/arrows-leftright.svg new file mode 100644 index 000000000000..53c75d411734 --- /dev/null +++ b/assets/images/arrows-leftright.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/assets/images/chatbubble-slash.svg b/assets/images/chatbubble-slash.svg new file mode 100644 index 000000000000..09d2b5bd3149 --- /dev/null +++ b/assets/images/chatbubble-slash.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index 1992a27c40c3..d50fa927fa95 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -179,7 +179,7 @@ const getCommonConfiguration = ({file = '.env', platform = 'web'}: Environment): // We are importing this worker as a string by using asset/source otherwise it will default to loading via an HTTPS request later. // This causes issues if we have gone offline before the pdfjs web worker is set up as we won't be able to load it from the server. { - test: new RegExp('node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs'), + test: new RegExp('node_modules/pdfjs-dist/legacy/build/pdf.worker.min.mjs'), type: 'asset/source', }, diff --git a/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md b/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md index 7243bdd5f470..c812071166c3 100644 --- a/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md +++ b/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md @@ -1,74 +1,92 @@ --- title: Reconcile Company Card Expenses -description: How to reconcile company card transactions +description: Learn how to reconcile company card expenses in Expensify, including troubleshooting discrepancies, managing approvals, and preparing accruals --- -If your company imports corporate card transactions into Expensify, you can reconcile them by using the Reconciliation dashboard. +This guide explains how to reconcile corporate card transactions imported into Expensify using the reconciliation dashboard feature. -1. Hover over **Settings** and click **Domains**. +# Steps to Reconcile Transactions + +## Access the Reconciliation Dashboard +1. Hover over **Settings** and click **Domains**. 2. Select the desired domain. 3. Click the **Reconciliation** tab near the top of the page. -4. Enter the statement dates and click **Run**. - -# Confirm statement total - -To confirm the total of transactions imported into Expensify against a credit card statement: -1. Review the **Imported Total**, which shows the sum of all expenses imported into Expensify for that statement period. This should match the total on your credit card statement. -2. If there is a discrepancy, refresh the feed to import missing expenses. Click **Update all cards** for commercial card feeds, or update individual cards by clicking the blue cog icon and choosing **Update** for other feed types. -3. After updating, click **Run** to update the transaction totals. - -# Confirm card totals - -If there is a discrepancy between the totals on the credit card statement and the Reconciliation dashboard, then review each card’s total to find the source of the missing transactions. - -1. Sort the cards by clicking the heading for **Card Name/Number**, **Assignee** or **Total** and compare each card's total to the statement to determine which card(s) don't match the statement total. -2. Click on the **Total** amount for a card to view the imported expenses and identify any that are missing from the statement. Confirm that all cards have been assigned to cardholders, as this could be another reason that the Imported Total doesn't match the statement. -3. If there is still a discrepancy after updating and re-calculating the totals, contact concierge@expensify.com and provide the details of the expenses that are showing on your statement but are missing in Expensify. To investigate, we’ll need the cardholder email, expense date, and amount. Keep in mind sorting by column heading also makes locating expenses easier. - -# Identify outstanding unapproved expenses - -Use the **Unapproved total** and **Approved Total** to identify expenses that have not yet been approved and/or exported. +4. Enter the statement dates and click **Run**. -# View expenses +## Confirm Statement Total +To verify the total transactions imported into Expensify match your credit card statement: -- Click the **Unapproved Total** heading to sort cards by those with outstanding expenses. -- Click the **Unapproved** amount for a card to view the expenses which are in the Unreported, Open, Processing, or Deleted states. +1. Review the **Imported Total**, which shows the sum of all imported expenses for the selected statement period. + - This total should match the amount on your credit card statement. +2. If there’s a discrepancy: + - Refresh the feed to import missing expenses: + - Click **Update All Cards** for commercial card feeds. + - For other feeds, click the blue cog icon next to individual cards and select **Update**. + - After updating, click **Run** to recalculate the totals. -*Note: You must be both a Domain Admin and a Workspace Admin to access expenses.* +## Confirm Card Totals +If the totals on the credit card statement and the Reconciliation dashboard still don’t match, follow these steps: -# Add unreported and/or deleted expenses to a report +1. Sort the cards by clicking the column heading for **Card Name/Number**, **Assignee**, or **Total**. +2. Compare each card’s total to the credit card statement to find discrepancies. +3. Click the **Total** amount for a card to view its imported expenses. Check for: + - Missing transactions. + - Unassigned cards (all cards must be assigned to cardholders). +4. If discrepancies persist, contact **concierge@expensify.com** with details of the missing expenses: + - Cardholder email + - Expense date + - Expense amount -1. Change the filters so that only Unreported and/or Deleted expenses are showing. -2. Select all expenses, then click **Add to a Report,** then **Auto Report**. -3. If there is an open report in the cardholder's account, the expense(s) will be added to it. If not, a new report will be created with the expenses. +## Identify Outstanding, Unapproved Expenses +Use the **Unapproved Total** and **Approved Total** columns to locate expenses that haven’t been approved or exported: -# Process reports +1. Click the **Unapproved Total** heading to sort cards by those with outstanding expenses. +2. Click the **Unapproved** amount for a card to view expenses in the Unreported, Open, Processing, or Deleted states. -- Workspace admins have the ability to code (categorize or tag an expense or add a receipt or comment to it) unsubmitted expenses, submit Open reports, and approve Processing reports. Any changes made by an admin are tracked under Report History and Comments at the bottom of each report. -- You can remind members to submit and approve reports via the Report History and Comments. An email notification will be sent to all members who have taken action on the report. +**Note: You must be both a Domain Admin and Workspace Admin to access expenses.** -# Prepare accrual +## Add Unreported or Deleted Expenses to a Report +1. Filter the expenses to display only Unreported or Deleted expenses. +2. Select all relevant expenses and click **Add to a Report** > **Auto Report**. +3. If an open report exists in the cardholder’s account, the expenses will be added to it. Otherwise, a new report will be created. -If there are still unapproved expenses when you want to close your books for the month, then you can use the feed’s Imported, Approved, and Unapproved totals to create an accrual entry. -- Match the Imported Total to the Statement amount. -- Match the Approved Total to the Company Card Liability account in your accounting system. -- The Unapproved Total becomes the Accrual amount (if the two amounts above are correct). - -{% include faq-begin.md %} - -**Who can view and access the Reconciliation tab?** - -Only Domain admins have access to the Reconciliation tool. - -**Who can view and process company card transactions?** - -- Domain admins can view all company card transactions using the Reconciliation tool, even if they are unreported. -- Workspace admins can only view reported expenses on a workspace. So if a workspace admin does not have access to the domain, they will be unable to see any transaction that hasn’t been placed on a report. +--- +# Process and Edit Reports -**What do I do if company card expenses are missing?** +Workspace Admins can do the following via the Reconciliation Dashboard: + - Code (categorize or tag expenses, add receipts or comments) expenses. + - Submit Open reports. + - Approve Processing reports. +- All changes made by admins are tracked in the **Report History and Comments** section at the bottom of each report. +- You can remind members to submit or approve reports via Report History, which sends email notifications to users. -If a cardholder reports expenses as missing, we first recommend using the Reconciliation tool to try and locate the expense. Select the date range the expense falls under, and once the report is available, select the specific card to view the data. If the expense is not listed, you will want to click **Update** next to the card under the Card List tab. This will pull in any missing expenses. +--- +# Prepare Accrual -If after updating, the expense still hasn’t appeared, you should reach out to Concierge with the missing expense specifics (merchant, date, amount and last four digits of the card number). Please note, only posted transactions will import. +To close your books for the month with unapproved expenses: +1. Match the **Imported Total** to the statement amount. +2. Match the **Approved Total** to the Company Card Liability account in your accounting system. +3. Use the **Unapproved Total** as the accrual amount if the above totals are correct. -{% include faq-end.md %} +--- +# FAQ + +## Who can access the Reconciliation tab? +Only Domain Admins can access the Reconciliation tool. + +## Who can view and process company card transactions? +- **Domain Admins** can view all company card transactions, including unreported ones, via the Reconciliation tool. +- **Workspace Admins** can only view reported expenses in a workspace. If they lack domain access, they cannot see transactions that haven’t been added to a report. + +## What do I do if company card expenses are missing? +1. Use the Reconciliation tool to locate the missing expense: + - Select the date range for the expense. + - View the specific card to check the data. +2. If the expense isn’t listed, click **Update** next to the card under the Card List tab to pull in missing transactions. +3. If the expense still doesn’t appear, contact Concierge with these details: + - Merchant name + - Date + - Amount + - Last four digits of the card number + +**Note: Only posted transactions will be imported.** diff --git a/docs/articles/expensify-classic/expensify-card/Request-the-Card.md b/docs/articles/expensify-classic/expensify-card/Request-the-Card.md deleted file mode 100644 index 1272cbd1f117..000000000000 --- a/docs/articles/expensify-classic/expensify-card/Request-the-Card.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: Request the Card -description: Details on requesting the Expensify Card as an employee ---- -_Note: The Expensify Card is currently only available to companies that have:_ -_- A US Bank Account_ -_- US documentation_ -_- A private email domain i.e. we cannot provision Expensify cards for users with gmail.com, hotmail.com, yahoo.com etc_ - -To start using the Expensify Card, do the following: -1. **Enable Expensify Cards:** An admin must first enable the cards. Then, an admin can assign you a card by setting a limit, which allows access to the card. -2. **Request the Card:** - - If you haven’t been assigned a limit, look for the task on your account’s homepage that says, “Ask your admin for the card!” - - Completing that task will send an in-product notification to your admin team that you requested the card. - - Once you’re assigned a card limit, you’ll receive an email notification. Click the link in the email to provide your shipping address on your account’s homepage. - - Enter your address, and the physical card will be shipped within 3-5 business days. -3. **Activate the Card:** When your physical card arrives, activate it in Expensify by entering the last four digits of the card in the activation task on your homepage. - -### Virtual Cards -Once you've been assigned a limit, a virtual card is available immediately. You can view its details via _**Settings > Account > Credit Card Import > Show Details**_. - -To protect your account and card spend, enable two-factor authentication under _**Settings > Account > Account Details**_. - -### Notifications -- Download the Expensify mobile app and enable push notifications to stay updated on your card’s limit and spending. -- Each transaction triggers a push notification. -- You’ll also get notifications for potentially fraudulent activity, allowing you to confirm or dispute charges. - -## Request a Replacement Expensify Card -### If the card is lost, stolen, or damaged Card: - - Go to _**Settings > Account > Credit Card Import** and click **Request a New Card**_. - - Confirm your shipping information and complete the prompts. The new card will arrive in 2-3 business days. - - Selecting “lost” or “stolen” deactivates your current card to prevent fraud. Choosing “damaged” keeps the current card active until the new one arrives. - - If you can’t access the website or app, call 1-877-751-5848 (US) or +44 808 196 0632 (Internationally) to cancel your card. - -### If the card is expiring -- If your card is about to expire, Expensify will notify you via your account’s Home (Inbox) tab. -- Enter your address if it has changed; otherwise, do nothing, and the new card will ship to your address on file. -- The new card will have a unique number and will not be linked to the old one. - -{% include faq-begin.md %} - -## What if I haven’t received my card after multiple weeks? - -Reach out to support, and we can locate a tracking number for the card. If the card shows as delivered, but you still haven’t received it, you’ll need to confirm your address and order a new one. - -## I’m self-employed. Can I set up the Expensify Card as an individual? - -Yep! As long as you have a business bank account and have registered your company with the IRS, you are eligible to use the Expensify Card as an individual business owner. - -{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/expensify-card/Request-the-Expensify-Card.md b/docs/articles/expensify-classic/expensify-card/Request-the-Expensify-Card.md new file mode 100644 index 000000000000..52ffec9f716e --- /dev/null +++ b/docs/articles/expensify-classic/expensify-card/Request-the-Expensify-Card.md @@ -0,0 +1,88 @@ +--- +title: Request the Expensify Card +description: Learn how to request, activate, and manage the Expensify Card, including virtual card setup, replacement procedures, and eligibility requirements. +--- + +This guide provides details on how you and your employees can request and use the Expensify Card. + +# Requirements for the Expensify Card + +The Expensify Card is currently available only to companies that meet the following criteria: +- **US Bank Account** +- **US Documentation** +- **Private Email Domain**: We cannot provision Expensify Cards for users with public domains like gmail.com, hotmail.com, yahoo.com, etc. + +--- +# Steps to Request the Expensify Card + +## 1. Enable Expensify Cards (Admin Action) +- An admin must first enable the cards. +- The admin will assign you a card by setting a spending limit and granting access to the card. + +## 2. Request the Card +If a card limit hasn’t been assigned to you, look for the task on your account homepage that says: **"Ask your admin for the card!"** +- Completing this task sends an in-product notification to your admin team requesting the card. +- Once assigned a card limit, you’ll receive an email notification. Follow these steps: + 1. Click the link in the email. + 2. Provide your shipping address on your account homepage. + 3. Submit the address to have your physical card shipped within **3-5 business days**. + +## 3. Activate the Card +When the physical card arrives, activate it by: + - Entering the last four digits of the card in the activation task on your account homepage. + +--- +# Virtual Cards + +- Virtual cards are available immediately once a spending limit is assigned. +- To view your virtual card details, go to: + **Settings > Account > Credit Card Import > Show Details**. + +## Security Tip +[Enable two-factor authentication](https://help.expensify.com/articles/expensify-classic/settings/Enable-two-factor-authentication) to secure your account and card spend: +1. Navigate to **Settings > Account > Account Details** +2. Under the Account Details tab, find the Two-Factor Authentication section, and switch on the toggle + +--- +# Notifications +Download the Expensify mobile app and enable push notifications to stay updated on: + - Card spending limits + - Transactions + - Potentially fraudulent activity + +--- +# Request a Replacement Expensify Card + +## Lost, Stolen, or Damaged Cards +1. Go to **Settings > Account > Credit Card Import** and click **Request a New Card**. +2. Confirm your shipping information and complete the prompts. +3. Replacement Timeline: + - **Lost or Stolen**: The current card is deactivated immediately to prevent fraud. + - **Damaged**: The current card remains active until the replacement arrives. +4. A new card will arrive within **2-3 business days**. + +**Alternative: Contact Support** +If you can’t access the website or app: +- Call **1-877-751-5848** (US) or **+44 808 196 0632** (International) to cancel the card. + +## Expiring Cards +- Expensify notifies you via the **Home (Inbox)** tab when your card is nearing expiration. +- If your address has changed, update it to receive the new card. +- Otherwise, the card will ship automatically to your address on file. + +**Important**: The new card will have a unique number and won’t be linked to the old one. + +--- +# FAQ + +## What if I haven’t received my card after multiple weeks? +- Reach out to support for a tracking number. + +- If the card is marked as delivered but not received: + - Confirm your address. + - Order a replacement card. + +## I’m self-employed. Can I set up the Expensify Card as an individual? +Yes! If you have a business bank account and IRS registration for your company, you can use the Expensify Card as an individual business owner. + +--- diff --git a/docs/articles/expensify-classic/settings/Change-or-add-email-address.md b/docs/articles/expensify-classic/settings/Change-or-add-email-address.md deleted file mode 100644 index f6fe3d8e13b4..000000000000 --- a/docs/articles/expensify-classic/settings/Change-or-add-email-address.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: Change or add email address -description: Update your Expensify email address or add a secondary email ---- -
- -The primary email address on your Expensify account is the email that receives email updates and notifications for your account. You can add a secondary email address in order to -- Change your primary email to a new one. -- Connect your personal email address as a secondary login if your primary email address is one from your employer. This allows you to always have access to your Expensify account, even if your employer changes. - -{% include info.html %} -Before you can remove a primary email address, you must add a new one to your Expensify account and make it the primary using the steps below. Email addresses must be added as a secondary login before they can be made the primary. -{% include end-info.html %} - -# Adding a new Secondary Login -*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.* - -1. Hover over Settings, then click **Account**. -2. Under the Account Details > Secondary Logins > click **Add Secondary Login**. -3. Enter the email address or phone number you wish to use as a secondary login. For phone numbers, be sure to include the international code, if applicable. -4. Find the email or text message from Expensify containing the Magic Code and enter it into the field. - -# Changing your Primary Login -If you already have multiple email addresses linked to your account, you can change which one is listed as the Primary Login. - -1. Settings > Account > Secondary Logins. -2. Click **Make Primary** next to the email address you want to appear on your account. - -You can keep both logins, or you can click **Remove** next to the old email address to delete it from your account. - -# Unlinking an email from your old account -If you at one point added your personal email address as a Secondary Login to your account, and then the account was closed - for example if you had a company account and then left the company - you may want to unlink your personal email to use it with a new Expensify account. You can do this with the following steps: - -1. Navigate to the sign in page at expensify.com. -2. Enter your personal email address into the email field. -3. Click **Unlink Accounts**. -4. You will recieve a verification email to complete the unlinking of your personal address. - -# FAQ -**What does changing the primary login do?** -When you change your primary login this will update the email address that appears on your reports (old and new), in workspace account settings, and on your account. - -**Can I have multiple Seconary Logins?** -Yes, you can have an unlimited number of logins attached to your account. -
diff --git a/docs/articles/expensify-classic/settings/Managing-Primary-and-Secondary-Logins-in-Expensify.md b/docs/articles/expensify-classic/settings/Managing-Primary-and-Secondary-Logins-in-Expensify.md new file mode 100644 index 000000000000..788d0ff94eb9 --- /dev/null +++ b/docs/articles/expensify-classic/settings/Managing-Primary-and-Secondary-Logins-in-Expensify.md @@ -0,0 +1,61 @@ +--- +title: Managing Primary and Secondary Logins in Expensify +description: Learn how to update or add an email address to your Expensify Classic account with this step-by-step guide. +--- +
+ +Your **primary email address** on Expensify is used for receiving all notifications and updates. Adding a **secondary email address** enables you to: +- Change your primary email to a new email. +- Connect a personal email as a secondary login when your primary email is employer-provided. This ensures continued account access if your employment changes. +- Log in to your Expensify account using either your primary or secondary email address. +- SmartScan receipts by sending them to receipts@expensify.com from your secondary login. + +**Important:** Before removing your primary email, add and make another email the primary. Emails must be added as a secondary login first. + +--- + +# Adding a Secondary Login + +⚠️ **This process is only available on the Expensify website, not the mobile app.** + +1. Go to **Settings** > **Account**. +2. Under **Account Details**, find **Secondary Logins**, and click **Add Secondary Login**. +3. Enter the email address or phone number you want to use. + - For phone numbers, include the international code if applicable. +4. Check your email or text messages for a verification Magic Code and enter it in the required field. + +--- + +# Changing Your Primary Login + +If you have multiple email addresses linked to your account, follow these steps to change your primary login: + +1. Navigate to **Settings** > **Account** > **Secondary Logins**. +2. Click **Make Primary** next to the desired email address. +3. *(Optional)* To remove the old email address, click **Remove** next to it. + +--- + +# Unlinking an Email from a Closed Account + +If you previously added your personal email as a Secondary Login on a company account and the account has been closed, you can unlink your email to use it with a new Expensify account: + +1. Go to the **Expensify Sign-In** page. +2. Enter your personal email address. +3. Click **Unlink Accounts**. +4. Follow the steps in the verification email to complete the unlinking process. + +--- + +# FAQ + +## What does changing the primary login do? +Changing your primary login updates: +- The email address displayed on reports (old and new). +- Workspace account settings. +- Your account’s default email. + +## Can I have multiple secondary logins? +Yes, you can add an unlimited number of secondary logins to your account. + +
diff --git a/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md b/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md deleted file mode 100644 index 782e939e991e..000000000000 --- a/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: Upgrade to the new Expensify Card from Visa -description: Get the new Expensify Visa® Commercial Card ---- -
- -When you upgrade the Expensify Cards to the new program, you'll have access to even more tools to manage employee spending, including: -- Unlimited [virtual cards](https://use.expensify.com/unlimited-virtual-cards) -- Controlled spending amounts on virtual cards to manage subscriptions -- Tighter controls for managing spend across employees and merchants -- Fixed or monthly spending limits for each card -- Unique naming for each virtual card for simplified expense categorization - -{% include info.html %} -The Expensify Card upgrade must be completed by December 1, 2024. -{% include end-info.html %} - -# Upgrade your company’s Expensify Card program -This process must be completed by a Domain Admin. Any domain Admin can complete the upgrade, but only one admin needs to complete these steps. - -**Before updating the card program:** -- Make sure your employees' address is up-to-date in their Expensify account -- Confirm the employees who should be receiving a new Expensify Card have a card limit set that's greater than $0 - -## Steps to upgrade the Expensify Cards -1. On your Home page, click the task titled _Upgrade to the new and improved Expensify Card._ -2. Review and agree to the Terms of Service. -3. Click **Get the new card**. All existing cardholders with a limit greater than $0 will be automatically mailed a new physical card to the address they have on file. Virtual cards will be automatically issued and available for immediate use. -4. If you have Positive Pay enabled for your settlement account, contact your bank as soon as possible to whitelist the new ACH ID: 2270239450. -5. Remind your employees to update their payment information for recurring charges to their virtual card information. - -New cards will have the same limit as the existing cards. Each cardholder’s current physical and virtual cards will remain active until a Domain Admin or the cardholder deactivates it. - -{% include info.html %} -Cards won’t be issued to any employees who don’t currently have them. In this case, you’ll need to [issue a new card](https://help.expensify.com/articles/expensify-classic/expensify-card/Set-Up-the-Expensify-Visa%C2%AE-Commercial-Card-for-your-Company) -{% include end-info.html %} - -{% include faq-begin.md %} - -## Why don’t I see the task to agree to new terms on my Home page? - -There are a few reasons why you might not see the task on your Home page: -- You may not be a Domain Admin -- Another domain admin has already accepted the terms -- The task may be hidden. To find hidden tasks, scroll to the bottom of the Home page and click **Show Hidden Tasks** to see all of your available tasks. - -## Will this affect the continuous reconciliation process? - -No. During the transition period, you may have some employees with old cards and some with new cards, so you’ll have two different debits (settlements) made to your settlement account for each settlement period. Once all spending has transitioned to the new cards, you’ll only see one debit/settlement. - -## Do I have to upgrade to the new Expensify Visa® Commercial Card? - -Yes, the Expensify Cards will not work on the old program. This must be completed by November 1, 2024. -{% include faq-end.md %} -
diff --git a/docs/articles/new-expensify/travel/Configure-travel-policy-and-preferences.md b/docs/articles/new-expensify/travel/Configure-travel-policy-and-preferences.md index 237aad83169a..d7d9c656a612 100644 --- a/docs/articles/new-expensify/travel/Configure-travel-policy-and-preferences.md +++ b/docs/articles/new-expensify/travel/Configure-travel-policy-and-preferences.md @@ -158,8 +158,16 @@ Flight preferences include multiple sections with different settings: # FAQ -How do travel policy rules interact with Expensify’s [approval flows](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses)? +## How do travel policy rules interact with Expensify’s [approval flows](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses)? Travel policy rules define what can and can’t be booked by your employees while they’re making the booking. Once a booking is placed and the travel itself is [approved](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses), the expense will appear in Expensify. It will then be coded, submitted, pushed through the existing expense approval process as defined by your workspace, and exported to your preferred accounting platform (if applicable). +## Why are some policies not selectable? + +If the travel policy you want to configure is greyed out, it might be linked to a parent policy. To unlink it from the default policy: + +1. Look for the “link” icon next to the dropdown menu. +2. Click the icon to unlink the policy from the parent policy. +3. Once unlinked, you’ll be able to make your selection. + diff --git a/docs/redirects.csv b/docs/redirects.csv index 40e8b8d0ca61..81dc9a9a3129 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -61,7 +61,7 @@ https://community.expensify.com/discussion/4343/expensify-anz-partnership-announ https://community.expensify.com/discussion/7318/deep-dive-company-credit-card-import-options,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards https://community.expensify.com/discussion/2673/personalize-your-commercial-card-feed-name,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Commercial-Card-Feeds https://community.expensify.com/discussion/6569/how-to-import-and-assign-company-cards-from-csv-file,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import -https://community.expensify.com/discussion/4714/how-to-set-up-a-direct-bank-connection-for-company-cards,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Direct-Bank-Connections +https://community.expensify.com/discussion/4714/how-to-set-up-a--connection-for-company-cards,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Direct-Bank-Connections https://community.expensify.com/discussion/5366/deep-dive-troubleshooting-credit-card-issues-in-expensify,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Troubleshooting https://community.expensify.com/discussion/9554/how-to-set-up-global-reimbursemen,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursements https://community.expensify.com/discussion/4463/how-to-remove-or-manage-settings-for-imported-personal-cards,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards @@ -612,11 +612,14 @@ https://help.expensify.com/articles/expensify-classic/expenses/Apply-Tax,https:/ https://help.expensify.com/articles/expensify-classic/workspaces/Tax-Tracking,https://help.expensify.com/articles/expensify-classic/workspaces/Track-Taxes https://help.expensify.com/articles/new-expensify/travel/manage-travel-member-roles,https://help.expensify.com/articles/new-expensify/travel/Manage-Travel-Member-Roles https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Connect-Personal-Credit-Cards -https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Direct-Bank-Connections,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/ https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/CSV-Import,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Connect-Company-Cards/ https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Commercial-Card-Feeds,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Connect-Company-Cards/ https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Configure-Company-Card-Settings/ https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Connect-ANZ,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Connect-Company-Cards/ https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Reconciliation,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses/ https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Connect-Company-Cards/ +https://help.expensify.com/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa,https://help.expensify.com/new-expensify/hubs/expensify-card/ https://help.expensify.com/articles/new-expensify/expensify-card/Dispute-Expensify-Card-transaction,https://help.expensify.com/articles/new-expensify/expensify-card/Disputing-Expensify-Card-Transactions +https://help.expensify.com/articles/expensify-classic/expensify-card/Request-the-Card,https://help.expensify.com/articles/expensify-classic/expensify-card/Request-the-Expensify-Card +https://help.expensify.com/articles/expensify-classic/settings/Change-or-add-email-address,https://help.expensify.com/articles/expensify-classic/settings/Managing-Primary-and-Secondary-Logins-in-Expensify +https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Direct-Bank-Connections,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Connect-Company-Cards diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index b740755740ed..184498800897 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.0.89.2 + 9.0.89.6 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 4bf6d22ef364..1c17b3df0e3d 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 9.0.89.2 + 9.0.89.6 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 5af1c9a4914b..99c7550ee5f2 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.0.89 CFBundleVersion - 9.0.89.2 + 9.0.89.6 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 82299434a3b1..184c7101b6b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.89-2", + "version": "9.0.89-6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.89-2", + "version": "9.0.89-6", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 393f670098fa..43c38ed9b904 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.89-2", + "version": "9.0.89-6", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/src/CONST.ts b/src/CONST.ts index ac4b9562672d..4ddde915155e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -328,6 +328,9 @@ const CONST = { ANIMATED_HIGHLIGHT_END_DURATION: 2000, ANIMATED_TRANSITION: 300, ANIMATED_TRANSITION_FROM_VALUE: 100, + ANIMATED_PROGRESS_BAR_DELAY: 300, + ANIMATED_PROGRESS_BAR_OPACITY_DURATION: 300, + ANIMATED_PROGRESS_BAR_DURATION: 750, ANIMATION_IN_TIMING: 100, ANIMATION_DIRECTION: { IN: 'in', @@ -446,6 +449,9 @@ const CONST = { MAX_LENGTH: 83, }, + REVERSED_TRANSACTION_ATTRIBUTE: 'is-reversed-transaction', + HIDDEN_MESSAGE_ATTRIBUTE: 'is-hidden-message', + CALENDAR_PICKER: { // Numbers were arbitrarily picked. MIN_YEAR: CURRENT_YEAR - 100, @@ -904,7 +910,6 @@ const CONST = { CLOUDFRONT_URL, EMPTY_ARRAY, EMPTY_OBJECT, - EMPTY_STRING: '', DEFAULT_NUMBER_ID: 0, USE_EXPENSIFY_URL, EXPENSIFY_URL, @@ -1707,6 +1712,10 @@ const CONST = { CONCIERGE: 'concierge', OTHER: 'other', WEB_PROP_ATTR: 'data-testid', + SHUTDOWN: 'shutdown', + RESTART: 'restart', + SET_IDENTITY: 'setIdentity', + OBSERVE: 'observe', }, CONCIERGE_DISPLAY_NAME: 'Concierge', @@ -2393,6 +2402,7 @@ const CONST = { TRACK: 'track', }, AMOUNT_MAX_LENGTH: 8, + DISTANCE_REQUEST_AMOUNT_MAX_LENGTH: 14, RECEIPT_STATE: { SCANREADY: 'SCANREADY', OPEN: 'OPEN', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 4a9bbe12203d..7750e795e9c7 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -418,9 +418,6 @@ const ONYXKEYS = { /** Stores the last export method for policy */ LAST_EXPORT_METHOD: 'lastExportMethod', - /** Stores the information about the state of issuing a new card */ - ISSUE_NEW_EXPENSIFY_CARD: 'issueNewExpensifyCard', - /** Stores the information about the state of addint a new company card */ ADD_NEW_COMPANY_CARD: 'addNewCompanyCard', @@ -556,6 +553,9 @@ const ONYXKEYS = { /** Whether the bank account chosen for Expensify Card in on verification waitlist */ NVP_EXPENSIFY_ON_CARD_WAITLIST: 'nvp_expensify_onCardWaitlist_', + + /** Stores the information about the state of issuing a new card */ + ISSUE_NEW_EXPENSIFY_CARD: 'issueNewExpensifyCard_', }, /** List of Form ids */ @@ -897,6 +897,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.EXPENSIFY_CARD_USE_CONTINUOUS_RECONCILIATION]: boolean; [ONYXKEYS.COLLECTION.LAST_SELECTED_FEED]: OnyxTypes.CompanyCardFeed; [ONYXKEYS.COLLECTION.NVP_EXPENSIFY_ON_CARD_WAITLIST]: OnyxTypes.CardOnWaitlist; + [ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard; }; type OnyxValuesMapping = { @@ -1036,7 +1037,6 @@ type OnyxValuesMapping = { [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING]: boolean; [ONYXKEYS.NVP_TRAVEL_SETTINGS]: OnyxTypes.TravelSettings; [ONYXKEYS.REVIEW_DUPLICATES]: OnyxTypes.ReviewDuplicates; - [ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard; [ONYXKEYS.ADD_NEW_COMPANY_CARD]: OnyxTypes.AddNewCompanyCardFeed; [ONYXKEYS.ASSIGN_CARD]: OnyxTypes.AssignCard; [ONYXKEYS.MOBILE_SELECTION_MODE]: OnyxTypes.MobileSelectionMode; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 33c94e343568..aa6e54f82dd3 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -322,8 +322,12 @@ const ROUTES = { }, EDIT_REPORT_FIELD_REQUEST: { route: 'r/:reportID/edit/policyField/:policyID/:fieldID', - getRoute: (reportID: string, policyID: string, fieldID: string, backTo?: string) => - getUrlWithBackToParam(`r/${reportID}/edit/policyField/${policyID}/${encodeURIComponent(fieldID)}` as const, backTo), + getRoute: (reportID: string | undefined, policyID: string | undefined, fieldID: string, backTo?: string) => { + if (!policyID || !reportID) { + Log.warn('Invalid policyID or reportID is used to build the EDIT_REPORT_FIELD_REQUEST route', {policyID, reportID}); + } + return getUrlWithBackToParam(`r/${reportID}/edit/policyField/${policyID}/${encodeURIComponent(fieldID)}` as const, backTo); + }, }, REPORT_WITH_ID_DETAILS_SHARE_CODE: { route: 'r/:reportID/details/shareCode', @@ -400,11 +404,21 @@ const ROUTES = { }, SPLIT_BILL_DETAILS: { route: 'r/:reportID/split/:reportActionID', - getRoute: (reportID: string, reportActionID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/split/${reportActionID}` as const, backTo), + getRoute: (reportID: string | undefined, reportActionID: string, backTo?: string) => { + if (!reportID) { + Log.warn('Invalid reportID is used to build the SPLIT_BILL_DETAILS route'); + } + return getUrlWithBackToParam(`r/${reportID}/split/${reportActionID}` as const, backTo); + }, }, TASK_TITLE: { route: 'r/:reportID/title', - getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/title` as const, backTo), + getRoute: (reportID: string | undefined, backTo?: string) => { + if (!reportID) { + Log.warn('Invalid reportID is used to build the TASK_TITLE route'); + } + return getUrlWithBackToParam(`r/${reportID}/title` as const, backTo); + }, }, REPORT_DESCRIPTION: { route: 'r/:reportID/description', @@ -417,7 +431,12 @@ const ROUTES = { }, TASK_ASSIGNEE: { route: 'r/:reportID/assignee', - getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/assignee` as const, backTo), + getRoute: (reportID: string | undefined, backTo?: string) => { + if (!reportID) { + Log.warn('Invalid reportID is used to build the TASK_ASSIGNEE route'); + } + return getUrlWithBackToParam(`r/${reportID}/assignee` as const, backTo); + }, }, PRIVATE_NOTES_LIST: { route: 'r/:reportID/notes', @@ -534,7 +553,12 @@ const ROUTES = { }, SETTINGS_TAGS_ROOT: { route: 'settings/:policyID/tags', - getRoute: (policyID: string, backTo = '') => getUrlWithBackToParam(`settings/${policyID}/tags`, backTo), + getRoute: (policyID: string | undefined, backTo = '') => { + if (!policyID) { + Log.warn('Invalid policyID while building route SETTINGS_TAGS_ROOT'); + } + return getUrlWithBackToParam(`settings/${policyID}/tags`, backTo); + }, }, SETTINGS_TAGS_SETTINGS: { route: 'settings/:policyID/tags/settings', @@ -669,8 +693,11 @@ const ROUTES = { }, MONEY_REQUEST_STEP_TAG: { route: ':action/:iouType/tag/:orderWeight/:transactionID/:reportID/:reportActionID?', - getRoute: (action: IOUAction, iouType: IOUType, orderWeight: number, transactionID: string, reportID: string, backTo = '', reportActionID?: string) => - getUrlWithBackToParam(`${action as string}/${iouType as string}/tag/${orderWeight}/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo), + getRoute: (action: IOUAction, iouType: IOUType, orderWeight: number, transactionID: string, reportID?: string, backTo = '', reportActionID?: string) => + getUrlWithBackToParam( + `${action as string}/${iouType as string}/tag/${orderWeight}/${transactionID}${reportID ? `/${reportID}` : ''}${reportActionID ? `/${reportActionID}` : ''}`, + backTo, + ), }, MONEY_REQUEST_STEP_WAYPOINT: { route: ':action/:iouType/waypoint/:transactionID/:reportID/:pageIndex', @@ -1283,6 +1310,15 @@ const ROUTES = { return `settings/workspaces/${policyID}/company-cards` as const; }, }, + WORKSPACE_COMPANY_CARDS_BANK_CONNECTION: { + route: 'settings/workspaces/:policyID/company-cards/:bankName/bank-connection', + getRoute: (policyID: string | undefined, bankName: string, backTo: string) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_COMPANY_CARDS_BANK_CONNECTION route'); + } + return getUrlWithBackToParam(`settings/workspaces/${policyID}/company-cards/${bankName}/bank-connection`, backTo); + }, + }, WORKSPACE_COMPANY_CARDS_ADD_NEW: { route: 'settings/workspaces/:policyID/company-cards/add-card-feed', getRoute: (policyID: string) => `settings/workspaces/${policyID}/company-cards/add-card-feed` as const, @@ -1310,7 +1346,7 @@ const ROUTES = { }, WORKSPACE_EXPENSIFY_CARD: { route: 'settings/workspaces/:policyID/expensify-card', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card` as const, + getRoute: (policyID: string | undefined) => `settings/workspaces/${policyID}/expensify-card` as const, }, WORKSPACE_EXPENSIFY_CARD_DETAILS: { route: 'settings/workspaces/:policyID/expensify-card/:cardID', @@ -1909,7 +1945,7 @@ const ROUTES = { }, DEBUG_REPORT: { route: 'debug/report/:reportID', - getRoute: (reportID: string) => `debug/report/${reportID}` as const, + getRoute: (reportID: string | undefined) => `debug/report/${reportID}` as const, }, DEBUG_REPORT_TAB_DETAILS: { route: 'debug/report/:reportID/details', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 3d85cd907f2a..76456485a3a4 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -456,6 +456,7 @@ const SCREENS = { COMPANY_CARDS: 'Workspace_CompanyCards', COMPANY_CARDS_ASSIGN_CARD: 'Workspace_CompanyCards_AssignCard', COMPANY_CARDS_SELECT_FEED: 'Workspace_CompanyCards_Select_Feed', + COMPANY_CARDS_BANK_CONNECTION: 'Workspace_CompanyCards_BankConnection', COMPANY_CARDS_ADD_NEW: 'Workspace_CompanyCards_New', COMPANY_CARDS_TYPE: 'Workspace_CompanyCards_Type', COMPANY_CARDS_INSTRUCTIONS: 'Workspace_CompanyCards_Instructions', diff --git a/src/components/AnimatedStep/index.tsx b/src/components/AnimatedStep/index.tsx index 6de7d0c2b013..e9f99b6fc17c 100644 --- a/src/components/AnimatedStep/index.tsx +++ b/src/components/AnimatedStep/index.tsx @@ -1,8 +1,8 @@ import React, {useMemo} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; +// eslint-disable-next-line no-restricted-imports -- will be removed in the future PR import * as Animatable from 'react-native-animatable'; import useThemeStyles from '@hooks/useThemeStyles'; -import useNativeDriver from '@libs/useNativeDriver'; import CONST from '@src/CONST'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {AnimationDirection} from './AnimatedStepContext'; @@ -37,8 +37,6 @@ function AnimatedStep({onAnimationEnd, direction = CONST.ANIMATION_DIRECTION.IN, }} duration={CONST.ANIMATED_TRANSITION} animation={animationStyle} - // eslint-disable-next-line react-compiler/react-compiler - useNativeDriver={useNativeDriver} style={style} > {children} diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index d831fca562c3..c998c38e96ca 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -7,8 +7,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isReceiptError} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; -import * as Localize from '@libs/Localize'; -import CONST from '@src/CONST'; +import {translateLocal} from '@libs/Localize'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; @@ -61,38 +60,17 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles}: DotIndica key={index} style={styles.offlineFeedback.text} > - {Localize.translateLocal('iou.error.receiptFailureMessage')} + {translateLocal('iou.error.receiptFailureMessage')} { fileDownload(message.source, message.filename); }} > - {Localize.translateLocal('iou.error.saveFileMessage')} + {translateLocal('iou.error.saveFileMessage')} - {Localize.translateLocal('iou.error.loseFileMessage')} - - ); - } - - if (message === CONST.COMPANY_CARDS.CONNECTION_ERROR) { - return ( - - {Localize.translateLocal('workspace.companyCards.brokenConnectionErrorFirstPart')} - { - // TODO: re-navigate the user to the bank’s website to re-authenticate https://github.com/Expensify/App/issues/50448 - }} - > - {Localize.translateLocal('workspace.companyCards.brokenConnectionErrorLink')} - - - {Localize.translateLocal('workspace.companyCards.brokenConnectionErrorSecondPart')} + {translateLocal('iou.error.loseFileMessage')} ); } diff --git a/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts b/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts index 3189eebf2f04..a1e21f07a8ee 100644 --- a/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts +++ b/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts @@ -6,6 +6,7 @@ type FocusTrapForModalProps = { children: React.ReactNode; active: boolean; initialFocus?: FocusTrapOptions['initialFocus']; + shouldPreventScroll?: boolean; }; export default FocusTrapForModalProps; diff --git a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx index c47b7086bbd8..1be3f06224f2 100644 --- a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx @@ -5,12 +5,13 @@ import blurActiveElement from '@libs/Accessibility/blurActiveElement'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import type FocusTrapForModalProps from './FocusTrapForModalProps'; -function FocusTrapForModal({children, active, initialFocus = false}: FocusTrapForModalProps) { +function FocusTrapForModal({children, active, initialFocus = false, shouldPreventScroll = false}: FocusTrapForModalProps) { return ( ) { + const styles = useThemeStyles(); + const theme = useTheme(); + const htmlAttribs = tnode.attributes; + + const reversedTransactionValue = htmlAttribs[CONST.REVERSED_TRANSACTION_ATTRIBUTE]; + const hiddenMessageValue = htmlAttribs[CONST.HIDDEN_MESSAGE_ATTRIBUTE]; + + const getIcon = () => { + if (reversedTransactionValue === 'true') { + return Expensicons.ArrowsLeftRight; + } + if (hiddenMessageValue === 'true') { + return Expensicons.EyeDisabled; + } + return Expensicons.Trashcan; + }; + + return ( + + + { + const firstChild = props?.childTnode?.children?.at(0); + const data = firstChild && 'data' in firstChild ? firstChild.data : null; + + if (typeof data === 'string') { + return {data}; + } + return props.childElement; + }} + /> + + ); +} + +DeletedActionRenderer.displayName = 'DeletedActionRenderer'; + +export default DeletedActionRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx index e44d3ef97df6..ddaab1a55994 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx @@ -1,12 +1,12 @@ import {createContext} from 'react'; type MentionReportContextProps = { - currentReportID: string; + currentReportID: string | undefined; exactlyMatch?: boolean; }; const MentionReportContext = createContext({ - currentReportID: '', + currentReportID: undefined, }); export default MentionReportContext; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx index 89a9fb21d48f..fcae31dd7d2f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx @@ -60,9 +60,9 @@ function MentionReportRenderer({style, tnode, TDefaultRenderer, ...defaultRender const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const currentReportID = useCurrentReportID(); - const currentReportIDValue = currentReportIDContext || currentReportID?.currentReportID; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentReportIDValue || -1}`); + const currentReportIDValue = currentReportIDContext || currentReportID?.currentReportID; + const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentReportIDValue}`); // When we invite someone to a room they don't have the policy object, but we still want them to be able to see and click on report mentions, so we only check if the policyID in the report is from a workspace const isGroupPolicyReport = useMemo(() => currentReport && !isEmptyObject(currentReport) && !!currentReport.policyID && currentReport.policyID !== CONST.POLICY.ID_FAKE, [currentReport]); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/index.ts b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts index ce24584048b0..91ed66f8b931 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/index.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts @@ -1,6 +1,7 @@ import type {CustomTagRendererRecord} from 'react-native-render-html'; import AnchorRenderer from './AnchorRenderer'; import CodeRenderer from './CodeRenderer'; +import DeletedActionRenderer from './DeletedActionRenderer'; import EditedRenderer from './EditedRenderer'; import EmojiRenderer from './EmojiRenderer'; import ImageRenderer from './ImageRenderer'; @@ -30,6 +31,7 @@ const HTMLEngineProviderComponentList: CustomTagRendererRecord = { 'mention-here': MentionHereRenderer, emoji: EmojiRenderer, 'next-step-email': NextStepEmailRenderer, + 'deleted-action': DeletedActionRenderer, /* eslint-enable @typescript-eslint/naming-convention */ }; diff --git a/src/components/Hoverable/ActiveHoverable.tsx b/src/components/Hoverable/ActiveHoverable.tsx index eda42a703d65..3ec7bb8f25d4 100644 --- a/src/components/Hoverable/ActiveHoverable.tsx +++ b/src/components/Hoverable/ActiveHoverable.tsx @@ -15,35 +15,35 @@ type OnMouseEvents = Record void>; function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, shouldFreezeCapture, children}: ActiveHoverableProps, outerRef: Ref) { const [isHovered, setIsHovered] = useState(false); - const elementRef = useRef(null); const isScrollingRef = useRef(false); const isHoveredRef = useRef(false); - const isVisibiltyHidden = useRef(false); + const isVisibilityHidden = useRef(false); const updateIsHovered = useCallback( (hovered: boolean) => { + if (shouldFreezeCapture) { + return; + } + isHoveredRef.current = hovered; - // Nullish coalescing operator (`??`) wouldn't be appropriate here because - // it's not a matter of providing a default when encountering `null` or `undefined` - // but rather making a decision based on the truthy nature of the complete expressions. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if ((shouldHandleScroll && isScrollingRef.current) || shouldFreezeCapture) { + isVisibilityHidden.current = false; + + if (shouldHandleScroll && isScrollingRef.current) { return; } + setIsHovered(hovered); + + if (hovered) { + onHoverIn?.(); + } else { + onHoverOut?.(); + } }, - [shouldHandleScroll, shouldFreezeCapture], + [shouldHandleScroll, shouldFreezeCapture, onHoverIn, onHoverOut], ); - useEffect(() => { - if (isHovered) { - onHoverIn?.(); - } else { - onHoverOut?.(); - } - }, [isHovered, onHoverIn, onHoverOut]); - useEffect(() => { if (!shouldHandleScroll) { return; @@ -51,105 +51,65 @@ function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, shouldFreez const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling: boolean) => { isScrollingRef.current = scrolling; - if (!isScrollingRef.current) { - setIsHovered(isHoveredRef.current); + if (scrolling && isHovered) { + setIsHovered(false); + onHoverOut?.(); + } else if (!scrolling && elementRef.current?.matches(':hover')) { + setIsHovered(true); + onHoverIn?.(); } }); return () => scrollingListener.remove(); - }, [shouldHandleScroll]); + }, [shouldHandleScroll, isHovered, onHoverIn, onHoverOut]); useEffect(() => { - // Do not mount a listener if the component is not hovered - if (!isHovered) { - return; - } - - /** - * Checks the hover state of a component and updates it based on the event target. - * This is necessary to handle cases where the hover state might get stuck due to an unreliable mouseleave trigger, - * such as when an element is removed before the mouseleave event is triggered. - * @param event The hover event object. - */ - const unsetHoveredIfOutside = (event: MouseEvent) => { - // We're also returning early if shouldFreezeCapture is true in order - // to not update the hover state but keep it frozen. - if (!elementRef.current || elementRef.current.contains(event.target as Node) || shouldFreezeCapture) { - return; + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + isVisibilityHidden.current = true; + setIsHovered(false); + } else { + isVisibilityHidden.current = false; } - - setIsHovered(false); }; - document.addEventListener('mouseover', unsetHoveredIfOutside, true); - - return () => document.removeEventListener('mouseover', unsetHoveredIfOutside); - }, [isHovered, elementRef, shouldFreezeCapture]); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, []); - useEffect(() => { - const unsetHoveredWhenDocumentIsHidden = () => { - if (document.visibilityState !== 'hidden') { + const handleMouseEvents = useCallback( + (type: 'enter' | 'leave' | 'blur') => () => { + if (shouldFreezeCapture) { return; } - isVisibiltyHidden.current = true; - setIsHovered(false); - }; - - document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden); + const newHoverState = type === 'enter'; + isHoveredRef.current = newHoverState; + isVisibilityHidden.current = false; - return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden); - }, []); + updateIsHovered(newHoverState); + }, + [shouldFreezeCapture, updateIsHovered], + ); - const child = useMemo(() => getReturnValue(children, !isScrollingRef.current && isHovered), [children, isHovered]); + const child = useMemo(() => getReturnValue(children, isHovered), [children, isHovered]); - const {onMouseEnter, onMouseLeave, onMouseMove, onBlur} = child.props as OnMouseEvents; + const {onMouseEnter, onMouseLeave, onBlur} = child.props as OnMouseEvents; - const hoverAndForwardOnMouseEnter = useCallback( - (e: MouseEvent) => { - isVisibiltyHidden.current = false; - updateIsHovered(true); + return cloneElement(child, { + ref: mergeRefs(elementRef, outerRef, child.ref), + onMouseEnter: (e: MouseEvent) => { + handleMouseEvents('enter')(); onMouseEnter?.(e); }, - [updateIsHovered, onMouseEnter], - ); - - const unhoverAndForwardOnMouseLeave = useCallback( - (e: MouseEvent) => { - updateIsHovered(false); + onMouseLeave: (e: MouseEvent) => { + handleMouseEvents('leave')(); onMouseLeave?.(e); }, - [updateIsHovered, onMouseLeave], - ); - - const unhoverAndForwardOnBlur = useCallback( - (event: MouseEvent) => { - // Check if the blur event occurred due to clicking outside the element - // and the wrapperView contains the element that caused the blur and reset isHovered - if (!elementRef.current?.contains(event.target as Node) && !elementRef.current?.contains(event.relatedTarget as Node) && !shouldFreezeCapture) { - setIsHovered(false); - } - - onBlur?.(event); - }, - [onBlur, shouldFreezeCapture], - ); - - const handleAndForwardOnMouseMove = useCallback( - (e: MouseEvent) => { - isVisibiltyHidden.current = false; - updateIsHovered(true); - onMouseMove?.(e); + onBlur: (e: MouseEvent) => { + handleMouseEvents('blur')(); + onBlur?.(e); }, - [updateIsHovered, onMouseMove], - ); - - return cloneElement(child, { - ref: mergeRefs(elementRef, outerRef, child.ref), - onMouseEnter: hoverAndForwardOnMouseEnter, - onMouseLeave: unhoverAndForwardOnMouseLeave, - onBlur: unhoverAndForwardOnBlur, - ...(isVisibiltyHidden.current ? {onMouseMove: handleAndForwardOnMouseMove} : {}), }); } diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 02a6843dc11f..e4072504f3d6 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -7,6 +7,7 @@ import ArrowRightLong from '@assets/images/arrow-right-long.svg'; import ArrowRight from '@assets/images/arrow-right.svg'; import ArrowUpLong from '@assets/images/arrow-up-long.svg'; import UpArrow from '@assets/images/arrow-up.svg'; +import ArrowsLeftRight from '@assets/images/arrows-leftright.svg'; import ArrowsUpDown from '@assets/images/arrows-updown.svg'; import AttachmentNotFound from '@assets/images/attachment-not-found.svg'; import AdminRoomAvatar from '@assets/images/avatars/admin-room.svg'; @@ -43,6 +44,7 @@ import Cash from '@assets/images/cash.svg'; import Chair from '@assets/images/chair.svg'; import ChatBubbleAdd from '@assets/images/chatbubble-add.svg'; import ChatBubbleReply from '@assets/images/chatbubble-reply.svg'; +import ChatBubbleSlash from '@assets/images/chatbubble-slash.svg'; import ChatBubbleUnread from '@assets/images/chatbubble-unread.svg'; import ChatBubble from '@assets/images/chatbubble.svg'; import ChatBubbles from '@assets/images/chatbubbles.svg'; @@ -220,6 +222,7 @@ export { ArrowRight, ArrowRightLong, ArrowsUpDown, + ArrowsLeftRight, ArrowUpLong, ArrowDownLong, AttachmentNotFound, @@ -390,6 +393,7 @@ export { Linkedin, Instagram, ChatBubbleAdd, + ChatBubbleSlash, ChatBubbleUnread, ChatBubbleReply, Lightbulb, diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 3bf8e11d4ad6..604e2b3065fd 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -55,7 +55,6 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti const [isScreenFocused, setIsScreenFocused] = useState(false); const {shouldUseNarrowLayout} = useResponsiveLayout(); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${optionItem?.reportID}`); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); diff --git a/src/components/LoadingBar.tsx b/src/components/LoadingBar.tsx new file mode 100644 index 000000000000..e6d1ec0cd66d --- /dev/null +++ b/src/components/LoadingBar.tsx @@ -0,0 +1,83 @@ +import React, {useEffect} from 'react'; +import Animated, {cancelAnimation, Easing, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming} from 'react-native-reanimated'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + +type LoadingBarProps = { + // Whether or not to show the loading bar + shouldShow: boolean; +}; + +function LoadingBar({shouldShow}: LoadingBarProps) { + const left = useSharedValue(0); + const width = useSharedValue(0); + const opacity = useSharedValue(0); + const styles = useThemeStyles(); + + useEffect(() => { + if (shouldShow) { + // eslint-disable-next-line react-compiler/react-compiler + left.set(0); + width.set(0); + opacity.set(withTiming(1, {duration: CONST.ANIMATED_PROGRESS_BAR_OPACITY_DURATION})); + + left.set( + withDelay( + CONST.ANIMATED_PROGRESS_BAR_DELAY, + withRepeat( + withSequence( + withTiming(0, {duration: 0}), + withTiming(0, {duration: CONST.ANIMATED_PROGRESS_BAR_DURATION, easing: Easing.bezier(0.65, 0, 0.35, 1)}), + withTiming(100, {duration: CONST.ANIMATED_PROGRESS_BAR_DURATION, easing: Easing.bezier(0.65, 0, 0.35, 1)}), + ), + -1, + false, + ), + ), + ); + + width.set( + withDelay( + CONST.ANIMATED_PROGRESS_BAR_DELAY, + withRepeat( + withSequence( + withTiming(0, {duration: 0}), + withTiming(100, {duration: CONST.ANIMATED_PROGRESS_BAR_DURATION, easing: Easing.bezier(0.65, 0, 0.35, 1)}), + withTiming(0, {duration: CONST.ANIMATED_PROGRESS_BAR_DURATION, easing: Easing.bezier(0.65, 0, 0.35, 1)}), + ), + -1, + false, + ), + ), + ); + } else { + opacity.set( + withTiming(0, {duration: CONST.ANIMATED_PROGRESS_BAR_OPACITY_DURATION}, () => { + cancelAnimation(left); + cancelAnimation(width); + }), + ); + } + // we want to update only when shouldShow changes + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [shouldShow]); + + const animatedIndicatorStyle = useAnimatedStyle(() => ({ + left: `${left.get()}%`, + width: `${width.get()}%`, + })); + + const animatedContainerStyle = useAnimatedStyle(() => ({ + opacity: opacity.get(), + })); + + return ( + + + + ); +} + +LoadingBar.displayName = 'ProgressBar'; + +export default LoadingBar; diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index e1c5a7c48b86..e59f8f6453fd 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -14,9 +14,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import ComposerFocusManager from '@libs/ComposerFocusManager'; import Overlay from '@libs/Navigation/AppNavigator/Navigators/Overlay'; -import useNativeDriver from '@libs/useNativeDriver'; import variables from '@styles/variables'; -import * as Modal from '@userActions/Modal'; +import {areAllModalsHidden, closeTop, onModalDidClose, setCloseModal, setModalVisibility, willAlertModalBecomeVisible} from '@userActions/Modal'; import CONST from '@src/CONST'; import ModalContent from './ModalContent'; import ModalContext from './ModalContext'; @@ -37,7 +36,7 @@ function BaseModal( fullscreen = true, animationIn, animationOut, - useNativeDriver: useNativeDriverProp, + useNativeDriver, useNativeDriverForBackdrop, hideModalContentWhileAnimating = false, animationInTiming, @@ -54,6 +53,7 @@ function BaseModal( restoreFocusType, shouldUseModalPaddingStyle = true, initialFocus = false, + shouldPreventScrollOnFocus = false, }: BaseModalProps, ref: React.ForwardedRef, ) { @@ -85,16 +85,16 @@ function BaseModal( */ const hideModal = useCallback( (callHideCallback = true) => { - if (Modal.areAllModalsHidden()) { - Modal.willAlertModalBecomeVisible(false); + if (areAllModalsHidden()) { + willAlertModalBecomeVisible(false); if (shouldSetModalVisibility) { - Modal.setModalVisibility(false); + setModalVisibility(false); } } if (callHideCallback) { onModalHide(); } - Modal.onModalDidClose(); + onModalDidClose(); ComposerFocusManager.refocusAfterModalFullyClosed(uniqueModalId, restoreFocusType); }, [shouldSetModalVisibility, onModalHide, restoreFocusType, uniqueModalId], @@ -104,9 +104,9 @@ function BaseModal( isVisibleRef.current = isVisible; let removeOnCloseListener: () => void; if (isVisible) { - Modal.willAlertModalBecomeVisible(true, type === CONST.MODAL.MODAL_TYPE.POPOVER || type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED); + willAlertModalBecomeVisible(true, type === CONST.MODAL.MODAL_TYPE.POPOVER || type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED); // To handle closing any modal already visible when this modal is mounted, i.e. PopoverReportActionContextMenu - removeOnCloseListener = Modal.setCloseModal(onClose); + removeOnCloseListener = setCloseModal(onClose); } return () => { @@ -131,7 +131,7 @@ function BaseModal( const handleShowModal = () => { if (shouldSetModalVisibility) { - Modal.setModalVisibility(true); + setModalVisibility(true); } onModalShow(); }; @@ -231,7 +231,7 @@ function BaseModal( onBackdropPress={handleBackdropPress} // Note: Escape key on web/desktop will trigger onBackButtonPress callback // eslint-disable-next-line react/jsx-props-no-multi-spaces - onBackButtonPress={Modal.closeTop} + onBackButtonPress={closeTop} onModalShow={handleShowModal} propagateSwipe={propagateSwipe} onModalHide={hideModal} @@ -250,10 +250,8 @@ function BaseModal( deviceWidth={windowWidth} animationIn={animationIn ?? modalStyleAnimationIn} animationOut={animationOut ?? modalStyleAnimationOut} - // eslint-disable-next-line react-compiler/react-compiler - useNativeDriver={useNativeDriverProp && useNativeDriver} - // eslint-disable-next-line react-compiler/react-compiler - useNativeDriverForBackdrop={useNativeDriverForBackdrop && useNativeDriver} + useNativeDriver={useNativeDriver} + useNativeDriverForBackdrop={useNativeDriverForBackdrop} hideModalContentWhileAnimating={hideModalContentWhileAnimating} animationInTiming={animationInTiming} animationOutTiming={animationOutTiming} @@ -271,6 +269,7 @@ function BaseModal( {}, type, onModalShow = ( onModalShow={showModal} avoidKeyboard={false} fullscreen={fullscreen} + useNativeDriver={false} + useNativeDriverForBackdrop={false} type={type} > {children} diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index aee2045dbe47..6e8648630156 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -93,6 +93,9 @@ type BaseModalProps = Partial & { /** Used to set the element that should receive the initial focus */ initialFocus?: FocusTrapOptions['initialFocus']; + + /** Whether to prevent the focus trap from scrolling the element into view. */ + shouldPreventScrollOnFocus?: boolean; }; export default BaseModalProps; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index aaa7c5c99ab5..9b030f9bfede 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -41,6 +41,7 @@ import { isPending, isReceiptBeingScanned, shouldShowBrokenConnectionViolation as shouldShowBrokenConnectionViolationTransactionUtils, + shouldShowRTERViolationMessage, } from '@libs/TransactionUtils'; import variables from '@styles/variables'; import { @@ -72,6 +73,7 @@ import DelegateNoAccessModal from './DelegateNoAccessModal'; import HeaderWithBackButton from './HeaderWithBackButton'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; +import LoadingBar from './LoadingBar'; import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; import type {MoneyRequestHeaderStatusBarProps} from './MoneyRequestHeaderStatusBar'; import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar'; @@ -183,7 +185,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const shouldShowSettlementButton = !shouldShowSubmitButton && (shouldShowPayButton || shouldShowApproveButton) && - !hasAllPendingRTERViolations && + !shouldShowRTERViolationMessage(transactions) && !shouldShowExportIntegrationButton && !shouldShowBrokenConnectionViolation; @@ -208,6 +210,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const isMoreContentShown = shouldShowNextStep || shouldShowStatusBar || (shouldShowAnyButton && shouldUseNarrowLayout); const {isDelegateAccessRestricted} = useDelegateUserDetails(); const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); + const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); const isReportInRHP = route.name === SCREENS.SEARCH.REPORT_RHP; const shouldDisplaySearchRouter = !isReportInRHP || isSmallScreenWidth; @@ -347,7 +350,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea }, [canDeleteRequest]); return ( - + {isDuplicate && !shouldUseNarrowLayout && ( @@ -427,7 +429,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea )} {!!isMoreContentShown && ( - + {isDuplicate && shouldUseNarrowLayout && ( diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index d1dcdb2f57f5..6354b69bc58e 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -16,12 +16,25 @@ import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as CurrencyUtils from '@libs/CurrencyUtils'; +import {convertToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; -import * as ReportUtils from '@libs/ReportUtils'; +import { + getAvailableReportFields, + getFieldViolation, + getFieldViolationTranslation, + getMoneyRequestSpendBreakdown, + getReportFieldKey, + hasUpdatedTotal, + isClosedExpenseReportWithNoExpenses as isClosedExpenseReportWithNoExpensesReportUtils, + isInvoiceReport as isInvoiceReportUtils, + isPaidGroupPolicyExpenseReport as isPaidGroupPolicyExpenseReportUtils, + isReportFieldDisabled, + isReportFieldOfTypeTitle, + isSettled as isSettledReportUtils, +} from '@libs/ReportUtils'; import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; import variables from '@styles/variables'; -import * as reportActions from '@src/libs/actions/Report'; +import {clearReportFieldKeyErrors} from '@src/libs/actions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Policy, PolicyReportField, Report} from '@src/types/onyx'; @@ -29,7 +42,7 @@ import type {PendingAction} from '@src/types/onyx/OnyxCommon'; type MoneyReportViewProps = { /** The report currently being looked at */ - report: Report; + report: OnyxEntry; /** Policy that the report belongs to */ policy: OnyxEntry; @@ -52,15 +65,15 @@ function MoneyReportView({report, policy, isCombinedReport = false, shouldShowTo const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const isSettled = ReportUtils.isSettled(report.reportID); - const isTotalUpdated = ReportUtils.hasUpdatedTotal(report, policy); + const isSettled = isSettledReportUtils(report?.reportID); + const isTotalUpdated = hasUpdatedTotal(report, policy); - const {totalDisplaySpend, nonReimbursableSpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(report); + const {totalDisplaySpend, nonReimbursableSpend, reimbursableSpend} = getMoneyRequestSpendBreakdown(report); const shouldShowBreakdown = nonReimbursableSpend && reimbursableSpend && shouldShowTotal; - const formattedTotalAmount = CurrencyUtils.convertToDisplayString(totalDisplaySpend, report.currency); - const formattedOutOfPocketAmount = CurrencyUtils.convertToDisplayString(reimbursableSpend, report.currency); - const formattedCompanySpendAmount = CurrencyUtils.convertToDisplayString(nonReimbursableSpend, report.currency); + const formattedTotalAmount = convertToDisplayString(totalDisplaySpend, report?.currency); + const formattedOutOfPocketAmount = convertToDisplayString(reimbursableSpend, report?.currency); + const formattedCompanySpendAmount = convertToDisplayString(nonReimbursableSpend, report?.currency); const isPartiallyPaid = !!report?.pendingFields?.partial; const subAmountTextStyles: StyleProp = [ @@ -70,25 +83,25 @@ function MoneyReportView({report, policy, isCombinedReport = false, shouldShowTo StyleUtils.getColorStyle(theme.textSupporting), ]; - const [violations] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_VIOLATIONS}${report.reportID}`); + const [violations] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_VIOLATIONS}${report?.reportID}`); const sortedPolicyReportFields = useMemo((): PolicyReportField[] => { - const fields = ReportUtils.getAvailableReportFields(report, Object.values(policy?.fieldList ?? {})); - return fields.filter((field) => field.target === report.type).sort(({orderWeight: firstOrderWeight}, {orderWeight: secondOrderWeight}) => firstOrderWeight - secondOrderWeight); + const fields = getAvailableReportFields(report, Object.values(policy?.fieldList ?? {})); + return fields.filter((field) => field.target === report?.type).sort(({orderWeight: firstOrderWeight}, {orderWeight: secondOrderWeight}) => firstOrderWeight - secondOrderWeight); }, [policy, report]); - const enabledReportFields = sortedPolicyReportFields.filter((reportField) => !ReportUtils.isReportFieldDisabled(report, reportField, policy)); - const isOnlyTitleFieldEnabled = enabledReportFields.length === 1 && ReportUtils.isReportFieldOfTypeTitle(enabledReportFields.at(0)); - const isClosedExpenseReportWithNoExpenses = ReportUtils.isClosedExpenseReportWithNoExpenses(report); - const isPaidGroupPolicyExpenseReport = ReportUtils.isPaidGroupPolicyExpenseReport(report); - const isInvoiceReport = ReportUtils.isInvoiceReport(report); + const enabledReportFields = sortedPolicyReportFields.filter((reportField) => !isReportFieldDisabled(report, reportField, policy)); + const isOnlyTitleFieldEnabled = enabledReportFields.length === 1 && isReportFieldOfTypeTitle(enabledReportFields.at(0)); + const isClosedExpenseReportWithNoExpenses = isClosedExpenseReportWithNoExpensesReportUtils(report); + const isPaidGroupPolicyExpenseReport = isPaidGroupPolicyExpenseReportUtils(report); + const isInvoiceReport = isInvoiceReportUtils(report); const shouldShowReportField = !isClosedExpenseReportWithNoExpenses && (isPaidGroupPolicyExpenseReport || isInvoiceReport) && (!isCombinedReport || !isOnlyTitleFieldEnabled); const renderThreadDivider = useMemo( () => shouldHideThreadDividerLine && !isCombinedReport ? ( ) : ( @@ -97,7 +110,7 @@ function MoneyReportView({report, policy, isCombinedReport = false, shouldShowTo style={[!shouldHideThreadDividerLine ? styles.reportHorizontalRule : {}]} /> ), - [shouldHideThreadDividerLine, report.reportID, styles.reportHorizontalRule, isCombinedReport], + [shouldHideThreadDividerLine, report?.reportID, styles.reportHorizontalRule, isCombinedReport], ); return ( @@ -110,39 +123,34 @@ function MoneyReportView({report, policy, isCombinedReport = false, shouldShowTo policy?.areReportFieldsEnabled && (!isCombinedReport || !isOnlyTitleFieldEnabled) && sortedPolicyReportFields.map((reportField) => { - if (ReportUtils.isReportFieldOfTypeTitle(reportField)) { + if (isReportFieldOfTypeTitle(reportField)) { return null; } const fieldValue = reportField.value ?? reportField.defaultValue; - const isFieldDisabled = ReportUtils.isReportFieldDisabled(report, reportField, policy); - const fieldKey = ReportUtils.getReportFieldKey(reportField.fieldID); + const isFieldDisabled = isReportFieldDisabled(report, reportField, policy); + const fieldKey = getReportFieldKey(reportField.fieldID); - const violation = ReportUtils.getFieldViolation(violations, reportField); - const violationTranslation = ReportUtils.getFieldViolationTranslation(reportField, violation); + const violation = getFieldViolation(violations, reportField); + const violationTranslation = getFieldViolationTranslation(reportField, violation); return ( reportActions.clearReportFieldKeyErrors(report.reportID, fieldKey)} + onClose={() => clearReportFieldKeyErrors(report?.reportID, fieldKey)} > + onPress={() => { Navigation.navigate( - ROUTES.EDIT_REPORT_FIELD_REQUEST.getRoute( - report.reportID, - report.policyID ?? '-1', - reportField.fieldID, - Navigation.getReportRHPActiveRoute(), - ), - ) - } + ROUTES.EDIT_REPORT_FIELD_REQUEST.getRoute(report?.reportID, report?.policyID, reportField.fieldID, Navigation.getReportRHPActiveRoute()), + ); + }} shouldShowRightIcon disabled={isFieldDisabled} wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} diff --git a/src/components/ReportActionItem/MoneyRequestAction.tsx b/src/components/ReportActionItem/MoneyRequestAction.tsx index af54e2940d3f..2936fddd0376 100644 --- a/src/components/ReportActionItem/MoneyRequestAction.tsx +++ b/src/components/ReportActionItem/MoneyRequestAction.tsx @@ -1,14 +1,18 @@ import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import RenderHTML from '@components/RenderHTML'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as IOUUtils from '@libs/IOUUtils'; +import {isIOUReportPendingCurrencyConversion} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; -import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import { + isDeletedParentAction as isDeletedParentActionReportActionsUtils, + isReversedTransaction as isReversedTransactionReportActionsUtils, + isSplitBillAction as isSplitBillActionReportActionsUtils, + isTrackExpenseAction as isTrackExpenseActionReportActionsUtils, +} from '@libs/ReportActionsUtils'; import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -18,29 +22,18 @@ import type * as OnyxTypes from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import MoneyRequestPreview from './MoneyRequestPreview'; -type MoneyRequestActionOnyxProps = { - /** Chat report associated with iouReport */ - chatReport: OnyxEntry; - - /** IOU report data object */ - iouReport: OnyxEntry; - - /** Report actions for this report */ - reportActions: OnyxEntry; -}; - -type MoneyRequestActionProps = MoneyRequestActionOnyxProps & { +type MoneyRequestActionProps = { /** All the data of the action */ action: OnyxTypes.ReportAction; /** The ID of the associated chatReport */ - chatReportID: string; + chatReportID: string | undefined; /** The ID of the associated expense report */ - requestReportID: string; + requestReportID: string | undefined; /** The ID of the current report */ - reportID: string; + reportID: string | undefined; /** Is this IOUACTION the most recent? */ isMostRecentIOUReportAction: boolean; @@ -72,34 +65,33 @@ function MoneyRequestAction({ isMostRecentIOUReportAction, contextMenuAnchor, checkIfContextMenuActive = () => {}, - chatReport, - iouReport, - reportActions, isHovered = false, style, isWhisper = false, shouldDisplayContextMenu = true, }: MoneyRequestActionProps) { + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`); + const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${requestReportID}`); + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, {canEvict: false}); + const styles = useThemeStyles(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const isSplitBillAction = ReportActionsUtils.isSplitBillAction(action); - const isTrackExpenseAction = ReportActionsUtils.isTrackExpenseAction(action); + const isSplitBillAction = isSplitBillActionReportActionsUtils(action); + const isTrackExpenseAction = isTrackExpenseActionReportActionsUtils(action); const onMoneyRequestPreviewPressed = () => { if (isSplitBillAction) { - const reportActionID = action.reportActionID ?? '-1'; - Navigation.navigate(ROUTES.SPLIT_BILL_DETAILS.getRoute(chatReportID, reportActionID, Navigation.getReportRHPActiveRoute())); + Navigation.navigate(ROUTES.SPLIT_BILL_DETAILS.getRoute(chatReportID, action.reportActionID, Navigation.getReportRHPActiveRoute())); return; } - const childReportID = action?.childReportID ?? '-1'; - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID)); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(action?.childReportID)); }; let shouldShowPendingConversionMessage = false; - const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(action); - const isReversedTransaction = ReportActionsUtils.isReversedTransaction(action); + const isDeletedParentAction = isDeletedParentActionReportActionsUtils(action); + const isReversedTransaction = isReversedTransactionReportActionsUtils(action); if ( !isEmptyObject(iouReport) && !isEmptyObject(reportActions) && @@ -108,7 +100,7 @@ function MoneyRequestAction({ action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && isOffline ) { - shouldShowPendingConversionMessage = IOUUtils.isIOUReportPendingCurrencyConversion(iouReport); + shouldShowPendingConversionMessage = isIOUReportPendingCurrencyConversion(iouReport); } if (isDeletedParentAction || isReversedTransaction) { @@ -118,7 +110,7 @@ function MoneyRequestAction({ } else { message = 'parentReportAction.deletedExpense'; } - return ${translate(message)}`} />; + return ${translate(message)}`} />; } return ( ({ - chatReport: { - key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, - }, - iouReport: { - key: ({requestReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${requestReportID}`, - }, - reportActions: { - key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, - canEvict: false, - }, -})(MoneyRequestAction); +export default MoneyRequestAction; diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 17874c9dd148..e20fe09058e1 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -103,9 +103,9 @@ function MoneyRequestPreviewContent({ const route = useRoute>(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID || CONST.DEFAULT_NUMBER_ID}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`); const [session] = useOnyx(ONYXKEYS.SESSION); - const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID || CONST.DEFAULT_NUMBER_ID}`); + const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`); const policy = usePolicy(iouReport?.policyID); const isMoneyRequestAction = isMoneyRequestActionReportActionsUtils(action); diff --git a/src/components/ReportActionItem/MoneyRequestPreview/index.tsx b/src/components/ReportActionItem/MoneyRequestPreview/index.tsx index f902948b2cb5..0b9d4e5f5629 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/index.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/index.tsx @@ -6,7 +6,7 @@ import MoneyRequestPreviewContent from './MoneyRequestPreviewContent'; import type {MoneyRequestPreviewProps} from './types'; function MoneyRequestPreview(props: MoneyRequestPreviewProps) { - const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${props.iouReportID || '-1'}`); + const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${props.iouReportID}`); // We should not render the component if there is no iouReport and it's not a split or track expense. // Moved outside of the component scope to allow for easier use of hooks in the main component. // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/src/components/ReportActionItem/MoneyRequestPreview/types.ts b/src/components/ReportActionItem/MoneyRequestPreview/types.ts index c40b45c6d2bd..186c81a8c866 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/types.ts +++ b/src/components/ReportActionItem/MoneyRequestPreview/types.ts @@ -7,13 +7,13 @@ type MoneyRequestPreviewProps = { /** The active IOUReport, used for Onyx subscription */ // The iouReportID is used inside withOnyx HOC // eslint-disable-next-line react/no-unused-prop-types - iouReportID: string; + iouReportID: string | undefined; /** The associated chatReport */ - chatReportID: string; + chatReportID: string | undefined; /** The ID of the current report */ - reportID: string; + reportID: string | undefined; /** Callback for the preview pressed */ onPreviewPressed: (event?: GestureResponderEvent | KeyboardEvent) => void; diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 423bb6434a4c..4616feeffbca 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -70,13 +70,12 @@ import StringUtils from '@libs/StringUtils'; import { getDescription, getMerchant, - getTransactionViolations, - hasPendingUI, isCardTransaction, isPartialMerchant, isPending, isReceiptBeingScanned, shouldShowBrokenConnectionViolation as shouldShowBrokenConnectionViolationTransactionUtils, + shouldShowRTERViolationMessage, } from '@libs/TransactionUtils'; import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import variables from '@styles/variables'; @@ -97,13 +96,13 @@ type ReportPreviewProps = { action: ReportAction; /** The associated chatReport */ - chatReportID: string; + chatReportID: string | undefined; /** The active IOUReport, used for Onyx subscription */ iouReportID: string | undefined; /** The report's policyID, used for Onyx subscription */ - policyID: string; + policyID: string | undefined; /** Extra styles to pass to View wrapper */ containerStyles?: StyleProp; @@ -145,7 +144,6 @@ function ReportPreview({ const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`); const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { selector: (_transactions) => reportTransactionsSelector(_transactions, iouReportID), - initialValue: [], }); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET); @@ -237,7 +235,7 @@ function ReportPreview({ const lastThreeTransactions = transactions?.slice(-3) ?? []; const lastTransaction = transactions?.at(0); const lastThreeReceipts = lastThreeTransactions.map((transaction) => ({...getThumbnailAndImageURIs(transaction), transaction})); - const showRTERViolationMessage = numberOfRequests === 1 && hasPendingUI(lastTransaction, getTransactionViolations(lastTransaction?.transactionID, transactionViolations)); + const showRTERViolationMessage = shouldShowRTERViolationMessage(transactions); const transactionIDList = [lastTransaction?.transactionID].filter((transactionID): transactionID is string => transactionID !== undefined); const shouldShowBrokenConnectionViolation = numberOfRequests === 1 && shouldShowBrokenConnectionViolationTransactionUtils(transactionIDList, iouReport, policy); let formattedMerchant = numberOfRequests === 1 ? getMerchant(lastTransaction) : null; diff --git a/src/components/ReportActionItem/TaskPreview.tsx b/src/components/ReportActionItem/TaskPreview.tsx index 2ea295d16143..3eca7561eb04 100644 --- a/src/components/ReportActionItem/TaskPreview.tsx +++ b/src/components/ReportActionItem/TaskPreview.tsx @@ -20,15 +20,15 @@ import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {checkIfActionIsAllowed} from '@libs/actions/Session'; +import {canActionTask, completeTask, getTaskAssigneeAccountID, reopenTask} from '@libs/actions/Task'; import ControlSelection from '@libs/ControlSelection'; -import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getButtonState from '@libs/getButtonState'; import Navigation from '@libs/Navigation/Navigation'; -import * as ReportUtils from '@libs/ReportUtils'; -import * as TaskUtils from '@libs/TaskUtils'; +import {isCanceledTaskReport, isOpenTaskReport, isReportManager} from '@libs/ReportUtils'; +import {getTaskTitleFromReport} from '@libs/TaskUtils'; import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; -import * as Session from '@userActions/Session'; -import * as Task from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -38,9 +38,9 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; type TaskPreviewProps = WithCurrentUserPersonalDetailsProps & { /** The ID of the associated policy */ // eslint-disable-next-line react/no-unused-prop-types - policyID: string; + policyID: string | undefined; /** The ID of the associated taskReport */ - taskReportID: string; + taskReportID: string | undefined; /** Whether the task preview is hovered so we can modify its style */ isHovered: boolean; @@ -49,7 +49,7 @@ type TaskPreviewProps = WithCurrentUserPersonalDetailsProps & { action: OnyxEntry; /** The chat report associated with taskReport */ - chatReportID: string; + chatReportID: string | undefined; /** Popover context menu anchor, used for showing context menu */ contextMenuAnchor: ContextMenuAnchor; @@ -74,27 +74,27 @@ function TaskPreview({taskReportID, action, contextMenuAnchor, chatReportID, che const isTaskCompleted = !isEmptyObject(taskReport) ? 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 ?? CONST.DEFAULT_NUMBER_ID; + const taskTitle = Str.htmlEncode(getTaskTitleFromReport(taskReport, action?.childReportName ?? '')); + const taskAssigneeAccountID = 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; const avatarSize = CONST.AVATAR_SIZE.SMALL; - const isDeletedParentAction = ReportUtils.isCanceledTaskReport(taskReport, action); + const isDeletedParentAction = isCanceledTaskReport(taskReport, action); const iconWrapperStyle = StyleUtils.getTaskPreviewIconWrapper(hasAssignee ? avatarSize : undefined); const titleStyle = StyleUtils.getTaskPreviewTitleStyle(iconWrapperStyle.height, isTaskCompleted); - const shouldShowGreenDotIndicator = ReportUtils.isOpenTaskReport(taskReport, action) && ReportUtils.isReportManager(taskReport); + const shouldShowGreenDotIndicator = isOpenTaskReport(taskReport, action) && isReportManager(taskReport); if (isDeletedParentAction) { - return ${translate('parentReportAction.deletedTask')}`} />; + return ${translate('parentReportAction.deletedTask')}`} />; } return ( Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(taskReportID))} - onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} + onPressIn={() => canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} onLongPress={(event) => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive)} shouldUseHapticsOnLongPress @@ -107,12 +107,12 @@ function TaskPreview({taskReportID, action, contextMenuAnchor, chatReportID, che { + disabled={!canActionTask(taskReport, currentUserPersonalDetails.accountID, taskOwnerAccountID, taskAssigneeAccountID)} + onPress={checkIfActionIsAllowed(() => { if (isTaskCompleted) { - Task.reopenTask(taskReport, taskReportID); + reopenTask(taskReport, taskReportID); } else { - Task.completeTask(taskReport, taskReportID); + completeTask(taskReport, taskReportID); } })} accessibilityLabel={translate('task.task')} diff --git a/src/components/ReportActionItem/TaskView.tsx b/src/components/ReportActionItem/TaskView.tsx index 7901426b33e0..3e077c2bda4a 100644 --- a/src/components/ReportActionItem/TaskView.tsx +++ b/src/components/ReportActionItem/TaskView.tsx @@ -1,5 +1,6 @@ import React, {useEffect} from 'react'; import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import Checkbox from '@components/Checkbox'; import Hoverable from '@components/Hoverable'; import Icon from '@components/Icon'; @@ -17,18 +18,18 @@ import useThemeStyles from '@hooks/useThemeStyles'; import convertToLTR from '@libs/convertToLTR'; import getButtonState from '@libs/getButtonState'; import Navigation from '@libs/Navigation/Navigation'; -import * as OptionsListUtils from '@libs/OptionsListUtils'; -import * as ReportUtils from '@libs/ReportUtils'; -import * as TaskUtils from '@libs/TaskUtils'; -import * as Session from '@userActions/Session'; -import * as Task from '@userActions/Task'; +import {getAvatarsForAccountIDs, getPersonalDetailsForAccountIDs} from '@libs/OptionsListUtils'; +import {getDisplayNameForParticipant, getDisplayNamesWithTooltips, isCompletedTaskReport, isOpenTaskReport} from '@libs/ReportUtils'; +import {isActiveTaskEditRoute} from '@libs/TaskUtils'; +import {checkIfActionIsAllowed} from '@userActions/Session'; +import {canActionTask as canActionTaskUtil, canModifyTask as canModifyTaskUtil, clearTaskErrors, completeTask, reopenTask, setTaskReport} from '@userActions/Task'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {Report} from '@src/types/onyx'; type TaskViewProps = { /** The report currently being looked at */ - report: Report; + report: OnyxEntry; }; function TaskView({report}: TaskViewProps) { @@ -37,17 +38,14 @@ function TaskView({report}: TaskViewProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalDetails = usePersonalDetails(); useEffect(() => { - Task.setTaskReport(report); + setTaskReport(report); }, [report]); - const taskTitle = convertToLTR(report.reportName ?? ''); - const assigneeTooltipDetails = ReportUtils.getDisplayNamesWithTooltips( - OptionsListUtils.getPersonalDetailsForAccountIDs(report.managerID ? [report.managerID] : [], personalDetails), - false, - ); - const isOpen = ReportUtils.isOpenTaskReport(report); - const isCompleted = ReportUtils.isCompletedTaskReport(report); - const canModifyTask = Task.canModifyTask(report, currentUserPersonalDetails.accountID); - const canActionTask = Task.canActionTask(report, currentUserPersonalDetails.accountID); + const taskTitle = convertToLTR(report?.reportName ?? ''); + const assigneeTooltipDetails = getDisplayNamesWithTooltips(getPersonalDetailsForAccountIDs(report?.managerID ? [report?.managerID] : [], personalDetails), false); + const isOpen = isOpenTaskReport(report); + const isCompleted = isCompletedTaskReport(report); + const canModifyTask = canModifyTaskUtil(report, currentUserPersonalDetails.accountID); + const canActionTask = canActionTaskUtil(report, currentUserPersonalDetails.accountID); const disableState = !canModifyTask; const isDisableInteractive = !canModifyTask || !isOpen; const {translate} = useLocalize(); @@ -56,14 +54,14 @@ function TaskView({report}: TaskViewProps) { Task.clearTaskErrors(report.reportID)} + errors={report?.errorFields?.editTask ?? report?.errorFields?.createTask} + onClose={() => clearTaskErrors(report?.reportID)} errorRowStyles={styles.ph5} > {(hovered) => ( { + onPress={checkIfActionIsAllowed((e) => { if (isDisableInteractive) { return; } @@ -71,7 +69,7 @@ function TaskView({report}: TaskViewProps) { (e.currentTarget as HTMLElement).blur(); } - Navigation.navigate(ROUTES.TASK_TITLE.getRoute(report.reportID, Navigation.getReportRHPActiveRoute())); + Navigation.navigate(ROUTES.TASK_TITLE.getRoute(report?.reportID, Navigation.getReportRHPActiveRoute())); })} style={({pressed}) => [ styles.ph5, @@ -83,19 +81,19 @@ function TaskView({report}: TaskViewProps) { disabled={isDisableInteractive} > {({pressed}) => ( - + {translate('task.title')} { + onPress={checkIfActionIsAllowed(() => { // If we're already navigating to these task editing pages, early return not to mark as completed, otherwise we would have not found page. - if (TaskUtils.isActiveTaskEditRoute(report.reportID)) { + if (isActiveTaskEditRoute(report?.reportID)) { return; } if (isCompleted) { - Task.reopenTask(report); + reopenTask(report); } else { - Task.completeTask(report); + completeTask(report); } })} isChecked={isCompleted} @@ -129,12 +127,12 @@ function TaskView({report}: TaskViewProps) { )} - + Navigation.navigate(ROUTES.REPORT_DESCRIPTION.getRoute(report.reportID, Navigation.getReportRHPActiveRoute()))} + title={report?.description ?? ''} + onPress={() => Navigation.navigate(ROUTES.REPORT_DESCRIPTION.getRoute(report?.reportID, Navigation.getReportRHPActiveRoute()))} shouldShowRightIcon={!isDisableInteractive} disabled={disableState} wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} @@ -144,16 +142,16 @@ function TaskView({report}: TaskViewProps) { shouldUseDefaultCursorWhenDisabled /> - - {report.managerID ? ( + + {report?.managerID ? ( Navigation.navigate(ROUTES.TASK_ASSIGNEE.getRoute(report.reportID, Navigation.getReportRHPActiveRoute()))} + onPress={() => Navigation.navigate(ROUTES.TASK_ASSIGNEE.getRoute(report?.reportID, Navigation.getReportRHPActiveRoute()))} shouldShowRightIcon={!isDisableInteractive} disabled={disableState} wrapperStyle={[styles.pv2]} @@ -166,7 +164,7 @@ function TaskView({report}: TaskViewProps) { ) : ( Navigation.navigate(ROUTES.TASK_ASSIGNEE.getRoute(report.reportID, Navigation.getReportRHPActiveRoute()))} + onPress={() => Navigation.navigate(ROUTES.TASK_ASSIGNEE.getRoute(report?.reportID, Navigation.getReportRHPActiveRoute()))} shouldShowRightIcon={!isDisableInteractive} disabled={disableState} wrapperStyle={[styles.pv2]} diff --git a/src/components/ReportActionItem/TripRoomPreview.tsx b/src/components/ReportActionItem/TripRoomPreview.tsx index 8c275464804c..d85c19d21ee0 100644 --- a/src/components/ReportActionItem/TripRoomPreview.tsx +++ b/src/components/ReportActionItem/TripRoomPreview.tsx @@ -1,5 +1,5 @@ import React, {useMemo} from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; +import type {ListRenderItemInfo, StyleProp, ViewStyle} from 'react-native'; import {FlatList, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -36,7 +36,7 @@ type TripRoomPreviewProps = { action: ReportAction; /** The associated chatReport */ - chatReportID: string; + chatReportID: string | undefined; /** Extra styles to pass to View wrapper */ containerStyles?: StyleProp; @@ -111,7 +111,7 @@ function ReservationView({reservation}: ReservationViewProps) { ); } -const renderItem = ({item}: {item: ReservationData}) => ; +const renderItem = ({item}: ListRenderItemInfo) => ; function TripRoomPreview({action, chatReportID, containerStyles, contextMenuAnchor, isHovered = false, checkIfContextMenuActive = () => {}}: TripRoomPreviewProps) { const styles = useThemeStyles(); diff --git a/src/components/SelectionList/BaseSelectionListItemRenderer.tsx b/src/components/SelectionList/BaseSelectionListItemRenderer.tsx index 204b147508b4..987a72e025c1 100644 --- a/src/components/SelectionList/BaseSelectionListItemRenderer.tsx +++ b/src/components/SelectionList/BaseSelectionListItemRenderer.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {NativeSyntheticEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import type useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import type useSingleExecution from '@hooks/useSingleExecution'; +import {isMobileChrome} from '@libs/Browser'; import {isReportListItemType} from '@libs/SearchUIUtils'; -import type {BaseListItemProps, BaseSelectionListProps, ListItem} from './types'; +import type {BaseListItemProps, BaseSelectionListProps, ExtendedTargetedEvent, ListItem} from './types'; type BaseSelectionListItemRendererProps = Omit, 'onSelectRow'> & Pick, 'ListItem' | 'shouldHighlightSelectedItem' | 'shouldIgnoreFocus' | 'shouldSingleExecuteRowSelect'> & { @@ -77,11 +78,15 @@ function BaseSelectionListItemRenderer({ isMultilineSupported={isMultilineSupported} isAlternateTextMultilineSupported={isAlternateTextMultilineSupported} alternateTextNumberOfLines={alternateTextNumberOfLines} - onFocus={() => { + onFocus={(event: NativeSyntheticEvent) => { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (shouldIgnoreFocus || isDisabled) { return; } + // Prevent unexpected scrolling on mobile Chrome after the context menu closes by ignoring programmatic focus not triggered by direct user interaction. + if (isMobileChrome() && event.nativeEvent && !event.nativeEvent.sourceCapabilities) { + return; + } setFocusedIndex(normalizedIndex); }} shouldSyncFocus={shouldSyncFocus} diff --git a/src/components/SelectionList/Search/SearchQueryListItem.tsx b/src/components/SelectionList/Search/SearchQueryListItem.tsx index 770bad4faa31..82adb668fa95 100644 --- a/src/components/SelectionList/Search/SearchQueryListItem.tsx +++ b/src/components/SelectionList/Search/SearchQueryListItem.tsx @@ -3,7 +3,7 @@ import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import Icon from '@components/Icon'; import BaseListItem from '@components/SelectionList/BaseListItem'; -import type {ListItem} from '@components/SelectionList/types'; +import type {ListItem, ListItemFocusEventHandler} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -25,7 +25,7 @@ type SearchQueryListItemProps = { isFocused?: boolean; showTooltip: boolean; onSelectRow: (item: SearchQueryItem) => void; - onFocus?: () => void; + onFocus?: ListItemFocusEventHandler; shouldSyncFocus?: boolean; }; diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index caf941911ec5..71d172d4146d 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -7,6 +7,7 @@ import type { NativeSyntheticEvent, SectionListData, StyleProp, + TargetedEvent, TextInput, TextStyle, ViewStyle, @@ -84,12 +85,19 @@ type CommonListItemProps = { alternateTextNumberOfLines?: number; /** Handles what to do when the item is focused */ - onFocus?: () => void; + onFocus?: ListItemFocusEventHandler; /** Callback to fire when the item is long pressed */ onLongPressRow?: (item: TItem) => void; } & TRightHandSideComponent; +type ListItemFocusEventHandler = (event: NativeSyntheticEvent) => void; + +type ExtendedTargetedEvent = TargetedEvent & { + /** Provides information about the input device responsible for the event, or null if triggered programmatically, available in some browsers */ + sourceCapabilities?: unknown; +}; + type ListItem = { /** Text to display */ text?: string; @@ -684,10 +692,12 @@ export type { BaseSelectionListProps, ButtonOrCheckBoxRoles, CommonListItemProps, + ExtendedTargetedEvent, FlattenedSectionsReturn, InviteMemberListItemProps, ItemLayout, ListItem, + ListItemFocusEventHandler, ListItemProps, RadioListItemProps, ReportListItemProps, diff --git a/src/components/SelectionListWithModal/index.tsx b/src/components/SelectionListWithModal/index.tsx index 59e9b268bc52..a4c8b83b29d8 100644 --- a/src/components/SelectionListWithModal/index.tsx +++ b/src/components/SelectionListWithModal/index.tsx @@ -113,6 +113,7 @@ function SelectionListWithModal( isVisible={isModalVisible} type={CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED} onClose={() => setIsModalVisible(false)} + shouldPreventScrollOnFocus > ) => void; + onSubmit: (selectedTag: Partial) => void; /** Should show the selected option that is disabled? */ shouldShowDisabledAndSelectedOption?: boolean; @@ -46,8 +46,8 @@ function TagPicker({selectedTag, tagListName, policyID, tagListIndex, shouldShow const [searchValue, setSearchValue] = useState(''); const policyRecentlyUsedTagsList = useMemo(() => policyRecentlyUsedTags?.[tagListName] ?? [], [policyRecentlyUsedTags, tagListName]); - const policyTagList = PolicyUtils.getTagList(policyTags, tagListIndex); - const policyTagsCount = PolicyUtils.getCountOfEnabledTagsOfList(policyTagList.tags); + const policyTagList = getTagList(policyTags, tagListIndex); + const policyTagsCount = getCountOfEnabledTagsOfList(policyTagList.tags); const isTagsCountBelowThreshold = policyTagsCount < CONST.STANDARD_LIST_ITEM_LIMIT; const shouldShowTextInput = !isTagsCountBelowThreshold; @@ -76,7 +76,7 @@ function TagPicker({selectedTag, tagListName, policyID, tagListIndex, shouldShow }, [selectedOptions, policyTagList, shouldShowDisabledAndSelectedOption]); const sections = useMemo(() => { - const tagSections = TagOptionListUtils.getTagListSections({ + const tagSections = getTagListSections({ searchValue, selectedOptions, tags: enabledTags, @@ -90,7 +90,7 @@ function TagPicker({selectedTag, tagListName, policyID, tagListIndex, shouldShow : tagSections; }, [searchValue, selectedOptions, enabledTags, policyRecentlyUsedTagsList, shouldOrderListByTagName]); - const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList((sections?.at(0)?.data?.length ?? 0) > 0, searchValue); + const headerMessage = getHeaderMessageForNonUserList((sections?.at(0)?.data?.length ?? 0) > 0, searchValue); const selectedOptionKey = sections.at(0)?.data?.filter((policyTag) => policyTag.searchText === selectedTag)?.[0]?.keyForList; diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx index 6db4e512f225..8d1800ce3b65 100644 --- a/src/components/TextInput/BaseTextInput/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/index.native.tsx @@ -306,7 +306,7 @@ function BaseTextInput( /> ) : null} - + {!!iconLeft && ( ) : null} - + {!!iconLeft && ( , isFocused: boolean, shouldSyncFocus = true) => { +const useSyncFocus = (ref: RefObject, isFocused: boolean, shouldSyncFocus = true) => { // this hook can be used outside ScreenWrapperStatusContext (eg. in Popovers). So we to check if the context is present. // If we are outside context we don't have to look at transition status const contextValue = useContext(ScreenWrapperStatusContext); @@ -20,7 +20,7 @@ const useSyncFocus = (ref: RefObject, isFocused: boolean, shouldSyncFocus return; } - ref.current?.focus(); + ref.current?.focus({preventScroll: true}); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [didScreenTransitionEnd, isFocused, ref]); }; diff --git a/src/languages/en.ts b/src/languages/en.ts index 7c3bd33c8b2a..3b55d6fbc75c 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4974,12 +4974,12 @@ const translations = { viewAttachment: 'View attachment', }, parentReportAction: { - deletedReport: '[Deleted report]', - deletedMessage: '[Deleted message]', - deletedExpense: '[Deleted expense]', - reversedTransaction: '[Reversed transaction]', - deletedTask: '[Deleted task]', - hiddenMessage: '[Hidden message]', + deletedReport: 'Deleted report', + deletedMessage: 'Deleted message', + deletedExpense: 'Deleted expense', + reversedTransaction: 'Reversed transaction', + deletedTask: 'Deleted task', + hiddenMessage: 'Hidden message', }, threads: { thread: 'Thread', diff --git a/src/languages/es.ts b/src/languages/es.ts index 140891c7a4fa..dff3dcd575c0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5486,12 +5486,12 @@ const translations = { viewAttachment: 'Ver archivo adjunto', }, parentReportAction: { - deletedReport: '[Informe eliminado]', - deletedMessage: '[Mensaje eliminado]', - deletedExpense: '[Gasto eliminado]', - reversedTransaction: '[Transacción anulada]', - deletedTask: '[Tarea eliminada]', - hiddenMessage: '[Mensaje oculto]', + deletedReport: 'Informe eliminado', + deletedMessage: 'Mensaje eliminado', + deletedExpense: 'Gasto eliminado', + reversedTransaction: 'Transacción anulada', + deletedTask: 'Tarea eliminada', + hiddenMessage: 'Mensaje oculto', }, threads: { thread: 'Hilo', diff --git a/src/libs/API/parameters/CreateExpensifyCardParams.ts b/src/libs/API/parameters/CreateExpensifyCardParams.ts index f6aa9eb8e512..d90b7649a7d6 100644 --- a/src/libs/API/parameters/CreateExpensifyCardParams.ts +++ b/src/libs/API/parameters/CreateExpensifyCardParams.ts @@ -1,5 +1,5 @@ type CreateExpensifyCardParams = { - policyID: string; + policyID: string | undefined; assigneeEmail: string; limit: number; limitType: string; diff --git a/src/libs/API/parameters/StartIssueNewCardFlowParams.ts b/src/libs/API/parameters/StartIssueNewCardFlowParams.ts index 8ed04b756a10..8bff276b9cd0 100644 --- a/src/libs/API/parameters/StartIssueNewCardFlowParams.ts +++ b/src/libs/API/parameters/StartIssueNewCardFlowParams.ts @@ -1,5 +1,5 @@ type StartIssueNewCardFlowParams = { - policyID: string; + policyID: string | undefined; }; export default StartIssueNewCardFlowParams; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 1d697af554f5..81e77d565db3 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -287,8 +287,8 @@ function getCardFeedIcon(cardFeed: CompanyCardFeed | typeof CONST.EXPENSIFY_CARD /** * Verify if the feed is a custom feed. Those are also refered to as commercial feeds. */ -function isCustomFeed(feed: CompanyCardFeed): boolean { - return [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX].some((value) => value === feed); +function isCustomFeed(feed: CompanyCardFeedWithNumber): boolean { + return [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX].some((value) => feed.startsWith(value)); } function getCompanyFeeds(cardFeeds: OnyxEntry, shouldFilterOutRemovedFeeds = false): CompanyFeeds { diff --git a/src/libs/Fullstory/index.native.ts b/src/libs/Fullstory/index.native.ts index 930c12241b78..d1f1bc2223d2 100644 --- a/src/libs/Fullstory/index.native.ts +++ b/src/libs/Fullstory/index.native.ts @@ -1,6 +1,6 @@ import FullStory, {FSPage} from '@fullstory/react-native'; +import {Str} from 'expensify-common'; import type {OnyxEntry} from 'react-native-onyx'; -import {isExpensifyTeam} from '@libs/PolicyUtils'; import {isConciergeChatReport, shouldUnmaskChat} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import * as Environment from '@src/libs/Environment/Environment'; @@ -43,7 +43,7 @@ const FS = { // UserMetadata onyx key. Environment.getEnvironment().then((envName: string) => { const isTestEmail = value.email !== undefined && value.email.startsWith('fullstory') && value.email.endsWith(CONST.EMAIL.QA_DOMAIN); - if ((CONST.ENVIRONMENT.PRODUCTION !== envName && !isTestEmail) || isExpensifyTeam(value?.email)) { + if ((CONST.ENVIRONMENT.PRODUCTION !== envName && !isTestEmail) || Str.extractEmailDomain(value.email ?? '') === CONST.EXPENSIFY_PARTNER_NAME) { return; } FullStory.restart(); diff --git a/src/libs/Fullstory/index.ts b/src/libs/Fullstory/index.ts index c7b1c2c9eb7a..4b6d11ee15e8 100644 --- a/src/libs/Fullstory/index.ts +++ b/src/libs/Fullstory/index.ts @@ -1,7 +1,8 @@ import {FullStory, init, isInitialized} from '@fullstory/browser'; +import {Str} from 'expensify-common'; import type {OnyxEntry} from 'react-native-onyx'; -import {isExpensifyTeam} from '@libs/PolicyUtils'; import {isConciergeChatReport, shouldUnmaskChat} from '@libs/ReportUtils'; +import * as Session from '@userActions/Session'; import CONST from '@src/CONST'; import * as Environment from '@src/libs/Environment/Environment'; import type {OnyxInputOrEntry, PersonalDetailsList, Report, UserMetadata} from '@src/types/onyx'; @@ -103,20 +104,26 @@ const FS = { new Promise((resolve) => { if (!isInitialized()) { init({orgId: ''}, resolve); + + // FS init function might have a race condition with the head snippet. If the head snipped is loaded first, + // then the init function will not call the resolve function, and we'll never identify the user logging in, + // and we need to call resolve manually. We're adding a 1s timeout to make sure the init function has enough + // time to call the resolve function in case it ran successfully. + setTimeout(resolve, 1000); } else { - FullStory('observe', {type: 'start', callback: resolve}); + FullStory(CONST.FULL_STORY.OBSERVE, {type: 'start', callback: resolve}); } }), /** * Sets the identity as anonymous using the FullStory library. */ - anonymize: () => FullStory('setIdentity', {anonymous: true}), + anonymize: () => FullStory(CONST.FULL_STORY.SET_IDENTITY, {anonymous: true}), /** * Sets the identity consent status using the FullStory library. */ - consent: (c: boolean) => FullStory('setIdentity', {consent: c}), + consent: (c: boolean) => FullStory(CONST.FULL_STORY.SET_IDENTITY, {consent: c}), /** * Initializes the FullStory metadata with the provided metadata information. @@ -130,9 +137,18 @@ const FS = { try { Environment.getEnvironment().then((envName: string) => { const isTestEmail = value.email !== undefined && value.email.startsWith('fullstory') && value.email.endsWith(CONST.EMAIL.QA_DOMAIN); - if ((CONST.ENVIRONMENT.PRODUCTION !== envName && !isTestEmail) || isExpensifyTeam(value?.email)) { + if ( + (CONST.ENVIRONMENT.PRODUCTION !== envName && !isTestEmail) || + Str.extractEmailDomain(value.email ?? '') === CONST.EXPENSIFY_PARTNER_NAME || + Session.isSupportAuthToken() + ) { + // On web, if we started FS at some point in a browser, it will run forever. So let's shut it down if we don't want it to run. + if (isInitialized()) { + FullStory(CONST.FULL_STORY.SHUTDOWN); + } return; } + FullStory(CONST.FULL_STORY.RESTART); FS.onReady().then(() => { FS.consent(true); const localMetadata = value; @@ -151,7 +167,7 @@ const FS = { * If the metadata contains an accountID, the user identity is defined with it. */ fsIdentify: (metadata: UserMetadata) => { - FullStory('setIdentity', { + FullStory(CONST.FULL_STORY.SET_IDENTITY, { uid: String(metadata.accountID), properties: metadata, }); diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 0b31401d7e25..1e5e5027dc4f 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -533,6 +533,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite').default, [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD]: () => require('../../../../pages/workspace/companyCards/assignCard/AssignCardFeedPage').default, [SCREENS.WORKSPACE.COMPANY_CARDS_SELECT_FEED]: () => require('../../../../pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage').default, + [SCREENS.WORKSPACE.COMPANY_CARDS_BANK_CONNECTION]: () => require('../../../../pages/workspace/companyCards/addNew/BankConnection').default, [SCREENS.WORKSPACE.COMPANY_CARDS_ADD_NEW]: () => require('../../../../pages/workspace/companyCards/addNew/AddNewCardPage').default, [SCREENS.WORKSPACE.COMPANY_CARD_DETAILS]: () => require('../../../../pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage').default, [SCREENS.WORKSPACE.COMPANY_CARD_NAME]: () => require('../../../../pages/workspace/companyCards/WorkspaceCompanyCardEditCardNamePage').default, diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx index c72c4de01e4e..775e60666bef 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx @@ -2,6 +2,7 @@ import React from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Breadcrumbs from '@components/Breadcrumbs'; +import LoadingBar from '@components/LoadingBar'; import {PressableWithoutFeedback} from '@components/Pressable'; import SearchButton from '@components/Search/SearchRouter/SearchButton'; import Text from '@components/Text'; @@ -29,6 +30,7 @@ function TopBar({breadcrumbLabel, activeWorkspaceID, shouldDisplaySearch = true, const {translate} = useLocalize(); const policy = usePolicy(activeWorkspaceID); const [session] = useOnyx(ONYXKEYS.SESSION, {selector: (sessionValue) => sessionValue && {authTokenType: sessionValue.authTokenType}}); + const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); const isAnonymousUser = Session.isAnonymousUser(session); const headerBreadcrumb = policy?.name @@ -74,6 +76,7 @@ function TopBar({breadcrumbLabel, activeWorkspaceID, shouldDisplaySearch = true, )} {displaySearch && } + ); } diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index 2c3b060e0835..7865993d08e9 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -213,6 +213,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.COMPANY_CARDS_NAME, SCREENS.WORKSPACE.COMPANY_CARDS_DETAILS, SCREENS.WORKSPACE.COMPANY_CARDS_SELECT_FEED, + SCREENS.WORKSPACE.COMPANY_CARDS_BANK_CONNECTION, SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS, SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS_FEED_NAME, SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 3d25dcb924c0..9b7061e09ccc 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1,6 +1,5 @@ import type {LinkingOptions} from '@react-navigation/native'; import type {RootStackParamList} from '@navigation/types'; -import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES from '@src/ROUTES'; import type {Screen} from '@src/SCREENS'; @@ -36,10 +35,12 @@ const config: LinkingOptions['config'] = { // If params are defined, but reportID is explicitly undefined, we will get the url /r/undefined. // We want to avoid that situation, so we will return an empty string instead. parse: { - reportID: (reportID: string | undefined) => reportID ?? CONST.EMPTY_STRING, + // eslint-disable-next-line + reportID: (reportID: string | undefined) => reportID ?? '', }, stringify: { - reportID: (reportID: string | undefined) => reportID ?? CONST.EMPTY_STRING, + // eslint-disable-next-line + reportID: (reportID: string | undefined) => reportID ?? '', }, }, [SCREENS.SETTINGS.PROFILE.ROOT]: { @@ -619,6 +620,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.COMPANY_CARDS_SELECT_FEED]: { path: ROUTES.WORKSPACE_COMPANY_CARDS_SELECT_FEED.route, }, + [SCREENS.WORKSPACE.COMPANY_CARDS_BANK_CONNECTION]: { + path: ROUTES.WORKSPACE_COMPANY_CARDS_BANK_CONNECTION.route, + }, [SCREENS.WORKSPACE.COMPANY_CARD_DETAILS]: { path: ROUTES.WORKSPACE_COMPANY_CARD_DETAILS.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 67752a152941..1de3e11f08e4 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -822,6 +822,11 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.COMPANY_CARDS_SELECT_FEED]: { policyID: string; }; + [SCREENS.WORKSPACE.COMPANY_CARDS_BANK_CONNECTION]: { + policyID: string; + bankName: string; + backTo: Routes; + }; [SCREENS.WORKSPACE.COMPANY_CARD_DETAILS]: { policyID: string; bank: CompanyCardFeed; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index c704b588d0cb..a8e23888db0d 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -127,6 +127,7 @@ import { shouldReportShowSubscript, } from './ReportUtils'; import type {OptionData} from './ReportUtils'; +import StringUtils from './StringUtils'; import {getTaskCreatedMessage, getTaskReportActionMessage} from './TaskUtils'; import {generateAccountID} from './UserUtils'; @@ -1839,29 +1840,31 @@ function filteredPersonalDetailsOfRecentReports(recentReports: OptionData[], per * Filters options based on the search input value */ function filterReports(reports: OptionData[], searchTerms: string[]): OptionData[] { + const normalizedSearchTerms = searchTerms.map((term) => StringUtils.normalizeAccents(term)); // We search eventually for multiple whitespace separated search terms. // We start with the search term at the end, and then narrow down those filtered search results with the next search term. // We repeat (reduce) this until all search terms have been used: - const filteredReports = searchTerms.reduceRight( + const filteredReports = normalizedSearchTerms.reduceRight( (items, term) => filterArrayByMatch(items, term, (item) => { const values: string[] = []; if (item.text) { - values.push(item.text); + values.push(StringUtils.normalizeAccents(item.text)); + values.push(StringUtils.normalizeAccents(item.text).replace(/['-]/g, '')); } if (item.login) { - values.push(item.login); - values.push(item.login.replace(CONST.EMAIL_SEARCH_REGEX, '')); + values.push(StringUtils.normalizeAccents(item.login)); + values.push(StringUtils.normalizeAccents(item.login.replace(CONST.EMAIL_SEARCH_REGEX, ''))); } if (item.isThread) { if (item.alternateText) { - values.push(item.alternateText); + values.push(StringUtils.normalizeAccents(item.alternateText)); } } else if (!!item.isChatRoom || !!item.isPolicyExpenseChat) { if (item.subtitle) { - values.push(item.subtitle); + values.push(StringUtils.normalizeAccents(item.subtitle)); } } @@ -2125,6 +2128,7 @@ export { filterWorkspaceChats, orderWorkspaceOptions, filterSelfDMChat, + filterReports, }; export type {Section, SectionBase, MemberForList, Options, OptionList, SearchOption, PayeePersonalDetails, Option, OptionTree, ReportAndPersonalDetailOptions, GetUserToInviteConfig}; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 9e5e03acd1b0..7a284ee527b9 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -9,7 +9,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; import type {OnyxInputOrEntry, Policy, PolicyCategories, PolicyEmployeeList, PolicyTagLists, PolicyTags, Report, TaxRate} from '@src/types/onyx'; -import type {CardFeedData} from '@src/types/onyx/CardFeeds'; import type {ErrorFields, PendingAction, PendingFields} from '@src/types/onyx/OnyxCommon'; import type { ConnectionLastSync, @@ -1146,10 +1145,6 @@ function getWorkflowApprovalsUnavailable(policy: OnyxEntry) { return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL || !!policy?.errorFields?.approvalMode; } -function hasPolicyFeedsError(feeds: Record, feedToSkip?: string): boolean { - return Object.entries(feeds).filter(([feedName, feedData]) => feedName !== feedToSkip && !!feedData.errors).length > 0; -} - function getAllPoliciesLength() { return Object.keys(allPolicies ?? {}).length; } @@ -1233,7 +1228,6 @@ export { goBackFromInvalidPolicy, hasAccountingConnections, shouldShowSyncError, - hasPolicyFeedsError, shouldShowCustomUnitsError, shouldShowEmployeeListError, hasIntegrationAutoSync, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index dbe768199dde..c615b58f2587 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3233,10 +3233,10 @@ function getReportFieldsByPolicyID(policyID: string | undefined): Record, policyReportFields: PolicyReportField[]): PolicyReportField[] { // Get the report fields that are attached to a report. These will persist even if a field is deleted from the policy. - const reportFields = Object.values(report.fieldList ?? {}); - const reportIsSettled = isSettled(report.reportID); + const reportFields = Object.values(report?.fieldList ?? {}); + const reportIsSettled = isSettled(report?.reportID); // If the report is settled, we don't want to show any new field that gets added to the policy. if (reportIsSettled) { @@ -3270,8 +3270,7 @@ function getAvailableReportFields(report: Report, policyReportFields: PolicyRepo * Get the title for an IOU or expense chat which will be showing the payer and the amount */ function getMoneyRequestReportName(report: OnyxEntry, policy?: OnyxEntry, invoiceReceiverPolicy?: OnyxEntry): string { - const isReportSettled = isSettled(report?.reportID); - const reportFields = isReportSettled ? report?.fieldList : getReportFieldsByPolicyID(report?.policyID); + const reportFields = getReportFieldsByPolicyID(report?.policyID); const titleReportField = Object.values(reportFields ?? {}).find((reportField) => reportField?.fieldID === CONST.REPORT_FIELD_TITLE_FIELD_ID); if (titleReportField && report?.reportName && isPaidGroupPolicyExpenseReport(report)) { @@ -8305,6 +8304,9 @@ function isReportOwner(report: OnyxInputOrEntry): boolean { function isAllowedToApproveExpenseReport(report: OnyxEntry, approverAccountID?: number, reportPolicy?: OnyxEntry | SearchPolicy): boolean { const policy = reportPolicy ?? getPolicy(report?.policyID); + if (!policy?.areRulesEnabled) { + return true; + } const isOwner = (approverAccountID ?? currentUserAccountID) === report?.ownerAccountID; return !(policy?.preventSelfApproval && isOwner); } diff --git a/src/libs/TaskUtils.ts b/src/libs/TaskUtils.ts index 975c3e617ce5..ac66bdebd42c 100644 --- a/src/libs/TaskUtils.ts +++ b/src/libs/TaskUtils.ts @@ -6,7 +6,7 @@ import ROUTES from '@src/ROUTES'; import type {Report} from '@src/types/onyx'; import type {Message} from '@src/types/onyx/ReportAction'; import type ReportAction from '@src/types/onyx/ReportAction'; -import * as Localize from './Localize'; +import {translateLocal} from './Localize'; import Navigation from './Navigation/Navigation'; import {getReportActionHtml, getReportActionText} from './ReportActionsUtils'; @@ -22,7 +22,11 @@ Onyx.connect({ /** * Check if the active route belongs to task edit flow. */ -function isActiveTaskEditRoute(reportID: string): boolean { +function isActiveTaskEditRoute(reportID: string | undefined): boolean { + if (!reportID) { + return false; + } + return [ROUTES.TASK_TITLE, ROUTES.TASK_ASSIGNEE, ROUTES.REPORT_DESCRIPTION].map((route) => route.getRoute(reportID)).some(Navigation.isActiveRoute); } @@ -32,18 +36,18 @@ function isActiveTaskEditRoute(reportID: string): boolean { function getTaskReportActionMessage(action: OnyxEntry): Pick { switch (action?.actionName) { case CONST.REPORT.ACTIONS.TYPE.TASK_COMPLETED: - return {text: Localize.translateLocal('task.messages.completed')}; + return {text: translateLocal('task.messages.completed')}; case CONST.REPORT.ACTIONS.TYPE.TASK_CANCELLED: - return {text: Localize.translateLocal('task.messages.canceled')}; + return {text: translateLocal('task.messages.canceled')}; case CONST.REPORT.ACTIONS.TYPE.TASK_REOPENED: - return {text: Localize.translateLocal('task.messages.reopened')}; + return {text: translateLocal('task.messages.reopened')}; case CONST.REPORT.ACTIONS.TYPE.TASK_EDITED: return { text: getReportActionText(action), html: getReportActionHtml(action), }; default: - return {text: Localize.translateLocal('task.task')}; + return {text: translateLocal('task.task')}; } } @@ -54,15 +58,15 @@ function getTaskTitleFromReport(taskReport: OnyxEntry, fallbackTitle = ' return taskReport?.reportID && taskReport.reportName ? taskReport.reportName : fallbackTitle; } -function getTaskTitle(taskReportID: string, fallbackTitle = ''): string { +function getTaskTitle(taskReportID: string | undefined, fallbackTitle = ''): string { const taskReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`]; return getTaskTitleFromReport(taskReport, fallbackTitle); } function getTaskCreatedMessage(reportAction: OnyxEntry) { - const taskReportID = reportAction?.childReportID ?? '-1'; + const taskReportID = reportAction?.childReportID; const taskTitle = getTaskTitle(taskReportID, reportAction?.childReportName); - return taskTitle ? Localize.translateLocal('task.messages.created', {title: taskTitle}) : ''; + return taskTitle ? translateLocal('task.messages.created', {title: taskTitle}) : ''; } export {isActiveTaskEditRoute, getTaskReportActionMessage, getTaskTitle, getTaskTitleFromReport, getTaskCreatedMessage}; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index b60419090c41..ca826f202d88 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1366,6 +1366,10 @@ function getAllSortedTransactions(iouReportID?: string): Array; + + /** ID of the policy */ + policyID: string | undefined; }; function reportVirtualExpensifyCardFraud(card: Card, validateCode: string) { @@ -381,19 +384,19 @@ function getCardDefaultName(userName?: string) { return `${userName}'s Card`; } -function setIssueNewCardStepAndData({data, isEditing, step}: IssueNewCardFlowData) { - Onyx.merge(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, {data, isEditing, currentStep: step, errors: null}); +function setIssueNewCardStepAndData({data, isEditing, step, policyID}: IssueNewCardFlowData) { + Onyx.merge(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, {data, isEditing, currentStep: step, errors: null}); } -function clearIssueNewCardFlow() { - Onyx.set(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, { +function clearIssueNewCardFlow(policyID: string | undefined) { + Onyx.set(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, { currentStep: null, data: {}, }); } -function clearIssueNewCardError() { - Onyx.merge(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, {errors: null}); +function clearIssueNewCardError(policyID: string | undefined) { + Onyx.merge(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, {errors: null}); } function updateExpensifyCardLimit(workspaceAccountID: number, cardID: number, newLimit: number, newAvailableSpend: number, oldLimit?: number, oldAvailableSpend?: number) { @@ -664,7 +667,7 @@ function deactivateCard(workspaceAccountID: number, card?: Card) { API.write(WRITE_COMMANDS.CARD_DEACTIVATE, parameters, {optimisticData, failureData}); } -function startIssueNewCardFlow(policyID: string) { +function startIssueNewCardFlow(policyID: string | undefined) { const parameters: StartIssueNewCardFlowParams = { policyID, }; @@ -725,7 +728,7 @@ function configureExpensifyCardsForPolicy(policyID: string, bankAccountID?: numb }); } -function issueExpensifyCard(policyID: string, feedCountry: string, validateCode: string, data?: IssueNewCardData) { +function issueExpensifyCard(policyID: string | undefined, feedCountry: string, validateCode: string, data?: IssueNewCardData) { if (!data) { return; } @@ -735,7 +738,7 @@ function issueExpensifyCard(policyID: string, feedCountry: string, validateCode: const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, + key: `${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, value: { isLoading: true, errors: null, @@ -747,7 +750,7 @@ function issueExpensifyCard(policyID: string, feedCountry: string, validateCode: const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, + key: `${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, value: { isLoading: false, isSuccessful: true, @@ -758,7 +761,7 @@ function issueExpensifyCard(policyID: string, feedCountry: string, validateCode: const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, + key: `${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, value: { isLoading: false, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), diff --git a/src/libs/actions/Chronos.ts b/src/libs/actions/Chronos.ts index 548b8398beec..5a54c2ee9368 100644 --- a/src/libs/actions/Chronos.ts +++ b/src/libs/actions/Chronos.ts @@ -7,7 +7,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ChronosOOOEvent} from '@src/types/onyx/OriginalMessage'; -const removeEvent = (reportID: string, reportActionID: string, eventID: string, events: ChronosOOOEvent[]) => { +const removeEvent = (reportID: string | undefined, reportActionID: string, eventID: string, events: ChronosOOOEvent[]) => { const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/libs/actions/CompanyCards.ts b/src/libs/actions/CompanyCards.ts index d4a905d5ef6d..f6e6dbb3729d 100644 --- a/src/libs/actions/CompanyCards.ts +++ b/src/libs/actions/CompanyCards.ts @@ -1,4 +1,4 @@ -import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import type { @@ -18,10 +18,11 @@ import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Card, CardFeeds} from '@src/types/onyx'; +import type {Card, CardFeeds, WorkspaceCardsList} from '@src/types/onyx'; import type {AssignCard, AssignCardData} from '@src/types/onyx/AssignCard'; import type {AddNewCardFeedData, AddNewCardFeedStep, CompanyCardFeed} from '@src/types/onyx/CardFeeds'; import type {OnyxData} from '@src/types/onyx/Request'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; type AddNewCompanyCardFlowData = { /** Step to be set in Onyx */ @@ -403,8 +404,6 @@ function unassignWorkspaceCompanyCard(workspaceAccountID: number, bankName: stri function updateWorkspaceCompanyCard(workspaceAccountID: number, cardID: string, bankName: CompanyCardFeed) { const authToken = NetworkStore.getAuthToken(); - const optimisticFeedUpdates = {[bankName]: {errors: null}}; - const failureFeedUpdates = {[bankName]: {errors: {error: CONST.COMPANY_CARDS.CONNECTION_ERROR}}}; const optimisticData: OnyxUpdate[] = [ { @@ -437,13 +436,6 @@ function updateWorkspaceCompanyCard(workspaceAccountID: number, cardID: string, }, }, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`, - value: { - settings: {companyCards: optimisticFeedUpdates}, - }, - }, ]; const finallyData: OnyxUpdate[] = [ @@ -504,13 +496,6 @@ function updateWorkspaceCompanyCard(workspaceAccountID: number, cardID: string, }, }, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`, - value: { - settings: {companyCards: failureFeedUpdates}, - }, - }, ]; const parameters = { @@ -740,6 +725,41 @@ function openPolicyCompanyCardsFeed(policyID: string, feed: CompanyCardFeed) { API.read(READ_COMMANDS.OPEN_POLICY_COMPANY_CARDS_FEED, parameters); } +/** + * Takes the list of cards divided by workspaces and feeds and returns the flattened non-Expensify cards related to the provided workspace + * + * @param allCardsList the list where cards split by workspaces and feeds and stored under `card_${workspaceAccountID}_${feedName}` keys + * @param workspaceAccountID the workspace account id we want to get cards for + */ +function flatAllCardsList(allCardsList: OnyxCollection, workspaceAccountID: number): Record | undefined { + if (!allCardsList) { + return; + } + + return Object.entries(allCardsList).reduce((acc, [key, allCards]) => { + if (!key.includes(workspaceAccountID.toString()) || key.includes(CONST.EXPENSIFY_CARD.BANK)) { + return acc; + } + const {cardList, ...feedCards} = allCards ?? {}; + Object.assign(acc, feedCards); + return acc; + }, {}); +} + +/** + * Check if any feed card has a broken connection + * + * @param feedCards the list of the cards, related to one or several feeds + * @param [feedToExclude] the feed to ignore during the check, it's useful for checking broken connection error only in the feeds other than the selected one + */ +function checkIfFeedConnectionIsBroken(feedCards: Record | undefined, feedToExclude?: string): boolean { + if (!feedCards || isEmptyObject(feedCards)) { + return false; + } + + return Object.values(feedCards).some((card) => card.bank !== feedToExclude && card.lastScrapeResult !== 200); +} + export { setWorkspaceCompanyCardFeedName, deleteWorkspaceCompanyCardFeed, @@ -757,4 +777,6 @@ export { clearAddNewCardFlow, setAssignCardStepAndData, clearAssignCardStepAndData, + checkIfFeedConnectionIsBroken, + flatAllCardsList, }; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 1602e447fe89..080e9505c6d5 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -3246,6 +3246,7 @@ function getUpdateMoneyRequestParams( policyTagList: OnyxTypes.OnyxInputOrEntry, policyCategories: OnyxTypes.OnyxInputOrEntry, violations?: OnyxEntry, + hash?: number, ): UpdateMoneyRequestData { const optimisticData: OnyxUpdate[] = []; const successData: OnyxUpdate[] = []; @@ -3549,21 +3550,40 @@ function getUpdateMoneyRequestParams( if (policy && isPaidGroupPolicy(policy) && updatedTransaction && (hasModifiedTag || hasModifiedCategory || hasModifiedDistanceRate)) { const currentTransactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; - optimisticData.push( - ViolationsUtils.getViolationsOnyxData( - updatedTransaction, - currentTransactionViolations, - policy, - policyTagList ?? {}, - policyCategories ?? {}, - hasDependentTags(policy, policyTagList ?? {}), - ), + const violationsOnyxdata = ViolationsUtils.getViolationsOnyxData( + updatedTransaction, + currentTransactionViolations, + policy, + policyTagList ?? {}, + policyCategories ?? {}, + hasDependentTags(policy, policyTagList ?? {}), ); + optimisticData.push(violationsOnyxdata); failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, value: currentTransactionViolations, }); + if (hash) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, + value: { + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]: violationsOnyxdata.value, + }, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, + value: { + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]: currentTransactionViolations, + }, + }, + }); + } } // Reset the transaction thread to its original state @@ -3837,16 +3857,17 @@ function updateMoneyRequestAttendees( /** Updates the tag of an expense */ function updateMoneyRequestTag( transactionID: string, - transactionThreadReportID: string, + transactionThreadReportID: string | undefined, tag: string, policy: OnyxEntry, policyTagList: OnyxEntry, policyCategories: OnyxEntry, + hash?: number, ) { const transactionChanges: TransactionChanges = { tag, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories); + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, undefined, hash); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, params, onyxData); } @@ -3973,12 +3994,13 @@ function updateMoneyRequestCategory( policy: OnyxEntry, policyTagList: OnyxEntry, policyCategories: OnyxEntry, + hash?: number, ) { const transactionChanges: TransactionChanges = { category, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories); + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, undefined, hash); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, params, onyxData); } @@ -8889,7 +8911,7 @@ function putOnHold(transactionID: string, comment: string, reportID: string, sea const currentTime = DateUtils.getDBTime(); const createdReportAction = buildOptimisticHoldReportAction(currentTime); const createdReportActionComment = buildOptimisticHoldReportActionComment(comment, DateUtils.addMillisecondsFromDateTime(currentTime, 1)); - const newViolation = {name: CONST.VIOLATIONS.HOLD, type: CONST.VIOLATION_TYPES.VIOLATION}; + const newViolation = {name: CONST.VIOLATIONS.HOLD, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}; const transactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; const updatedViolations = [...transactionViolations, newViolation]; const parentReportActionOptimistic = getOptimisticDataForParentReportAction(reportID, createdReportActionComment.created, CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 44b0a71dc72c..834812013b5c 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -874,11 +874,12 @@ function setWorkspaceInviteMembersDraft(policyID: string, invitedEmailsToAccount /** * Accept user join request to a workspace */ -function acceptJoinRequest(reportID: string, reportAction: OnyxEntry) { - const choice = CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.ACCEPT; - if (!reportAction) { +function acceptJoinRequest(reportID: string | undefined, reportAction: OnyxEntry) { + if (!reportAction || !reportID) { + Log.warn('acceptJoinRequest missing reportID or reportAction', {reportAction, reportID}); return; } + const choice = CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.ACCEPT; const optimisticData: OnyxUpdate[] = [ { @@ -933,8 +934,9 @@ function acceptJoinRequest(reportID: string, reportAction: OnyxEntry) { - if (!reportAction) { +function declineJoinRequest(reportID: string | undefined, reportAction: OnyxEntry) { + if (!reportAction || !reportID) { + Log.warn('declineJoinRequest missing reportID or reportAction', {reportAction, reportID}); return; } const choice = CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.DECLINE; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 50c8e7a0e2d2..5ae1c07f1eec 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -3171,6 +3171,7 @@ function enablePolicyRules(policyID: string, enabled: boolean, disableRedirect = key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { areRulesEnabled: enabled, + preventSelfApproval: false, ...(!enabled ? DISABLED_MAX_EXPENSE_VALUES : {}), pendingFields: { areRulesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, @@ -3195,6 +3196,7 @@ function enablePolicyRules(policyID: string, enabled: boolean, disableRedirect = key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { areRulesEnabled: !enabled, + preventSelfApproval: policy?.preventSelfApproval, ...(!enabled ? { maxExpenseAmountNoReceipt: policy?.maxExpenseAmountNoReceipt, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index ea8aa5414c7b..fa6046c6baa4 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1361,7 +1361,11 @@ function getNewerActions(reportID: string | undefined, reportActionID: string | /** * Gets metadata info about links in the provided report action */ -function expandURLPreview(reportID: string, reportActionID: string) { +function expandURLPreview(reportID: string | undefined, reportActionID: string) { + if (!reportID) { + return; + } + const parameters: ExpandURLPreviewParams = { reportID, reportActionID, @@ -1455,7 +1459,11 @@ function markCommentAsUnread(reportID: string | undefined, reportActionCreated: } /** Toggles the pinned state of the report. */ -function togglePinnedState(reportID: string, isPinnedChat: boolean) { +function togglePinnedState(reportID: string | undefined, isPinnedChat: boolean) { + if (!reportID) { + return; + } + const pinnedValue = !isPinnedChat; // Optimistically pin/unpin the report before we send out the command @@ -1743,14 +1751,13 @@ function handleUserDeletedLinksInHtml(newCommentText: string, originalCommentMar } /** Saves a new message for a comment. Marks the comment as edited, which will be reflected in the UI. */ -function editReportComment(reportID: string, originalReportAction: OnyxEntry, textForNewComment: string, videoAttributeCache?: Record) { +function editReportComment(reportID: string | undefined, originalReportAction: OnyxEntry, textForNewComment: string, videoAttributeCache?: Record) { const originalReportID = getOriginalReportID(reportID, originalReportAction); - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`]; - const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(report); - if (!originalReportID || !originalReportAction) { return; } + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`]; + const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(report); // Do not autolink if someone explicitly tries to remove a link from message. // https://github.com/Expensify/App/issues/9090 @@ -1873,13 +1880,13 @@ function editReportComment(reportID: string, originalReportAction: OnyxEntry = { [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: null, @@ -2523,7 +2534,7 @@ function deleteReport(reportID: string, shouldDeleteChildReports = false) { /** * @param reportID The reportID of the policy report (workspace room) */ -function navigateToConciergeChatAndDeleteReport(reportID: string, shouldPopToTop = false, shouldDeleteChildReports = false) { +function navigateToConciergeChatAndDeleteReport(reportID: string | undefined, shouldPopToTop = false, shouldDeleteChildReports = false) { // Dismiss the current report screen and replace it with Concierge Chat if (shouldPopToTop) { Navigation.setShouldPopAllStateOnUP(true); @@ -2730,7 +2741,7 @@ function showReportActionNotification(reportID: string, reportAction: ReportActi } /** Clear the errors associated with the IOUs of a given report. */ -function clearIOUError(reportID: string) { +function clearIOUError(reportID: string | undefined) { Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {errorFields: {iou: null}}); } @@ -2832,7 +2843,7 @@ function removeEmojiReaction(reportID: string, reportActionID: string, emoji: Em * Uses the NEW FORMAT for "emojiReactions" */ function toggleEmojiReaction( - reportID: string, + reportID: string | undefined, reportAction: ReportAction, reactionObject: Emoji, existingReactions: OnyxEntry, @@ -4318,9 +4329,13 @@ function clearNewRoomFormError() { }); } -function resolveActionableMentionWhisper(reportId: string, reportAction: OnyxEntry, resolution: ValueOf) { +function resolveActionableMentionWhisper( + reportID: string | undefined, + reportAction: OnyxEntry, + resolution: ValueOf, +) { const message = ReportActionsUtils.getReportActionMessage(reportAction); - if (!message || !reportAction) { + if (!message || !reportAction || !reportID) { return; } @@ -4337,8 +4352,8 @@ function resolveActionableMentionWhisper(reportId: string, reportAction: OnyxEnt }, }; - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportId}`]; - const reportUpdateDataWithPreviousLastMessage = getReportLastMessage(reportId, optimisticReportActions as ReportActions); + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const reportUpdateDataWithPreviousLastMessage = getReportLastMessage(reportID, optimisticReportActions as ReportActions); const reportUpdateDataWithCurrentLastMessage = { lastMessageText: report?.lastMessageText, @@ -4349,7 +4364,7 @@ function resolveActionableMentionWhisper(reportId: string, reportAction: OnyxEnt const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportId}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, value: { [reportAction.reportActionID]: { message: [updatedMessage], @@ -4361,7 +4376,7 @@ function resolveActionableMentionWhisper(reportId: string, reportAction: OnyxEnt }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${reportId}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: reportUpdateDataWithPreviousLastMessage, }, ]; @@ -4369,7 +4384,7 @@ function resolveActionableMentionWhisper(reportId: string, reportAction: OnyxEnt const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportId}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, value: { [reportAction.reportActionID]: { message: [message], @@ -4381,7 +4396,7 @@ function resolveActionableMentionWhisper(reportId: string, reportAction: OnyxEnt }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${reportId}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: reportUpdateDataWithCurrentLastMessage, // revert back to the current report last message data in case of failure }, ]; @@ -4395,11 +4410,11 @@ function resolveActionableMentionWhisper(reportId: string, reportAction: OnyxEnt } function resolveActionableReportMentionWhisper( - reportId: string, + reportId: string | undefined, reportAction: OnyxEntry, resolution: ValueOf, ) { - if (!reportAction) { + if (!reportAction || !reportId) { return; } @@ -4466,10 +4481,10 @@ function resolveActionableReportMentionWhisper( API.write(WRITE_COMMANDS.RESOLVE_ACTIONABLE_REPORT_MENTION_WHISPER, parameters, {optimisticData, failureData}); } -function dismissTrackExpenseActionableWhisper(reportID: string, reportAction: OnyxEntry): void { +function dismissTrackExpenseActionableWhisper(reportID: string | undefined, reportAction: OnyxEntry): void { const isArrayMessage = Array.isArray(reportAction?.message); const message = ReportActionsUtils.getReportActionMessage(reportAction); - if (!message || !reportAction) { + if (!message || !reportAction || !reportID) { return; } diff --git a/src/libs/actions/ReportActions.ts b/src/libs/actions/ReportActions.ts index 89517a753c26..e8d7949464d8 100644 --- a/src/libs/actions/ReportActions.ts +++ b/src/libs/actions/ReportActions.ts @@ -1,12 +1,12 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import * as ReportActionUtils from '@libs/ReportActionsUtils'; -import * as ReportUtils from '@libs/ReportUtils'; +import {getLinkedTransactionID, getReportAction, getReportActionMessage, isCreatedTaskReportAction} from '@libs/ReportActionsUtils'; +import {getOriginalReportID} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type ReportAction from '@src/types/onyx/ReportAction'; -import * as Report from './Report'; +import {deleteReport} from './Report'; type IgnoreDirection = 'parent' | 'child'; @@ -27,7 +27,7 @@ Onyx.connect({ }); function clearReportActionErrors(reportID: string, reportAction: ReportAction, keys?: string[]) { - const originalReportID = ReportUtils.getOriginalReportID(reportID, reportAction); + const originalReportID = getOriginalReportID(reportID, reportAction); if (!reportAction?.reportActionID) { return; @@ -41,16 +41,16 @@ function clearReportActionErrors(reportID: string, reportAction: ReportAction, k // If there's a linked transaction, delete that too // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const linkedTransactionID = ReportActionUtils.getLinkedTransactionID(reportAction.reportActionID, originalReportID || '-1'); + const linkedTransactionID = getLinkedTransactionID(reportAction.reportActionID, originalReportID); if (linkedTransactionID) { Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${linkedTransactionID}`, null); Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportAction.childReportID}`, null); } // Delete the failed task report too - const taskReportID = ReportActionUtils.getReportActionMessage(reportAction)?.taskReportID; - if (taskReportID && ReportActionUtils.isCreatedTaskReportAction(reportAction)) { - Report.deleteReport(taskReportID); + const taskReportID = getReportActionMessage(reportAction)?.taskReportID; + if (taskReportID && isCreatedTaskReportAction(reportAction)) { + deleteReport(taskReportID); } return; } @@ -81,9 +81,9 @@ function clearReportActionErrors(reportID: string, reportAction: ReportAction, k ignore: `undefined` means we want to check both parent and children report actions ignore: `parent` or `child` means we want to ignore checking parent or child report actions because they've been previously checked */ -function clearAllRelatedReportActionErrors(reportID: string, reportAction: ReportAction | null | undefined, ignore?: IgnoreDirection, keys?: string[]) { +function clearAllRelatedReportActionErrors(reportID: string | undefined, reportAction: ReportAction | null | undefined, ignore?: IgnoreDirection, keys?: string[]) { const errorKeys = keys ?? Object.keys(reportAction?.errors ?? {}); - if (!reportAction || errorKeys.length === 0) { + if (!reportAction || errorKeys.length === 0 || !reportID) { return; } @@ -91,7 +91,7 @@ function clearAllRelatedReportActionErrors(reportID: string, reportAction: Repor const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; if (report?.parentReportID && report?.parentReportActionID && ignore !== 'parent') { - const parentReportAction = ReportActionUtils.getReportAction(report.parentReportID, report.parentReportActionID); + const parentReportAction = getReportAction(report.parentReportID, report.parentReportActionID); const parentErrorKeys = Object.keys(parentReportAction?.errors ?? {}).filter((err) => errorKeys.includes(err)); clearAllRelatedReportActionErrors(report.parentReportID, parentReportAction, 'child', parentErrorKeys); @@ -101,7 +101,7 @@ function clearAllRelatedReportActionErrors(reportID: string, reportAction: Repor const childActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportAction.childReportID}`] ?? {}; Object.values(childActions).forEach((action) => { const childErrorKeys = Object.keys(action.errors ?? {}).filter((err) => errorKeys.includes(err)); - clearAllRelatedReportActionErrors(reportAction.childReportID ?? '-1', action, 'parent', childErrorKeys); + clearAllRelatedReportActionErrors(reportAction.childReportID, action, 'parent', childErrorKeys); }); } } diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index fa14e6fef7a8..2864b989ed3d 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -1251,7 +1251,11 @@ function canActionTask(taskReport: OnyxEntry, sessionAccountID return sessionAccountID === ownerAccountID || sessionAccountID === assigneeAccountID; } -function clearTaskErrors(reportID: string) { +function clearTaskErrors(reportID: string | undefined) { + if (!reportID) { + return; + } + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; // Delete the task preview in the parent report diff --git a/src/libs/actions/getCompanyCardBankConnection/index.tsx b/src/libs/actions/getCompanyCardBankConnection/index.tsx index fb6dd9943972..a136a3783136 100644 --- a/src/libs/actions/getCompanyCardBankConnection/index.tsx +++ b/src/libs/actions/getCompanyCardBankConnection/index.tsx @@ -11,7 +11,7 @@ type CompanyCardBankConnection = { isNewDot: string; }; -export default function getCompanyCardBankConnection(policyID?: string, bankName?: string, scrapeMinDate?: string) { +export default function getCompanyCardBankConnection(policyID?: string, bankName?: string) { const bankConnection = Object.keys(CONST.COMPANY_CARDS.BANKS).find((key) => CONST.COMPANY_CARDS.BANKS[key as keyof typeof CONST.COMPANY_CARDS.BANKS] === bankName); if (!bankName || !bankConnection || !policyID) { @@ -23,7 +23,7 @@ export default function getCompanyCardBankConnection(policyID?: string, bankName isNewDot: 'true', domainName: PolicyUtils.getDomainNameForPolicy(policyID), isCorporate: 'true', - scrapeMinDate: scrapeMinDate ?? '', + scrapeMinDate: '', }; const commandURL = getApiRoot({ shouldSkipWebProxy: true, diff --git a/src/libs/useNativeDriver/index.native.ts b/src/libs/useNativeDriver/index.native.ts deleted file mode 100644 index 93ed069fa807..000000000000 --- a/src/libs/useNativeDriver/index.native.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type UseNativeDriver from './types'; - -const useNativeDriver: UseNativeDriver = true; - -export default useNativeDriver; diff --git a/src/libs/useNativeDriver/index.ts b/src/libs/useNativeDriver/index.ts deleted file mode 100644 index faa9e3597cb9..000000000000 --- a/src/libs/useNativeDriver/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type UseNativeDriver from './types'; - -const useNativeDriver: UseNativeDriver = false; - -export default useNativeDriver; diff --git a/src/libs/useNativeDriver/types.ts b/src/libs/useNativeDriver/types.ts deleted file mode 100644 index de70cd0a49d0..000000000000 --- a/src/libs/useNativeDriver/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -type UseNativeDriver = boolean; - -export default UseNativeDriver; diff --git a/src/pages/home/HeaderView.tsx b/src/pages/home/HeaderView.tsx index 05646805e0b0..a453f03ed645 100644 --- a/src/pages/home/HeaderView.tsx +++ b/src/pages/home/HeaderView.tsx @@ -9,6 +9,7 @@ import ConfirmModal from '@components/ConfirmModal'; import DisplayNames from '@components/DisplayNames'; import Icon from '@components/Icon'; import {BackArrow, CalendarSolid, DotIndicator, FallbackAvatar} from '@components/Icon/Expensicons'; +import LoadingBar from '@components/LoadingBar'; import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ParentNavigationSubtitle from '@components/ParentNavigationSubtitle'; @@ -104,6 +105,7 @@ function HeaderView({report, parentReportAction, onNavigationMenuButtonClicked, const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID) ?? getNonEmptyStringOnyxID(report?.reportID)}`); const policy = usePolicy(report?.policyID); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); const [firstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL); const [lastDayFreeTrial] = useOnyx(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL); const [account] = useOnyx(ONYXKEYS.ACCOUNT); @@ -380,6 +382,7 @@ function HeaderView({report, parentReportAction, onNavigationMenuButtonClicked, )} + {shouldShowDiscount && isChatUsedForOnboarding && } diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index b97e7f2c3dd7..663b83c619e1 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -42,7 +42,7 @@ import {hideContextMenu, showContextMenu} from './ReportActionContextMenu'; type BaseReportActionContextMenuProps = { /** The ID of the report this report action is attached to. */ - reportID: string; + reportID: string | undefined; /** The ID of the report action this context menu is attached to. */ reportActionID: string; @@ -204,6 +204,7 @@ function BaseReportActionContextMenu({ let filteredContextMenuActions = ContextMenuActions.filter( (contextAction) => !disabledActions.includes(contextAction) && + reportID && contextAction.shouldShow({ type, reportAction, diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index 91481cd30754..3c9952063a00 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -147,7 +147,7 @@ type ShouldShow = (args: { type ContextMenuActionPayload = { reportAction: ReportAction; transaction?: OnyxEntry; - reportID: string; + reportID: string | undefined; report: OnyxEntry; draftMessage: string; selection: string; @@ -630,6 +630,10 @@ const ContextMenuActions: ContextMenuAction[] = [ !isChronosReport && reportAction?.actorAccountID !== CONST.ACCOUNT_ID.CONCIERGE, onPress: (closePopover, {reportID, reportAction}) => { + if (!reportID) { + return; + } + const activeRoute = Navigation.getActiveRoute(); if (closePopover) { hideContextMenu(false, () => Navigation.navigate(ROUTES.FLAG_COMMENT.getRoute(reportID, reportAction?.reportActionID, activeRoute))); diff --git a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts index 14ce17798e51..a9c2d7c83225 100644 --- a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts +++ b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts @@ -105,9 +105,9 @@ function showContextMenu( event: GestureResponderEvent | MouseEvent, selection: string, contextMenuAnchor: ContextMenuAnchor, - reportID = '-1', - reportActionID = '-1', - originalReportID = '-1', + reportID: string | undefined = undefined, + reportActionID: string | undefined = undefined, + originalReportID: string | undefined = undefined, draftMessage: string | undefined = undefined, onShow = () => {}, onHide = () => {}, @@ -171,8 +171,8 @@ function hideDeleteModal() { /** * Opens the Confirm delete action modal */ -function showDeleteModal(reportID: string, reportAction: OnyxEntry, shouldSetModalVisibility?: boolean, onConfirm?: OnConfirm, onCancel?: OnCancel) { - if (!contextMenuRef.current) { +function showDeleteModal(reportID: string | undefined, reportAction: OnyxEntry, shouldSetModalVisibility?: boolean, onConfirm?: OnConfirm, onCancel?: OnCancel) { + if (!contextMenuRef.current || !reportID) { return; } contextMenuRef.current.showDeleteModal(reportID, reportAction, shouldSetModalVisibility, onConfirm, onCancel); diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index ee09576d1a4d..aebf631257ce 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -68,7 +68,6 @@ import { isActionableReportMentionWhisper, isActionableTrackExpense, isActionOfType, - isAddCommentAction, isChronosOOOListAction, isCreatedTaskReportAction, isDeletedAction, @@ -231,7 +230,7 @@ type PureReportActionItemProps = { originalReportID?: string; /** Function to deletes the draft for a comment report action. */ - deleteReportActionDraft?: (reportID: string, action: OnyxTypes.ReportAction) => void; + deleteReportActionDraft?: (reportID: string | undefined, action: OnyxTypes.ReportAction) => void; /** Whether the room is archived */ isArchivedRoom?: boolean; @@ -241,7 +240,7 @@ type PureReportActionItemProps = { /** Function to toggle emoji reaction */ toggleEmojiReaction?: ( - reportID: string, + reportID: string | undefined, reportAction: OnyxTypes.ReportAction, reactionObject: Emoji, existingReactions: OnyxEntry, @@ -250,18 +249,18 @@ type PureReportActionItemProps = { ) => void; /** Function to create a draft transaction and navigate to participant selector */ - createDraftTransactionAndNavigateToParticipantSelector?: (transactionID: string, reportID: string, actionName: IOUAction, reportActionID: string) => void; + createDraftTransactionAndNavigateToParticipantSelector?: (transactionID: string | undefined, reportID: string | undefined, actionName: IOUAction, reportActionID: string) => void; /** Function to resolve actionable report mention whisper */ resolveActionableReportMentionWhisper?: ( - reportId: string, + reportId: string | undefined, reportAction: OnyxEntry, resolution: ValueOf, ) => void; /** Function to resolve actionable mention whisper */ resolveActionableMentionWhisper?: ( - reportId: string, + reportId: string | undefined, reportAction: OnyxEntry, resolution: ValueOf, ) => void; @@ -288,10 +287,10 @@ type PureReportActionItemProps = { clearError?: (transactionID: string) => void; /** Function to clear all errors from a report action */ - clearAllRelatedReportActionErrors?: (reportID: string, reportAction: OnyxTypes.ReportAction | null | undefined, ignore?: IgnoreDirection, keys?: string[]) => void; + clearAllRelatedReportActionErrors?: (reportID: string | undefined, reportAction: OnyxTypes.ReportAction | null | undefined, ignore?: IgnoreDirection, keys?: string[]) => void; /** Function to dismiss the actionable whisper for tracking expenses */ - dismissTrackExpenseActionableWhisper?: (reportID: string, reportAction: OnyxEntry) => void; + dismissTrackExpenseActionableWhisper?: (reportID: string | undefined, reportAction: OnyxEntry) => void; /** User payment card ID */ userBillingFundID?: number; @@ -354,7 +353,7 @@ function PureReportActionItem({ }: PureReportActionItemProps) { const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const reportID = report?.reportID ?? ''; + const reportID = report?.reportID; const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -556,7 +555,7 @@ function PureReportActionItem({ const contextValue = useMemo( () => ({ anchor: popoverAnchorRef.current, - report: {...report, reportID: report?.reportID ?? ''}, + report, reportNameValuePairs, action, transactionThreadReport, @@ -568,8 +567,7 @@ function PureReportActionItem({ const attachmentContextValue = useMemo(() => ({reportID, type: CONST.ATTACHMENT_TYPE.REPORT}), [reportID]); - const mentionReportContextValue = useMemo(() => ({currentReportID: report?.reportID ?? '-1'}), [report?.reportID]); - + const mentionReportContextValue = useMemo(() => ({currentReportID: report?.reportID}), [report?.reportID]); const actionableItemButtons: ActionableItem[] = useMemo(() => { if (isActionableAddPaymentCard(action) && userBillingFundID === undefined && shouldRenderAddPaymentCard()) { return [ @@ -596,7 +594,7 @@ function PureReportActionItem({ text: 'actionableMentionTrackExpense.submit', key: `${action.reportActionID}-actionableMentionTrackExpense-submit`, onPress: () => { - createDraftTransactionAndNavigateToParticipantSelector(transactionID ?? '0', reportID, CONST.IOU.ACTION.SUBMIT, action.reportActionID); + createDraftTransactionAndNavigateToParticipantSelector(transactionID, reportID, CONST.IOU.ACTION.SUBMIT, action.reportActionID); }, isMediumSized: true, }, @@ -604,7 +602,7 @@ function PureReportActionItem({ text: 'actionableMentionTrackExpense.categorize', key: `${action.reportActionID}-actionableMentionTrackExpense-categorize`, onPress: () => { - createDraftTransactionAndNavigateToParticipantSelector(transactionID ?? '0', reportID, CONST.IOU.ACTION.CATEGORIZE, action.reportActionID); + createDraftTransactionAndNavigateToParticipantSelector(transactionID, reportID, CONST.IOU.ACTION.CATEGORIZE, action.reportActionID); }, isMediumSized: true, }, @@ -612,7 +610,7 @@ function PureReportActionItem({ text: 'actionableMentionTrackExpense.share', key: `${action.reportActionID}-actionableMentionTrackExpense-share`, onPress: () => { - createDraftTransactionAndNavigateToParticipantSelector(transactionID ?? '0', reportID, CONST.IOU.ACTION.SHARE, action.reportActionID); + createDraftTransactionAndNavigateToParticipantSelector(transactionID, reportID, CONST.IOU.ACTION.SHARE, action.reportActionID); }, isMediumSized: true, }, @@ -703,11 +701,11 @@ function PureReportActionItem({ getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK) ) { // There is no single iouReport for bill splits, so only 1:1 requests require an iouReportID - const iouReportID = getOriginalMessage(action)?.IOUReportID ? getOriginalMessage(action)?.IOUReportID?.toString() ?? '-1' : '-1'; + const iouReportID = getOriginalMessage(action)?.IOUReportID?.toString(); children = ( ${translate('parentReportAction.deletedReport')}`} /> + ${translate('parentReportAction.deletedReport')}`} /> ) : ( ); } else if (isReimbursementQueuedAction(action)) { const linkedReport = isChatThread(report) ? parentReport : report; - const submitterDisplayName = formatPhoneNumber(getDisplayNameOrDefault(personalDetails?.[linkedReport?.ownerAccountID ?? -1])); + const submitterDisplayName = formatPhoneNumber(getDisplayNameOrDefault(personalDetails?.[linkedReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID])); const paymentType = getOriginalMessage(action)?.paymentType ?? ''; children = ( @@ -1057,7 +1055,7 @@ function PureReportActionItem({ }; if (action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { - const transactionID = isMoneyRequestAction(parentReportActionForTransactionThread) ? getOriginalMessage(parentReportActionForTransactionThread)?.IOUTransactionID : '-1'; + const transactionID = isMoneyRequestAction(parentReportActionForTransactionThread) ? getOriginalMessage(parentReportActionForTransactionThread)?.IOUTransactionID : undefined; return ( 1; - const iouReportID = isMoneyRequestAction(action) && getOriginalMessage(action)?.IOUReportID ? (getOriginalMessage(action)?.IOUReportID ?? '').toString() : '-1'; + const iouReportID = isMoneyRequestAction(action) && getOriginalMessage(action)?.IOUReportID ? getOriginalMessage(action)?.IOUReportID?.toString() : undefined; const transactionsWithReceipts = getTransactionsWithReceipts(iouReportID); const isWhisper = whisperedTo.length > 0 && transactionsWithReceipts.length === 0; const whisperedToPersonalDetails = isWhisper - ? (Object.values(personalDetails ?? {}).filter((details) => whisperedTo.includes(details?.accountID ?? -1)) as OnyxTypes.PersonalDetails[]) + ? (Object.values(personalDetails ?? {}).filter((details) => whisperedTo.includes(details?.accountID ?? CONST.DEFAULT_NUMBER_ID)) as OnyxTypes.PersonalDetails[]) : []; const isWhisperOnlyVisibleByUser = isWhisper && isCurrentUserTheOnlyParticipant(whisperedTo); const displayNamesWithTooltips = isWhisper ? getDisplayNamesWithTooltips(whisperedToPersonalDetails, isMultipleParticipant) : []; diff --git a/src/pages/home/report/ReportActionItemContentCreated.tsx b/src/pages/home/report/ReportActionItemContentCreated.tsx index 69e27701edd8..7c0016b56338 100644 --- a/src/pages/home/report/ReportActionItemContentCreated.tsx +++ b/src/pages/home/report/ReportActionItemContentCreated.tsx @@ -15,9 +15,9 @@ import UnreadActionIndicator from '@components/UnreadActionIndicator'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as ReportActionsUtils from '@libs/ReportActionsUtils'; -import * as ReportUtils from '@libs/ReportUtils'; -import * as TransactionUtils from '@libs/TransactionUtils'; +import {isMessageDeleted, isReversedTransaction as isReversedTransactionReportActionsUtils, isTransactionThread} from '@libs/ReportActionsUtils'; +import {isCanceledTaskReport, isExpenseReport, isInvoiceReport, isIOUReport, isTaskReport} from '@libs/ReportUtils'; +import {getCurrency} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -29,10 +29,7 @@ import ReportActionItemSingle from './ReportActionItemSingle'; type ReportActionItemContentCreatedProps = { /** The context value containing the report and action data, along with the show context menu props */ - contextValue: ShowContextMenuContextProps & { - report: OnyxTypes.Report; - action: OnyxTypes.ReportAction; - }; + contextValue: ShowContextMenuContextProps; /** Report action belonging to the report's parent */ parentReportAction: OnyxEntry; @@ -50,19 +47,18 @@ type ReportActionItemContentCreatedProps = { function ReportActionItemContentCreated({contextValue, parentReportAction, transactionID, draftMessage, shouldHideThreadDividerLine}: ReportActionItemContentCreatedProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {report, action, transactionThreadReport} = contextValue; - const policy = usePolicy(report.policyID === CONST.POLICY.OWNER_EMAIL_FAKE ? '-1' : report.policyID ?? '-1'); - const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID ?? '-1'}`); + const policy = usePolicy(report?.policyID === CONST.POLICY.OWNER_EMAIL_FAKE ? undefined : report?.policyID); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); - const transactionCurrency = TransactionUtils.getCurrency(transaction); + const transactionCurrency = getCurrency(transaction); const renderThreadDivider = useMemo( () => shouldHideThreadDividerLine ? ( ) : ( @@ -71,15 +67,15 @@ function ReportActionItemContentCreated({contextValue, parentReportAction, trans style={[!shouldHideThreadDividerLine ? styles.reportHorizontalRule : {}]} /> ), - [shouldHideThreadDividerLine, report.reportID, styles.reportHorizontalRule], + [shouldHideThreadDividerLine, report?.reportID, styles.reportHorizontalRule], ); const contextMenuValue = useMemo(() => ({...contextValue, isDisabled: true}), [contextValue]); - if (ReportActionsUtils.isTransactionThread(parentReportAction)) { - const isReversedTransaction = ReportActionsUtils.isReversedTransaction(parentReportAction); + if (isTransactionThread(parentReportAction)) { + const isReversedTransaction = isReversedTransactionReportActionsUtils(parentReportAction); - if (ReportActionsUtils.isMessageDeleted(parentReportAction) || isReversedTransaction) { + if (isMessageDeleted(parentReportAction) || isReversedTransaction) { let message: TranslationPaths; if (isReversedTransaction) { @@ -97,7 +93,7 @@ function ReportActionItemContentCreated({contextValue, parentReportAction, trans showHeader report={report} > - ${translate(message)}`} /> + ${translate(message)}`} /> @@ -106,7 +102,7 @@ function ReportActionItemContentCreated({contextValue, parentReportAction, trans } return ( - + @@ -131,7 +127,7 @@ function ReportActionItemContentCreated({contextValue, parentReportAction, trans showHeader={draftMessage === undefined} report={report} > - ${translate('parentReportAction.deletedTask')}`} /> + ${translate('parentReportAction.deletedTask')}`} /> @@ -150,17 +146,17 @@ function ReportActionItemContentCreated({contextValue, parentReportAction, trans ); } - if (ReportUtils.isExpenseReport(report) || ReportUtils.isIOUReport(report) || ReportUtils.isInvoiceReport(report)) { + if (isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report)) { return ( - + {!isEmptyObject(transactionThreadReport?.reportID) ? ( <> @@ -177,7 +173,7 @@ function ReportActionItemContentCreated({contextValue, parentReportAction, trans )} @@ -187,8 +183,8 @@ function ReportActionItemContentCreated({contextValue, parentReportAction, trans return ( ); } diff --git a/src/pages/home/report/ReportActionItemCreated.tsx b/src/pages/home/report/ReportActionItemCreated.tsx index 1adb24fa23a7..b5584c00087b 100644 --- a/src/pages/home/report/ReportActionItemCreated.tsx +++ b/src/pages/home/report/ReportActionItemCreated.tsx @@ -9,7 +9,7 @@ import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import * as ReportUtils from '@libs/ReportUtils'; +import {getIcons, isChatReport, isCurrentUserInvoiceReceiver, isInvoiceRoom, navigateToDetailsPage, shouldDisableDetailPage as shouldDisableDetailPageReportUtils} from '@libs/ReportUtils'; import {navigateToConciergeChatAndDeleteReport} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -17,7 +17,7 @@ import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; type ReportActionItemCreatedProps = { /** The id of the report */ - reportID: string; + reportID: string | undefined; /** The id of the policy */ // eslint-disable-next-line react/no-unused-prop-types @@ -31,16 +31,16 @@ function ReportActionItemCreated({reportID, policyID}: ReportActionItemCreatedPr const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); - const [invoiceReceiverPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? report.invoiceReceiver.policyID : -1}`); + const [invoiceReceiverPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? report.invoiceReceiver.policyID : undefined}`); - if (!ReportUtils.isChatReport(report)) { + if (!isChatReport(report)) { return null; } - let icons = ReportUtils.getIcons(report, personalDetails, null, '', -1, policy, invoiceReceiverPolicy); - const shouldDisableDetailPage = ReportUtils.shouldDisableDetailPage(report); + let icons = getIcons(report, personalDetails, null, '', -1, policy, invoiceReceiverPolicy); + const shouldDisableDetailPage = shouldDisableDetailPageReportUtils(report); - if (ReportUtils.isInvoiceRoom(report) && ReportUtils.isCurrentUserInvoiceReceiver(report)) { + if (isInvoiceRoom(report) && isCurrentUserInvoiceReceiver(report)) { icons = [...icons].reverse(); } @@ -59,7 +59,7 @@ function ReportActionItemCreated({reportID, policyID}: ReportActionItemCreatedPr > ReportUtils.navigateToDetailsPage(report, Navigation.getReportRHPActiveRoute())} + onPress={() => navigateToDetailsPage(report, Navigation.getReportRHPActiveRoute())} style={[styles.mh5, styles.mb3, styles.alignSelfStart, shouldDisableDetailPage && styles.cursorDefault]} accessibilityLabel={translate('common.details')} role={CONST.ROLE.BUTTON} diff --git a/src/pages/home/report/ReportActionItemFragment.tsx b/src/pages/home/report/ReportActionItemFragment.tsx index 05cb657b1e54..50721a5ffcad 100644 --- a/src/pages/home/report/ReportActionItemFragment.tsx +++ b/src/pages/home/report/ReportActionItemFragment.tsx @@ -6,7 +6,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import convertToLTR from '@libs/convertToLTR'; -import * as ReportUtils from '@libs/ReportUtils'; +import isReportMessageAttachment from '@libs/isReportMessageAttachment'; import CONST from '@src/CONST'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {DecisionName, OriginalMessageSource} from '@src/types/onyx/OriginalMessage'; @@ -106,14 +106,14 @@ function ReportActionItemFragment({ // immediately display "[Deleted message]" while the delete action is pending. if ((!isOffline && isThreadParentMessage && isPendingDelete) || fragment?.isDeletedParentAction) { - return ${translate('parentReportAction.deletedMessage')}`} />; + return ${translate('parentReportAction.deletedMessage')}`} />; } if (isThreadParentMessage && moderationDecision === CONST.MODERATION.MODERATOR_DECISION_PENDING_REMOVE) { - return ${translate('parentReportAction.hiddenMessage')}`} />; + return ${translate('parentReportAction.hiddenMessage')}`} />; } - if (ReportUtils.isReportMessageAttachment(fragment)) { + if (isReportMessageAttachment(fragment)) { return ( @@ -60,7 +72,7 @@ function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHid } if (action.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION) { - const fragment = ReportActionsUtils.getUpdateRoomDescriptionFragment(action); + const fragment = getUpdateRoomDescriptionFragment(action); return ( type === action.actionName); @@ -99,11 +111,11 @@ function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHid key={`actionFragment-${action.reportActionID}-${index}`} fragment={fragment} iouMessage={iouMessage} - isThreadParentMessage={ReportActionsUtils.isThreadParentMessage(action, reportID)} + isThreadParentMessage={isThreadParentMessage(action, reportID)} pendingAction={action.pendingAction} actionName={action.actionName} - source={ReportActionsUtils.isAddCommentAction(action) ? ReportActionsUtils.getOriginalMessage(action)?.source : ''} - accountID={action.actorAccountID ?? -1} + source={isAddCommentAction(action) ? getOriginalMessage(action)?.source : ''} + accountID={action.actorAccountID ?? CONST.DEFAULT_NUMBER_ID} style={style} displayAsGroup={displayAsGroup} isApprovedOrSubmittedReportAction={isApprovedOrSubmittedReportAction} @@ -113,7 +125,7 @@ function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHid // to decide if the fragment should be from left to right for RTL display names e.g. Arabic for proper // formatting. isFragmentContainingDisplayName={index === 0} - moderationDecision={ReportActionsUtils.getReportActionMessage(action)?.moderationDecision?.decision} + moderationDecision={getReportActionMessage(action)?.moderationDecision?.decision} /> )); @@ -132,7 +144,7 @@ function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHid Navigation.navigate(ROUTES.WORKSPACE_INVOICES.getRoute(policyID)); }; - const shouldShowAddBankAccountButton = action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && ReportUtils.hasMissingInvoiceBankAccount(reportID) && !ReportUtils.isSettled(reportID); + const shouldShowAddBankAccountButton = action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && hasMissingInvoiceBankAccount(reportID) && !isSettled(reportID); return ( diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 1862e2b96596..628c50a8a5a5 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -60,7 +60,7 @@ type ReportActionItemMessageEditProps = { draftMessage: string; /** ReportID that holds the comment we're editing */ - reportID: string; + reportID: string | undefined; /** PolicyID of the policy the report belongs to */ policyID?: string; diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 7eb3711ec637..edbd70349127 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -19,7 +19,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import DateUtils from '@libs/DateUtils'; -import {getChatFSAttributes} from '@libs/Fullstory'; +import {getChatFSAttributes, parseFSAttributes} from '@libs/Fullstory'; import isReportScreenTopmostCentralPane from '@libs/Navigation/isReportScreenTopmostCentralPane'; import isSearchTopmostCentralPane from '@libs/Navigation/isSearchTopmostCentralPane'; import Navigation from '@libs/Navigation/Navigation'; @@ -190,7 +190,6 @@ function ReportActionsList({ const lastMessageTime = useRef(null); const [isVisible, setIsVisible] = useState(Visibility.isVisible); const isFocused = useIsFocused(); - const [pendingBottomScroll, setPendingBottomScroll] = useState(false); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID}); @@ -456,48 +455,24 @@ function ReportActionsList({ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); - const isNewMessageDisplayed = useMemo(() => { - const prevActions = Object.values(prevSortedVisibleReportActionsObjects); - const lastPrevVisibleAction = prevActions.at(0); - return lastAction?.reportActionID !== lastPrevVisibleAction?.reportActionID; - }, [prevSortedVisibleReportActionsObjects, lastAction]); - const scrollToBottomForCurrentUserAction = useCallback( (isFromCurrentUser: boolean) => { - // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where - // they are now in the list. - if (!isFromCurrentUser || scrollingVerticalOffset.current === 0 || !isReportScreenTopmostCentralPane()) { - return; - } - if (!hasNewestReportActionRef.current) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report.reportID)); - return; - } - if (!isNewMessageDisplayed) { - setPendingBottomScroll(true); - } else { - InteractionManager.runAfterInteractions(() => { - reportScrollManager.scrollToBottom(); - }); - } - }, - [reportScrollManager, report.reportID, isNewMessageDisplayed], - ); - - useEffect(() => { - if (!pendingBottomScroll || scrollingVerticalOffset.current === 0) { - return; - } - - if (isNewMessageDisplayed) { InteractionManager.runAfterInteractions(() => { setIsFloatingMessageCounterVisible(false); + // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where + // they are now in the list. + if (!isFromCurrentUser || !isReportScreenTopmostCentralPane()) { + return; + } + if (!hasNewestReportActionRef.current) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report.reportID)); + return; + } reportScrollManager.scrollToBottom(); - setPendingBottomScroll(false); }); - } - }, [pendingBottomScroll, reportScrollManager, isNewMessageDisplayed]); - + }, + [reportScrollManager, report.reportID], + ); useEffect(() => { // Why are we doing this, when in the cleanup of the useEffect we are already calling the unsubscribe function? // Answer: On web, when navigating to another report screen, the previous report screen doesn't get unmounted, @@ -782,6 +757,9 @@ function ReportActionsList({ loadOlderChats(false); }, [loadOlderChats]); + // Parse Fullstory attributes on initial render + useLayoutEffect(parseFSAttributes, []); + const [reportActionsListTestID, reportActionsListFSClass] = getChatFSAttributes(participantsContext, 'ReportActionsList', report); return ( diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index 5fcad9785ecd..52a086315e5c 100644 --- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx +++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx @@ -20,6 +20,7 @@ import usePolicy from '@hooks/usePolicy'; import useScreenWrapperTranstionStatus from '@hooks/useScreenWrapperTransitionStatus'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import {isMovingTransactionFromTrackExpense} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import { filterAndOrderOptions, @@ -122,7 +123,7 @@ function MoneyRequestParticipantsSelector({participants = CONST.EMPTY_ARRAY, onF action, shouldSeparateSelfDMChat: iouType !== CONST.IOU.TYPE.INVOICE, shouldSeparateWorkspaceChat: true, - includeSelfDM: true, + includeSelfDM: !isMovingTransactionFromTrackExpense(action) && iouType !== CONST.IOU.TYPE.INVOICE, }, ); diff --git a/src/pages/iou/request/step/IOURequestStepCategory.tsx b/src/pages/iou/request/step/IOURequestStepCategory.tsx index 87a56e977817..59454cea3ce8 100644 --- a/src/pages/iou/request/step/IOURequestStepCategory.tsx +++ b/src/pages/iou/request/step/IOURequestStepCategory.tsx @@ -7,6 +7,7 @@ import Button from '@components/Button'; import CategoryPicker from '@components/CategoryPicker'; import FixedFooter from '@components/FixedFooter'; import * as Illustrations from '@components/Icon/Illustrations'; +import {useSearchContext} from '@components/Search/SearchContext'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection'; @@ -14,14 +15,14 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getIOURequestPolicyID, setDraftSplitTransaction, setMoneyRequestCategory, updateMoneyRequestCategory} from '@libs/actions/IOU'; +import {enablePolicyCategories, getPolicyCategories} from '@libs/actions/Policy/Category'; import Navigation from '@libs/Navigation/Navigation'; -import * as OptionsListUtils from '@libs/OptionsListUtils'; -import * as PolicyUtils from '@libs/PolicyUtils'; -import * as ReportActionsUtils from '@libs/ReportActionsUtils'; -import * as ReportUtils from '@libs/ReportUtils'; -import * as TransactionUtils from '@libs/TransactionUtils'; -import * as IOU from '@userActions/IOU'; -import * as Category from '@userActions/Policy/Category'; +import {hasEnabledOptions} from '@libs/OptionsListUtils'; +import {isPolicyAdmin} from '@libs/PolicyUtils'; +import {isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {canEditMoneyRequest, getTransactionDetails, isGroupPolicy, isReportInGroupPolicy} from '@libs/ReportUtils'; +import {areRequiredFieldsEmpty} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -43,12 +44,12 @@ function IOURequestStepCategory({ }, transaction, }: IOURequestStepCategoryProps) { + const [policyReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getIOURequestPolicyID(transaction, reportReal)}`); + const [policyDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${getIOURequestPolicyID(transaction, reportDraft)}`); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); - const [policyReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${IOU.getIOURequestPolicyID(transaction, reportReal)}`); - const [policyDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${IOU.getIOURequestPolicyID(transaction, reportDraft)}`); - const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${IOU.getIOURequestPolicyID(transaction, reportReal)}`); - const [policyCategoriesDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${IOU.getIOURequestPolicyID(transaction, reportDraft)}`); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${IOU.getIOURequestPolicyID(transaction, reportReal)}`); + const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getIOURequestPolicyID(transaction, reportReal)}`); + const [policyCategoriesDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${getIOURequestPolicyID(transaction, reportDraft)}`); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getIOURequestPolicyID(transaction, reportReal)}`); let reportID = '-1'; if (action === CONST.IOU.ACTION.EDIT && reportReal) { if (iouType === CONST.IOU.TYPE.SPLIT) { @@ -63,34 +64,35 @@ function IOURequestStepCategory({ const report = reportReal ?? reportDraft; const policy = policyReal ?? policyDraft; const policyCategories = policyCategoriesReal ?? policyCategoriesDraft; + const {currentSearchHash} = useSearchContext(); const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); const isEditing = action === CONST.IOU.ACTION.EDIT; const isEditingSplitBill = isEditing && iouType === CONST.IOU.TYPE.SPLIT; const currentTransaction = isEditingSplitBill && !lodashIsEmpty(splitDraftTransaction) ? splitDraftTransaction : transaction; - const transactionCategory = ReportUtils.getTransactionDetails(currentTransaction)?.category; + const transactionCategory = getTransactionDetails(currentTransaction)?.category; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const reportAction = reportActions?.[report?.parentReportActionID || reportActionID] ?? null; const shouldShowCategory = - (ReportUtils.isReportInGroupPolicy(report) || ReportUtils.isGroupPolicy(policy?.type ?? '')) && + (isReportInGroupPolicy(report) || isGroupPolicy(policy?.type ?? '')) && // The transactionCategory can be an empty string, so to maintain the logic we'd like to keep it in this shape until utils refactor // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - (!!transactionCategory || OptionsListUtils.hasEnabledOptions(Object.values(policyCategories ?? {}))); + (!!transactionCategory || hasEnabledOptions(Object.values(policyCategories ?? {}))); const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT; - const canEditSplitBill = isSplitBill && reportAction && session?.accountID === reportAction.actorAccountID && TransactionUtils.areRequiredFieldsEmpty(transaction); + const canEditSplitBill = isSplitBill && reportAction && session?.accountID === reportAction.actorAccountID && areRequiredFieldsEmpty(transaction); // eslint-disable-next-line rulesdir/no-negated-variables - const shouldShowNotFoundPage = isEditing && (isSplitBill ? !canEditSplitBill : !ReportActionsUtils.isMoneyRequestAction(reportAction) || !ReportUtils.canEditMoneyRequest(reportAction)); + const shouldShowNotFoundPage = isEditing && (isSplitBill ? !canEditSplitBill : !isMoneyRequestAction(reportAction) || !canEditMoneyRequest(reportAction)); const fetchData = () => { if ((!!policy && !!policyCategories) || !report?.policyID) { return; } - Category.getPolicyCategories(report?.policyID); + getPolicyCategories(report?.policyID); }; const {isOffline} = useNetwork({onReconnect: fetchData}); const isLoading = !isOffline && policyCategories === undefined; @@ -114,19 +116,19 @@ function IOURequestStepCategory({ if (transaction) { // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value if (isEditingSplitBill) { - IOU.setDraftSplitTransaction(transaction.transactionID, {category: updatedCategory}, policy); + setDraftSplitTransaction(transaction.transactionID, {category: updatedCategory}, policy); navigateBack(); return; } if (isEditing && report) { - IOU.updateMoneyRequestCategory(transaction.transactionID, report.reportID, updatedCategory, policy, policyTags, policyCategories); + updateMoneyRequestCategory(transaction.transactionID, report.reportID, updatedCategory, policy, policyTags, policyCategories, currentSearchHash); navigateBack(); return; } } - IOU.setMoneyRequestCategory(transactionID, updatedCategory, policy?.id); + setMoneyRequestCategory(transactionID, updatedCategory, policy?.id); if (action === CONST.IOU.ACTION.CATEGORIZE) { if (report?.reportID) { @@ -164,7 +166,7 @@ function IOURequestStepCategory({ subtitle={translate('workspace.categories.emptyCategories.subtitle')} containerStyle={[styles.flex1, styles.justifyContentCenter]} /> - {PolicyUtils.isPolicyAdmin(policy) && ( + {isPolicyAdmin(policy) && (