diff --git a/.github/workflows/testBuildHybrid.yml b/.github/workflows/testBuildHybrid.yml index 10131683df92..c84fe41fddae 100644 --- a/.github/workflows/testBuildHybrid.yml +++ b/.github/workflows/testBuildHybrid.yml @@ -86,7 +86,7 @@ jobs: run: working-directory: Mobile-Expensify/react-native outputs: - APK_FILE_NAME: ${{ steps.build.outputs.APK_FILE_NAME }} + S3_APK_PATH: ${{ steps.exportAndroidS3Path.outputs.S3_APK_PATH }} steps: - name: Checkout uses: actions/checkout@v4 @@ -139,9 +139,6 @@ jobs: bundler-cache: true working-directory: 'Mobile-Expensify/react-native' - - name: Install New Expensify Gems - run: bundle install - - name: Install 1Password CLI uses: 1password/install-cli-action@v1 @@ -175,60 +172,25 @@ jobs: ANDROID_UPLOAD_KEYSTORE_PASSWORD: ${{ steps.load-credentials.outputs.ANDROID_UPLOAD_KEYSTORE_PASSWORD }} ANDROID_UPLOAD_KEYSTORE_ALIAS: ${{ steps.load-credentials.outputs.ANDROID_UPLOAD_KEYSTORE_ALIAS }} ANDROID_UPLOAD_KEY_PASSWORD: ${{ steps.load-credentials.outputs.ANDROID_UPLOAD_KEY_PASSWORD }} - run: | - bundle exec fastlane android build_adhoc_hybrid - - # Refresh environment variables from GITHUB_ENV that are updated when running fastlane - # shellcheck disable=SC1090 - source "$GITHUB_ENV" - - # apkPath is set within the Fastfile - echo "APK_FILE_NAME=$(basename "$apkPath")" >> "$GITHUB_OUTPUT" - - - uploadAndroid: - name: Upload Android hybrid app to S3 - needs: [androidHybrid] - runs-on: ubuntu-latest - outputs: - S3_APK_PATH: ${{ steps.exportS3Path.outputs.S3_APK_PATH }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Ruby - uses: ruby/setup-ruby@v1.190.0 - with: - bundler-cache: true - - - name: Download Android build artifacts - uses: actions/download-artifact@v4 - with: - path: /tmp/artifacts - pattern: android-*-artifact - merge-multiple: true - - - name: Log downloaded artifact paths - run: ls -R /tmp/artifacts - + run: bundle exec fastlane android build_adhoc_hybrid + - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: us-east-1 - - - name: Upload AdHoc build to S3 + + - name: Upload Android AdHoc build to S3 run: bundle exec fastlane android upload_s3 env: - apkPath: /tmp/artifacts/${{ needs.androidHybrid.outputs.APK_FILE_NAME }} S3_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY_ID }} S3_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} S3_BUCKET: ad-hoc-expensify-cash - S3_REGION: us-east-1 + S3_REGION: us-east-1 - - name: Export S3 paths - id: exportS3Path + - name: Export S3 path + id: exportAndroidS3Path run: | # $s3APKPath is set from within the Fastfile, android upload_s3 lane echo "S3_APK_PATH=$s3APKPath" >> "$GITHUB_OUTPUT" @@ -236,7 +198,7 @@ jobs: postGithubComment: runs-on: ubuntu-latest name: Post a GitHub comment with app download links for testing - needs: [validateActor, getBranchRef, uploadAndroid] #TODO add ios job + needs: [validateActor, getBranchRef, androidHybrid] if: ${{ always() }} steps: - name: Checkout @@ -255,5 +217,5 @@ jobs: with: PR_NUMBER: ${{ env.PULL_REQUEST_NUMBER }} GITHUB_TOKEN: ${{ github.token }} - ANDROID: ${{ needs.uploadAndroid.result }} - ANDROID_LINK: ${{ needs.uploadAndroid.outputs.S3_APK_PATH }} \ No newline at end of file + ANDROID: ${{ needs.androidHybrid.result }} + ANDROID_LINK: ${{ needs.androidHybrid.outputs.S3_APK_PATH }} \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index f34e7d57d401..3294a7f3ed8d 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 1009007100 - versionName "9.0.71-0" + versionCode 1009007102 + versionName "9.0.71-2" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account.md index a7b7ed1c4f4f..b77f4c88605e 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account.md @@ -10,7 +10,7 @@ To connect a deposit-only account, 1. Hover over **Settings**, then click **Account**. 2. Click the **Payments** tab on the left. -3. Click **Add Deposit-Only Bank Account**, then click **Connect to your bank**. +3. Click **Add Deposit-Only Bank Account**, then click **Connect to your bank**. 4. Click **Continue**. 5. Search for your bank account in the list of banks and follow the prompts to sign in to your bank account. - If your bank doesn’t appear, click the X in the right corner of the Plaid pop-up window, then click **Connect Manually**. You’ll then manually enter your account information and click **Save & Continue**. @@ -19,6 +19,10 @@ To connect a deposit-only account, You’ll now receive reimbursements for your expense reports and invoices directly to this bank account. +{% include info.html %} +If your organization has global reimbursement enabled and you want to add a bank account outside of the US, you can do so by following the steps above. However, after clicking on **Add Deposit-Only Bank Account**, look for a button that says **Switch Country**. This will allow you to add a deposit account from a supported country and receive reimbursements in your local currency. +{% include end-info.html %} + {% include faq-begin.md %} **I connected my deposit-only bank account. Why haven’t I received my reimbursement?** diff --git a/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md b/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md index 14b5225801d0..71993956f4f4 100644 --- a/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md +++ b/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md @@ -1,6 +1,6 @@ --- -title: Add Domain Members and Admins -description: Add members and admins to a domain +title: Add and remove Domain Members and Admins +description: Add and remove members and admins to a domain ---
@@ -34,7 +34,19 @@ Once the member verifies their email address, all Domain Admins will be notified 1. Hover over Settings, then click **Domains**. 2. Click the name of the domain. 3. Click the **Domain Members** tab on the left. -4. Under the Domain Members section, enter the first part of the member’s email address and click **Invite**. +4. Under the Domain Members section, enter the first part of the member’s email address and click **Invite**. + +# Close a Domain Member’s account + +1. Hover over Settings, then click **Domains**. +2. Click the name of the domain. +3. Click the **Domain Members** tab on the left. +4. Find the user account you’d like to close, and select it +5. Click **Close** to close the account + +{% include info.html %} +Any closed account can be reopened at any time, by reinviting the user via the Domain Member page +{% include end-info.html %} # Add Domain Admin @@ -47,4 +59,12 @@ Once the member verifies their email address, all Domain Admins will be notified This can be any email address—it does not have to be an email address under the domain. {% include end-info.html %} +# Remove Domain Admin + +1. Hover over Settings, then click **Domains**. +2. Click the name of the domain. +3. Click the **Domain Admins** tab on the left. +4. Under the Domain Admins section, click the red trash can button next to the Domain Admin you’d like to remove +
+ diff --git a/docs/articles/expensify-classic/expenses/Create-Expense-Rules.md b/docs/articles/expensify-classic/expenses/Create-Expense-Rules.md new file mode 100644 index 000000000000..e83640403ce4 --- /dev/null +++ b/docs/articles/expensify-classic/expenses/Create-Expense-Rules.md @@ -0,0 +1,61 @@ +--- +title: Create Expense Rules +description: Automatically categorize, tag, and report expenses based on the merchant's name +--- + +Expense rules allow you to automatically categorize, tag, and report expenses based on the merchant’s name. + +# Create expense rules + +1. Hover over **Settings** and click **Account**. +2. Click **Expense Rules**. +2. Click **New Rule**. +3. Add what the merchant name should contain in order for the rule to be applied. *Note: If you enter just a period, the rule will apply to all expenses regardless of the merchant name. Universal Rules will always take precedence over all other expense rules.* +4. Choose from the following rules: +- **Merchant:** Updates the merchant name (e.g., “Starbucks #238” could be changed to “Starbucks”) +- **Category:** Applies a workspace category to the expense +- **Tag:** Applies a tag to the expense (e.g., a Department or Location) +- **Description:** Adds a description to the description field on the expense +- **Reimbursability:** Determines whether the expense will be marked as reimbursable or non-reimbursable +- **Billable**: Determines whether the expense is billable +- **Add to a report named:** Adds the expense to a report with the name you type into the field. If no report with that name exists, a new report will be created if the "Create report if necessary" checkbox is selected. + +![Fields to create a new expense rule, including the characters a merchant's name should contain for the rule to apply, as well as what changes should be applied to the expense including the merchant name, category, tag, description, reimbursability, whether it is billable, and what report it will be added to.](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_01.png){:width="100%"} + +{:start="6"} +6. (Optional) To apply the rule to previously entered expenses, select the **Apply to existing matching expenses** checkbox. You can also click **Preview Matching Expenses** to see if your rule matches the intended expenses. + +# How rules are applied + +In general, your expense rules will be applied in order, from **top to bottom**, (i.e., from the first rule). However, other settings can impact how expense rules are applied. Here is the hierarchy that determines how these are applied: + +1. A Universal Rule will **always** be applied over any other expense category rules. Rules that would otherwise change the expense category will **not** override the Universal Rule. +2. If Scheduled Submit and the setting “Enforce Default Report Title” are enabled on the workspace, this will take precedence over any rules trying to add the expense to a report. +3. If the expense is from a company card that is forced to a workspace with strict rule enforcement, those rules will take precedence over individual expense rules. +4. If you belong to a workspace that is tied to an accounting integration, the configuration settings for this connection may update your expense details upon export, even if the expense rules were successfully applied to the expense. + +# Create an expense rule from changes made to an expense + +If you open an expense and change it, you can then create an expense rule based on those changes by selecting the “Create a rule based on your changes" checkbox. *Note: The expense must be saved, reopened, and edited for this option to appear.* + +![The "Create a rule based on your changes" checkbox is located in the bottom right corner of the popup window, to the left of the Save button.](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_02.png){:width="100%"} + +# Delete an expense rule + +To delete an expense rule, + +1. Hover over **Settings** and click **Account**. +2. Click **Expense Rules**. +3. Scroll down to the rule you’d like to remove and click the trash can icon. + +![The Trash icon to delete an expense rule is located at the top right of the box containing the expense rule, to the left of the Edit icon.](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_03.png){:width="100%"} + +{% include faq-begin.md %} + +## How can I use expense rules to vendor match when exporting to an accounting package? + +When exporting non-reimbursable expenses to your connected accounting package, the payee field will list "Credit Card Misc." if the merchant name on the expense in Expensify is not an exact match to a vendor in the accounting package. When an exact match is unavailable, "Credit Card Misc." prevents multiple variations of the same vendor (e.g., Starbucks and Starbucks #1234, as is often seen in credit card statements) from being created in your accounting package. + +For repeated expenses, the best practice is to use Expense Rules, which will automatically update the merchant name without having to do it manually each time. This only works for connections to QuickBooks Online, Desktop, and Xero. Vendor matching cannot be performed in this manner for NetSuite or Sage Intacct due to limitations in the API of the accounting package. + +{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/expenses/Expense-Rules.md b/docs/articles/expensify-classic/expenses/Expense-Rules.md deleted file mode 100644 index 295aa8d00cc9..000000000000 --- a/docs/articles/expensify-classic/expenses/Expense-Rules.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: Expense Rules -description: Expense rules allow you to automatically categorize, tag, and report expenses based on the merchant's name. - ---- -# Overview -Expense rules allow you to automatically categorize, tag, and report expenses based on the merchant’s name. - -# How to use Expense Rules -**To create an expense rule, follow these steps:** -1. Navigate to **Settings > Account > Expense Rules** -2. Click on **New Rule** -3. Fill in the required information to set up your rule - -When creating an expense rule, you will be able to apply the following rules to expenses: - -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_01.png){:width="100%"} - -- **Merchant:** Updates the merchant name, e.g., “Starbucks #238” could be changed to “Starbucks” -- **Category:** Applies a workspace category to the expense -- **Tag:** Applies a tag to the expense, e.g., a Department or Location -- **Description:** Adds a description to the description field on the expense -- **Reimbursability:** Determines whether the expense will be marked as reimbursable or non-reimbursable -- **Billable**: Determines whether the expense is billable -- **Add to a report named:** Adds the expense to a report with the name you type into the field. If no report with that name exists, a new report will be created - -## Tips on using Expense Rules -- If you'd like to apply a rule to all expenses (“Universal Rule”) rather than just one merchant, simply enter a period [.] and nothing else into the **“When the merchant name contains:”** field. **Note:** Universal Rules will always take precedence over all other rules for category (more on this below). -- You can apply a rule to previously entered expenses by checking the **Apply to existing matching expenses** checkbox. Click “Preview Matching Expenses” to see if your rule matches the intended expenses. -- You can create expense rules while editing an expense. To do this, simply check the box **“Create a rule based on your changes"** at the time of editing. Note that the expense must be saved, reopened, and edited for this option to appear. - - -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_02.png){:width="100%"} - - -To delete an expense rule, go to **Settings > Account > Expense Rules**, scroll down to the rule you’d like to remove, and then click the trash can icon in the upper right corner of the rule: - -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_03.png){:width="100%"} - -# Deep Dive -In general, your expense rules will be applied in order, from **top to bottom**, i.e., from the first rule. However, other settings can impact how expense rules are applied. Here is the hierarchy that determines how these are applied: -1. A Universal Rule will **always** precede over any other expense category rules. Rules that would otherwise change the expense category will **not** override the Universal Rule. -2. If Scheduled Submit and the setting “Enforce Default Report Title” are enabled on the workspace, this will take precedence over any rules trying to add the expense to a report. -3. If the expense is from a Company Card that is forced to a workspace with strict rule enforcement, those rules will take precedence over individual expense rules. -4. If you belong to a workspace that is tied to an accounting integration, the configuration settings for this connection may update your expense details upon export, even if the expense rules were successfully applied to the expense. - - -{% include faq-begin.md %} -## How can I use Expense Rules to vendor match when exporting to an accounting package? -When exporting non-reimbursable expenses to your connected accounting package, the payee field will list "Credit Card Misc." if the merchant name on the expense in Expensify is not an exact match to a vendor in the accounting package. -When an exact match is unavailable, "Credit Card Misc." prevents multiple variations of the same vendor (e.g., Starbucks and Starbucks #1234, as is often seen in credit card statements) from being created in your accounting package. -For repeated expenses, the best practice is to use Expense Rules, which will automatically update the merchant name without having to do it manually each time. -This only works for connections to QuickBooks Online, Desktop, and Xero. Vendor matching cannot be performed in this manner for NetSuite or Sage Intacct due to limitations in the API of the accounting package. - -{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/settings/Set-Notifications.md b/docs/articles/expensify-classic/settings/Email-Notifications.md similarity index 60% rename from docs/articles/expensify-classic/settings/Set-Notifications.md rename to docs/articles/expensify-classic/settings/Email-Notifications.md index da55dafb833c..ff7449c5f9fd 100644 --- a/docs/articles/expensify-classic/settings/Set-Notifications.md +++ b/docs/articles/expensify-classic/settings/Email-Notifications.md @@ -1,10 +1,9 @@ --- -title: Set notifications -description: This article is about how to troubleshoot notifications from Expensify. +title: Expensify Email notifications +description: Troubleshooting steps for receiving emails and notifications from Expensify. --- -# Overview -Sometimes members may have trouble receiving important email notifications from Expensify, such as Expensify Magic Code emails, account validation emails, secondary login validations, integration emails, or report action notifications (rejections, approvals, etc.). +Occasionally, members may have trouble receiving email notifications from Expensify, such as Expensify Magic Code emails, account validation emails, secondary login validations, integration emails, or report action notifications. # Troubleshooting missing Expensify notifications @@ -12,45 +11,48 @@ Sometimes members may have trouble receiving important email notifications from Emails can sometimes be delayed and could take up to 30-60 minutes to arrive in your inbox. If you're expecting a notification that still hasn't arrived after waiting: - Check your **Email Preferences** on the web via **Settings > Account > Preferences**. In the **Contact Preferences** section, ensure that the relevant boxes are checked for the email type you're missing. - Check your email spam and trash folders, as Expensify messages might end up there inadvertently. - - Check to make sure you haven't unintentionally blocked Expensify emails. Allowlist the domain expensify.com with your email provider. + - Check to make sure you haven't unintentionally blocked Expensify emails. whitelist the domain expensify.com with your email provider. ## Issue: A banner that says “We’re having trouble emailing you” shows the top of your screen. -Confirm the email address on your Expensify account is a deliverable email address, and then click the link in the banner that says "here". If successful, you will see a confirmation that your email was unblocked. +Confirm that the email address on your Expensify account is deliverable, and then click the link in the banner that says "here." If successful, you will see a confirmation that your email was unblocked. ![ExpensifyHelp_EmailError]({{site.url}}/assets/images/ExpensifyHelp_EmailError.png){:width="100%"} **If unsuccessful, you will see another error:** - If the new error or SMTP message includes a URL, navigate to that URL for further instructions. - If the new error or SMTP message includes "mimecast.com", consult with your company's IT team. - - If the new error or SMTP message includes "blacklist", it means your company has configured their email servers to use a third-party email reputation or blocklisting service. Consult with your company's IT team. + - If the new error or SMTP message includes "blacklist," it means your company has configured its email servers to use a third-party email reputation or blocklisting service. Consult with your company's IT team. ![ExpensifyHelp_SMTPError]({{site.url}}/assets/images/ExpensifyHelp_SMTPError.png){:width="100%"} # Further troubleshooting for public domains -If you are still not receiving Expensify notifications and have an email address on a public domain such as gmail.com or yahoo.com, you may need to add Expensify's domain expensify.com to your email's allowlist by taking the following steps: +If you are still not receiving Expensify notifications and have an email address on a public domain such as gmail.com or yahoo.com, you may need to add Expensify's domain expensify.com to your email's whitelist by taking the following steps: - Search for messages from expensify.com in your spam folder, open them, and click “Not Spam” at the top of each message. - - Configure an email filter that identifies Expensify's email domain expensify.com and directs all incoming messages to your inbox, to avoid messages going to spam. - - Add specific known Expensify email addresses such as concierge@expensify.com to your email contacts list. + Configure an email filter that identifies Expensify's email domain as expensify.com and directs all incoming messages to your inbox to prevent messages from going to spam. + - Add specific known Expensify email addresses, such as concierge@expensify.com, to your email contacts list. # Further troubleshooting for private domains If your organization uses a private domain, Expensify emails may be blocked at the server level. This can sometimes happen unexpectedly due to broader changes in email provider's handling or filtering of incoming messages. Consult your internal IT team to assist with the following: - - Ensure that the domain expensify.com is allowlisted on domain email servers. This domains is the sources of various notification emails, so it's important it is allowlisted. - - Confirm there is no server-level email blocking and that spam filters are not blocking Expensify emails. Even if you have received messages from our Concierge support in the past, ensure that expensify.com is allowlisted. + - Ensure that the domain expensify.com is allowed on the domain email servers. This domain is the source of various notification emails, so it's important it is whitelisted. + - Confirm there is no server-level email blocking + - Make sure spam filters are not blocking Expensify emails. + +Even if you have received messages from our Concierge support in the past, ensure that expensify.com is whitelisted. ## Companies using Outlook - Add Expensify to your personal Safe Senders list by following these steps: [Outlook email client](https://support.microsoft.com/en-us/office/add-recipients-of-my-email-messages-to-the-safe-senders-list-be1baea0-beab-4a30-b968-9004332336ce) / [Outlook.com](https://support.microsoft.com/en-us/office/safe-senders-in-outlook-com-470d4ee6-e3b6-402b-8cd9-a6f00eda7339) - **Company IT administrators:** Add Expensify to your domain's Safe Sender list by following the steps here: [Create safe sender lists in EOP](https://learn.microsoft.com/en-us/defender-office-365/create-safe-sender-lists-in-office-365) -- **Company IT administrators:** Add expensify.com to the domain's explicit allowlist. You may need to contact Outlook support for specific instructions, as each company's setup varies. +**Company IT administrators:** Add expensify.com to the domain's explicit whitelist. As each company's setup varies, you may need to contact Outlook support for specific instructions. - **Company administrators:** Contact Outlook support to see if there are additional steps to take based on your domain's email configuration. ## Companies using Google Workspaces: -- **Company IT administrators:** Adjust your domain's email allowlist and safe senders lists to include expensify.com by following these steps: [Allowlists, denylists, and approved senders](https://support.google.com/a/answer/60752) +- **Company IT administrators:** Adjust your domain's email whitelist and safe senders lists to include expensify.com by following these steps: [Allowlists, denylists, and approved senders](https://support.google.com/a/answer/60752) {% include faq-begin.md %} @@ -60,10 +62,10 @@ Expensify's emails are SPF and DKIM-signed, meaning they are cryptographically s ## Why do legitimate emails from Expensify sometimes end up marked as spam? -The problem typically arises when our domain or one of our sending IP addresses gets erroneously flagged by a 3rd party domain or IP reputation services. Many IT departments use lists published by such services to filter email for the entire company. +The problem typically arises when a third-party domain or IP reputation service erroneously flags our domain or one of our sending IP addresses. Many IT departments use lists published by such services to filter email for the entire company. ## What is the best way to ensure emails are not accidentally marked as Spam? -For server-level spam detection, the safest approach to allowlisting email from Expensify is to verify DKIM and SPF, rather than solely relying on the third-party reputation of the sending IP address. +For server-level spam detection, the safest approach to whitelisting email from Expensify is to verify DKIM and SPF, rather than solely relying on the third-party reputation of the sending IP address. {% include faq-end.md %} diff --git a/docs/redirects.csv b/docs/redirects.csv index e1c0e12eb070..5bf8fa223ab9 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -591,6 +591,7 @@ https://help.expensify.com/articles/expensify-classic/articles/expensify-classic https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/add-a-payment-card-and-view-your-subscription,https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Billing-page-coming-soon,https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Billing-page +https://help.expensify.com/articles/expensify-classic/expenses/Expense-Rules,https://help.expensify.com/articles/expensify-classic/expenses/Create-Expense-Rules https://help.expensify.com/articles/expensify-classic/expenses/The-Expenses-Page,https://help.expensify.com/articles/expensify-classic/expenses/Navigate-the-Expenses-Page https://help.expensify.com/articles/expensify-classic/expenses/Add-expenses-in-bulk,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense https://help.expensify.com/articles/expensify-classic/expenses/Track-group-expenses,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense @@ -604,3 +605,4 @@ https://help.expensify.com/articles/expensify-classic/spending-insights/Default- https://help.expensify.com/articles/expensify-classic/spending-insights/Other-Export-Options,https://help.expensify.com/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports/ https://help.expensify.com/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements,https://help.expensify.com/articles/expensify-classic/travel/Book-with-Expensify-Travel https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills +https://help.expensify.com/articles/expensify-classic/settings/Set-Notifications,https://help.expensify.com/articles/expensify-classic/settings/Email-Notifications diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index ba4e887b78f6..a268b601b5db 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.71.0 + 9.0.71.2 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 190ebad05946..cae0d7a07cf6 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 9.0.71.0 + 9.0.71.2 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 9f38cc295fe1..4b2c45a06882 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.0.71 CFBundleVersion - 9.0.71.0 + 9.0.71.2 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index e495593a5831..519738dae386 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.71-0", + "version": "9.0.71-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.71-0", + "version": "9.0.71-2", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 21ea52960c58..c8f316bce71d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.71-0", + "version": "9.0.71-2", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/src/CONST.ts b/src/CONST.ts index ea9955600d9a..c28914541113 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -670,7 +670,6 @@ const CONST = { BETAS: { ALL: 'all', DEFAULT_ROOMS: 'defaultRooms', - DUPE_DETECTION: 'dupeDetection', P2P_DISTANCE_REQUESTS: 'p2pDistanceRequests', SPOTNANA_TRAVEL: 'spotnanaTravel', REPORT_FIELDS_FEATURE: 'reportFieldsFeature', @@ -1310,6 +1309,9 @@ const CONST = { SIDEBAR_LOADED: 'sidebar_loaded', LOAD_SEARCH_OPTIONS: 'load_search_options', SEND_MESSAGE: 'send_message', + APPLY_AIRSHIP_UPDATES: 'apply_airship_updates', + APPLY_PUSHER_UPDATES: 'apply_pusher_updates', + APPLY_HTTPS_UPDATES: 'apply_https_updates', COLD: 'cold', WARM: 'warm', REPORT_ACTION_ITEM_LAYOUT_DEBOUNCE_TIME: 1500, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index f97edbd744eb..3c3812774380 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -219,6 +219,9 @@ const ONYXKEYS = { /** The NVP containing all information related to educational tooltip in workspace chat */ NVP_WORKSPACE_TOOLTIP: 'workspaceTooltip', + /** The NVP containing the target url to navigate to when deleting a transaction */ + NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL: 'nvp_deleteTransactionNavigateBackURL', + /** Whether to show save search rename tooltip */ SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP: 'shouldShowSavedSearchRenameTooltip', @@ -1012,6 +1015,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: number; [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: number; [ONYXKEYS.NVP_WORKSPACE_TOOLTIP]: OnyxTypes.WorkspaceTooltip; + [ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL]: string | undefined; [ONYXKEYS.NVP_SHOULD_HIDE_GBR_TOOLTIP]: boolean; [ONYXKEYS.NVP_PRIVATE_CANCELLATION_DETAILS]: OnyxTypes.CancellationDetails[]; [ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE]: string; diff --git a/src/components/AddPaymentCard/PaymentCardForm.tsx b/src/components/AddPaymentCard/PaymentCardForm.tsx index 5aaa23b238f7..9843996602f1 100644 --- a/src/components/AddPaymentCard/PaymentCardForm.tsx +++ b/src/components/AddPaymentCard/PaymentCardForm.tsx @@ -165,10 +165,6 @@ function PaymentCardForm({ errors.addressStreet = translate(label.error.addressStreet); } - if (values.addressZipCode && !ValidationUtils.isValidZipCode(values.addressZipCode)) { - errors.addressZipCode = translate(label.error.addressZipCode); - } - if (!values.acceptTerms) { errors.acceptTerms = translate('common.error.acceptTerms'); } @@ -283,10 +279,9 @@ function PaymentCardForm({ InputComponent={TextInput} defaultValue={data?.addressZipCode} inputID={INPUT_IDS.ADDRESS_ZIP_CODE} - label={translate('common.zip')} - aria-label={translate('common.zip')} + label={translate('common.zipPostCode')} + aria-label={translate('common.zipPostCode')} role={CONST.ROLE.PRESENTATION} - inputMode={CONST.INPUT_MODE.NUMERIC} maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.ZIP_CODE} containerStyles={[styles.mt5]} /> diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index d5f72f7088b2..cea339de07e2 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -57,6 +57,25 @@ function Composer( inputCallbackRef(autoFocus ? textInput.current : null); }, [autoFocus, inputCallbackRef, autoFocusInputRef]); + useEffect(() => { + if (!textInput.current || !textInput.current.setSelection || !selection || isComposerFullSize) { + return; + } + + // We need the delay for setSelection to properly work for IOS in bridgeless mode due to a react native + // internal bug of dispatching the event before the component is ready for it. + // (see https://github.com/Expensify/App/pull/50520#discussion_r1861960311 for more context) + const timeoutID = setTimeout(() => { + // We are setting selection twice to trigger a scroll to the cursor on toggling to smaller composer size. + textInput.current?.setSelection((selection.start || 1) - 1, selection.start); + textInput.current?.setSelection(selection.start, selection.start); + }, 0); + + return () => clearTimeout(timeoutID); + + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [isComposerFullSize]); + /** * Set the TextInput Ref * @param {Element} el diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index e71ade65e66d..98ac9e00a98a 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -75,6 +75,7 @@ function Composer( const [isRendered, setIsRendered] = useState(false); const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? ''); const [prevScroll, setPrevScroll] = useState(); + const [prevHeight, setPrevHeight] = useState(); const isReportFlatListScrolling = useRef(false); useEffect(() => { @@ -243,11 +244,11 @@ function Composer( }, []); useEffect(() => { - if (!textInput.current || prevScroll === undefined) { + if (!textInput.current || prevScroll === undefined || prevHeight === undefined) { return; } // eslint-disable-next-line react-compiler/react-compiler - textInput.current.scrollTop = prevScroll; + textInput.current.scrollTop = prevScroll + prevHeight - textInput.current.clientHeight; // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isComposerFullSize]); @@ -353,6 +354,7 @@ function Composer( {...props} onSelectionChange={addCursorPositionToSelectionChange} onContentSizeChange={(e) => { + setPrevHeight(e.nativeEvent.contentSize.height); setHasMultipleLines(e.nativeEvent.contentSize.height > variables.componentSizeLarge); }} disabled={isDisabled} diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 9906f9b04c3c..4e1fef2d827e 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -53,14 +53,14 @@ function CalendarPicker({ const {preferredLocale, translate} = useLocalize(); const pressableRef = useRef(null); - const [currentDateView, setCurrentDateView] = useState(getInitialCurrentDateView(value, minDate, maxDate)); + const [currentDateView, setCurrentDateView] = useState(() => getInitialCurrentDateView(value, minDate, maxDate)); const [isYearPickerVisible, setIsYearPickerVisible] = useState(false); const minYear = getYear(new Date(minDate)); const maxYear = getYear(new Date(maxDate)); - const [years, setYears] = useState( + const [years, setYears] = useState(() => Array.from({length: maxYear - minYear + 1}, (v, i) => i + minYear).map((year) => ({ text: year.toString(), value: year, diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 8bf19a1edcbf..3e3f4d1b8e5d 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -9,7 +9,6 @@ import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {useSession} from '@components/OnyxProvider'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; import SubscriptAvatar from '@components/SubscriptAvatar'; import Text from '@components/Text'; @@ -52,12 +51,6 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti const [isOnboardingCompleted = true] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { selector: hasCompletedGuidedSetupFlowSelector, }); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const session = useSession(); - - // Guides are assigned for the MANAGE_TEAM onboarding action, except for emails that have a '+'. - const isOnboardingGuideAssigned = introSelected?.choice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM && !session?.email?.includes('+'); - const shouldShowToooltipOnThisReport = isOnboardingGuideAssigned ? ReportUtils.isAdminRoom(report) : ReportUtils.isConciergeChatReport(report); const [shouldHideGBRTooltip] = useOnyx(ONYXKEYS.NVP_SHOULD_HIDE_GBR_TOOLTIP, {initialValue: true}); const {translate} = useLocalize(); @@ -180,7 +173,9 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti needsOffscreenAlphaCompositing > setIsDeleteRequestModalVisible(false)} - onModalHide={() => ReportUtils.navigateBackAfterDeleteTransaction(navigateBackToAfterDelete.current)} + onModalHide={() => ReportUtils.navigateBackOnDeleteTransaction(navigateBackToAfterDelete.current)} prompt={translate('iou.deleteConfirmation', {count: 1})} confirmText={translate('common.delete')} cancelText={translate('common.cancel')} diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 9b5c0b1b6f56..7432c683e0a7 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -127,6 +127,9 @@ type PopoverMenuProps = Partial & { /** Whether to update the focused index on a row select */ shouldUpdateFocusedIndex?: boolean; + + /** Should we apply padding style in modal itself. If this value is false, we will handle it in ScreenWrapper */ + shouldUseModalPaddingStyle?: boolean; }; const renderWithConditionalWrapper = (shouldUseScrollView: boolean, contentContainerStyle: StyleProp, children: ReactNode): React.JSX.Element => { @@ -137,6 +140,10 @@ const renderWithConditionalWrapper = (shouldUseScrollView: boolean, contentConta return <>{children}; }; +function getSelectedItemIndex(menuItems: PopoverMenuItem[]) { + return menuItems.findIndex((option) => option.isSelected); +} + function PopoverMenu({ menuItems, onItemSelected, @@ -166,6 +173,7 @@ function PopoverMenu({ scrollContainerStyle, shouldUseScrollView = false, shouldUpdateFocusedIndex = true, + shouldUseModalPaddingStyle, }: PopoverMenuProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -174,7 +182,7 @@ function PopoverMenu({ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); const [currentMenuItems, setCurrentMenuItems] = useState(menuItems); - const currentMenuItemsFocusedIndex = currentMenuItems?.findIndex((option) => option.isSelected); + const currentMenuItemsFocusedIndex = getSelectedItemIndex(currentMenuItems); const [enteredSubMenuIndexes, setEnteredSubMenuIndexes] = useState(CONST.EMPTY_ARRAY); const {windowHeight} = useWindowDimensions(); @@ -300,7 +308,7 @@ function PopoverMenu({ ); const onModalHide = () => { - setFocusedIndex(-1); + setFocusedIndex(currentMenuItemsFocusedIndex); }; // When the menu items are changed, we want to reset the sub-menu to make sure @@ -312,7 +320,17 @@ function PopoverMenu({ } setEnteredSubMenuIndexes(CONST.EMPTY_ARRAY); setCurrentMenuItems(menuItems); - }, [menuItems]); + + // Update the focused item to match the selected item, but only when the popover is not visible. + // This ensures that if the popover is visible, highlight from the keyboard navigation is not overridden + // by external updates. + if (isVisible) { + return; + } + setFocusedIndex(getSelectedItemIndex(menuItems)); + + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [menuItems, setFocusedIndex]); return ( diff --git a/src/components/Search/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeaderInput.tsx index 85ca37b7a79a..dfc3d586fa83 100644 --- a/src/components/Search/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeaderInput.tsx @@ -13,7 +13,6 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as SearchActions from '@libs/actions/Search'; -import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -112,28 +111,15 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps const submitSearch = useCallback( (queryString: SearchQueryString) => { - if (!queryString) { + const queryWithSubstitutions = getQueryWithSubstitutions(queryString, autocompleteSubstitutions); + const updatedQuery = SearchQueryUtils.getQueryWithUpdatedValues(queryWithSubstitutions, queryJSON.policyID); + if (!updatedQuery) { return; } - const cleanedQueryString = getQueryWithSubstitutions(queryString, autocompleteSubstitutions); - const userQueryJSON = SearchQueryUtils.buildSearchQueryJSON(cleanedQueryString); + Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: updatedQuery})); - if (!userQueryJSON) { - Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} user query failed to parse`, {}, false); - return; - } - - if (queryJSON.policyID) { - userQueryJSON.policyID = queryJSON.policyID; - } - - const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(userQueryJSON, SearchQueryUtils.getUpdatedAmountValue); - const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery); - - Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); - - if (query !== originalInputQuery) { + if (updatedQuery !== originalInputQuery) { SearchActions.clearAllFilters(); setTextInputValue(''); setAutocompleteQueryValue(''); diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 14e84aaa80e5..e26b81d0222e 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -250,17 +250,14 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) const submitSearch = useCallback( (queryString: SearchQueryString) => { - const cleanedQueryString = getQueryWithSubstitutions(queryString, autocompleteSubstitutions); - const queryJSON = SearchQueryUtils.buildSearchQueryJSON(cleanedQueryString); - if (!queryJSON) { + const queryWithSubstitutions = getQueryWithSubstitutions(queryString, autocompleteSubstitutions); + const updatedQuery = SearchQueryUtils.getQueryWithUpdatedValues(queryWithSubstitutions, activeWorkspaceID); + if (!updatedQuery) { return; } - queryJSON.policyID = activeWorkspaceID; - onRouterClose(); - const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(queryJSON, SearchQueryUtils.getUpdatedAmountValue); - const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery); - Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); + onRouterClose(); + Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: updatedQuery})); setTextInputValue(''); setAutocompleteQueryValue(''); diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx index 9de6b6dd6d08..f6b0ef1e0157 100644 --- a/src/components/TextInput/BaseTextInput/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/index.native.tsx @@ -16,6 +16,7 @@ import Text from '@components/Text'; import * as styleConst from '@components/TextInput/styleConst'; import TextInputClearButton from '@components/TextInput/TextInputClearButton'; import TextInputLabel from '@components/TextInput/TextInputLabel'; +import useHtmlPaste from '@hooks/useHtmlPaste'; import useLocalize from '@hooks/useLocalize'; import useMarkdownStyle from '@hooks/useMarkdownStyle'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -98,6 +99,7 @@ function BaseTextInput( const labelTranslateY = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y)).current; const input = useRef(null); const isLabelActive = useRef(initialActiveLabel); + useHtmlPaste(input, undefined, false, isMarkdownEnabled); // AutoFocus which only works on mount: useEffect(() => { diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index e36ae60255fc..b1adf21d2ca8 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -1,7 +1,7 @@ import {Str} from 'expensify-common'; -import type {ForwardedRef} from 'react'; +import type {ForwardedRef, MutableRefObject} from 'react'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, ViewStyle} from 'react-native'; +import type {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TextInput, TextInputFocusEventData, ViewStyle} from 'react-native'; import {ActivityIndicator, Animated, StyleSheet, View} from 'react-native'; import Checkbox from '@components/Checkbox'; import FormHelpMessage from '@components/FormHelpMessage'; @@ -17,6 +17,7 @@ import Text from '@components/Text'; import * as styleConst from '@components/TextInput/styleConst'; import TextInputClearButton from '@components/TextInput/TextInputClearButton'; import TextInputLabel from '@components/TextInput/TextInputLabel'; +import useHtmlPaste from '@hooks/useHtmlPaste'; import useLocalize from '@hooks/useLocalize'; import useMarkdownStyle from '@hooks/useMarkdownStyle'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -103,6 +104,7 @@ function BaseTextInput( const labelTranslateY = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y)).current; const input = useRef(null); const isLabelActive = useRef(initialActiveLabel); + useHtmlPaste(input as MutableRefObject, undefined, false, isMarkdownEnabled); // AutoFocus which only works on mount: useEffect(() => { diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx index 140d3f5eccc4..a77993e90f92 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.tsx +++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx @@ -73,7 +73,7 @@ function BaseVideoPlayer({ const [isEnded, setIsEnded] = useState(false); const [isBuffering, setIsBuffering] = useState(true); // we add "#t=0.001" at the end of the URL to skip first milisecond of the video and always be able to show proper video preview when video is paused at the beginning - const [sourceURL] = useState(VideoUtils.addSkipTimeTagToURL(url.includes('blob:') || url.includes('file:///') ? url : addEncryptedAuthTokenToURL(url), 0.001)); + const [sourceURL] = useState(() => VideoUtils.addSkipTimeTagToURL(url.includes('blob:') || url.includes('file:///') ? url : addEncryptedAuthTokenToURL(url), 0.001)); const [isPopoverVisible, setIsPopoverVisible] = useState(false); const [popoverAnchorPosition, setPopoverAnchorPosition] = useState({horizontal: 0, vertical: 0}); const [controlStatusState, setControlStatusState] = useState(controlsStatus); diff --git a/src/hooks/useHtmlPaste/index.ts b/src/hooks/useHtmlPaste/index.ts index 6199a36abdca..791c81c6ba98 100644 --- a/src/hooks/useHtmlPaste/index.ts +++ b/src/hooks/useHtmlPaste/index.ts @@ -4,6 +4,7 @@ import Parser from '@libs/Parser'; import type UseHtmlPaste from './types'; const insertByCommand = (text: string) => { + // eslint-disable-next-line deprecation/deprecation document.execCommand('insertText', false, text); }; @@ -27,7 +28,7 @@ const insertAtCaret = (target: HTMLElement, text: string) => { } }; -const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, removeListenerOnScreenBlur = false) => { +const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, removeListenerOnScreenBlur = false, isMarkdownEnabled = true) => { const navigation = useNavigation(); /** @@ -129,6 +130,9 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, removeLi ); useEffect(() => { + if (!isMarkdownEnabled) { + return; + } // we need to re-register listener on navigation focus/blur if the component (like Composer) is not unmounting // when navigating away to different screen (report) to avoid paste event on other screen being wrongly handled // by current screen paste listener @@ -149,7 +153,7 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, removeLi document.removeEventListener('paste', handlePaste, true); }; // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, []); + }, [isMarkdownEnabled]); }; export default useHtmlPaste; diff --git a/src/hooks/useHtmlPaste/types.ts b/src/hooks/useHtmlPaste/types.ts index 305ebe5fbd0f..6518ccd8ef3e 100644 --- a/src/hooks/useHtmlPaste/types.ts +++ b/src/hooks/useHtmlPaste/types.ts @@ -5,6 +5,7 @@ type UseHtmlPaste = ( textInputRef: MutableRefObject<(HTMLTextAreaElement & TextInput) | TextInput | null>, preHtmlPasteCallback?: (event: ClipboardEvent) => boolean, removeListenerOnScreenBlur?: boolean, + isMarkdownEnabled?: boolean, ) => void; export default UseHtmlPaste; diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts index 5eb77f2d45e7..cc541eff1487 100644 --- a/src/hooks/useViolations.ts +++ b/src/hooks/useViolations.ts @@ -49,9 +49,6 @@ const violationFields: Record = { type ViolationsMap = Map; -// We don't want to show these violations on NewDot -const excludedViolationsName = ['taxAmountChanged', 'taxRateChanged']; - /** * @param violations – List of transaction violations * @param shouldShowOnlyViolations – Whether we should only show violations of type 'violation' @@ -59,9 +56,6 @@ const excludedViolationsName = ['taxAmountChanged', 'taxRateChanged']; function useViolations(violations: TransactionViolation[], shouldShowOnlyViolations: boolean) { const violationsByField = useMemo((): ViolationsMap => { const filteredViolations = violations.filter((violation) => { - if (excludedViolationsName.includes(violation.name)) { - return false; - } if (shouldShowOnlyViolations) { return violation.type === CONST.VIOLATION_TYPES.VIOLATION; } diff --git a/src/languages/en.ts b/src/languages/en.ts index fe791f4e18a4..d79695ed8b48 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3311,6 +3311,7 @@ const translations = { error: { pleaseSelectProvider: 'Please select a card provider before continuing.', pleaseSelectBankAccount: 'Please select a bank account before continuing.', + pleaseSelectBank: 'Please select a bank before continuing.', pleaseSelectFeedType: 'Please select a feed type before continuing.', }, }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 42da5f745ded..5ce47db18d35 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3351,6 +3351,7 @@ const translations = { error: { pleaseSelectProvider: 'Seleccione un proveedor de tarjetas antes de continuar.', pleaseSelectBankAccount: 'Seleccione una cuenta bancaria antes de continuar.', + pleaseSelectBank: 'Seleccione una bancaria antes de continuar.', pleaseSelectFeedType: 'Seleccione un tipo de pienso antes de continuar.', }, }, diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index d31da53304f6..892bad17928e 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -317,7 +317,6 @@ const WRITE_COMMANDS = { DELETE_MONEY_REQUEST_ON_SEARCH: 'DeleteMoneyRequestOnSearch', HOLD_MONEY_REQUEST_ON_SEARCH: 'HoldMoneyRequestOnSearch', APPROVE_MONEY_REQUEST_ON_SEARCH: 'ApproveMoneyRequestOnSearch', - PAY_MONEY_REQUEST_ON_SEARCH: 'PayMoneyRequestOnSearch', UNHOLD_MONEY_REQUEST_ON_SEARCH: 'UnholdMoneyRequestOnSearch', REQUEST_REFUND: 'User_RefundPurchase', UPDATE_NETSUITE_SUBSIDIARY: 'UpdateNetSuiteSubsidiary', @@ -767,7 +766,6 @@ type WriteCommandParameters = { [WRITE_COMMANDS.DELETE_MONEY_REQUEST_ON_SEARCH]: Parameters.DeleteMoneyRequestOnSearchParams; [WRITE_COMMANDS.HOLD_MONEY_REQUEST_ON_SEARCH]: Parameters.HoldMoneyRequestOnSearchParams; [WRITE_COMMANDS.APPROVE_MONEY_REQUEST_ON_SEARCH]: Parameters.ApproveMoneyRequestOnSearchParams; - [WRITE_COMMANDS.PAY_MONEY_REQUEST_ON_SEARCH]: Parameters.PayMoneyRequestOnSearchParams; [WRITE_COMMANDS.UNHOLD_MONEY_REQUEST_ON_SEARCH]: Parameters.UnholdMoneyRequestOnSearchParams; [WRITE_COMMANDS.REQUEST_REFUND]: null; @@ -1029,6 +1027,9 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { DISCONNECT_AS_DELEGATE: 'DisconnectAsDelegate', COMPLETE_HYBRID_APP_ONBOARDING: 'CompleteHybridAppOnboarding', CONNECT_POLICY_TO_QUICKBOOKS_DESKTOP: 'ConnectPolicyToQuickbooksDesktop', + + // PayMoneyRequestOnSearch only works online (pattern C) and we need to play the success sound only when the request is successful + PAY_MONEY_REQUEST_ON_SEARCH: 'PayMoneyRequestOnSearch', } as const; type SideEffectRequestCommand = ValueOf; @@ -1049,6 +1050,7 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.DISCONNECT_AS_DELEGATE]: EmptyObject; [SIDE_EFFECT_REQUEST_COMMANDS.COMPLETE_HYBRID_APP_ONBOARDING]: EmptyObject; [SIDE_EFFECT_REQUEST_COMMANDS.CONNECT_POLICY_TO_QUICKBOOKS_DESKTOP]: Parameters.ConnectPolicyToQuickBooksDesktopParams; + [SIDE_EFFECT_REQUEST_COMMANDS.PAY_MONEY_REQUEST_ON_SEARCH]: Parameters.PayMoneyRequestOnSearchParams; }; type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; diff --git a/src/libs/CategoryUtils.ts b/src/libs/CategoryUtils.ts index 789026f91af6..207977ddb000 100644 --- a/src/libs/CategoryUtils.ts +++ b/src/libs/CategoryUtils.ts @@ -1,7 +1,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; import type {Policy, TaxRate, TaxRatesWithDefault} from '@src/types/onyx'; -import type {ApprovalRule, ExpenseRule} from '@src/types/onyx/Policy'; +import type {ApprovalRule, ExpenseRule, MccGroup} from '@src/types/onyx/Policy'; import * as CurrencyUtils from './CurrencyUtils'; function formatDefaultTaxRateText(translate: LocaleContextProps['translate'], taxID: string, taxRate: TaxRate, policyTaxRates?: TaxRatesWithDefault) { @@ -68,4 +68,19 @@ function getCategoryDefaultTaxRate(expenseRules: ExpenseRule[], categoryName: st return categoryDefaultTaxRate; } -export {formatDefaultTaxRateText, formatRequireReceiptsOverText, getCategoryApproverRule, getCategoryExpenseRule, getCategoryDefaultTaxRate}; +function updateCategoryInMccGroup(mccGroups: Record, oldCategoryName: string, newCategoryName: string, shouldClearPendingAction?: boolean) { + if (oldCategoryName === newCategoryName) { + return mccGroups; + } + + const updatedGroups: Record = {}; + + for (const [key, group] of Object.entries(mccGroups || {})) { + updatedGroups[key] = + group.category === oldCategoryName ? {...group, category: newCategoryName, pendingAction: shouldClearPendingAction ? null : CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE} : group; + } + + return updatedGroups; +} + +export {formatDefaultTaxRateText, formatRequireReceiptsOverText, getCategoryApproverRule, getCategoryExpenseRule, getCategoryDefaultTaxRate, updateCategoryInMccGroup}; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 21851ee96599..99cd1498511c 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -550,7 +550,6 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie name={SCREENS.WORKSPACE_JOIN_USER} options={{ headerShown: false, - presentation: Presentation.TRANSPARENT_MODAL, }} listeners={modalScreenListeners} getComponent={loadWorkspaceJoinUser} diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index 0e7dfd4a0a0b..c9b43498734a 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -124,7 +124,7 @@ function OnyxTabNavigator({ const index = state.index; const routeNames = state.routeNames; const newSelectedTab = routeNames.at(index); - if (selectedTab === newSelectedTab) { + if (selectedTab === newSelectedTab || (selectedTab && !routeNames.includes(selectedTab))) { return; } Tab.setSelectedTab(id, newSelectedTab as SelectedTabRequest); diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 1d3428dfcfce..4a7bba3932a3 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -13,10 +13,6 @@ function canUseDefaultRooms(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.DEFAULT_ROOMS) || canUseAllBetas(betas); } -function canUseDupeDetection(betas: OnyxEntry): boolean { - return !!betas?.includes(CONST.BETAS.DUPE_DETECTION) || canUseAllBetas(betas); -} - function canUseSpotnanaTravel(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.SPOTNANA_TRAVEL) || canUseAllBetas(betas); } @@ -49,7 +45,6 @@ function canUseLinkPreviews(): boolean { export default { canUseDefaultRooms, canUseLinkPreviews, - canUseDupeDetection, canUseSpotnanaTravel, canUseNetSuiteUSATax, canUseCombinedTrackSubmit, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index a19ffbae1703..136521e23f64 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4208,7 +4208,7 @@ function goBackToDetailsPage(report: OnyxEntry, backTo?: string) { Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(report?.reportID ?? '-1', backTo)); } -function navigateBackAfterDeleteTransaction(backRoute: Route | undefined, isFromRHP?: boolean) { +function navigateBackOnDeleteTransaction(backRoute: Route | undefined, isFromRHP?: boolean) { if (!backRoute) { return; } @@ -8720,7 +8720,7 @@ export { canWriteInReport, navigateToDetailsPage, navigateToPrivateNotes, - navigateBackAfterDeleteTransaction, + navigateBackOnDeleteTransaction, parseReportRouteParams, parseReportActionHtmlToText, requiresAttentionFromCurrentUser, diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index f975c575400d..0f1354414ee0 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -11,7 +11,9 @@ import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import * as CardUtils from './CardUtils'; import * as CurrencyUtils from './CurrencyUtils'; import localeCompare from './LocaleCompare'; +import Log from './Log'; import {validateAmount} from './MoneyRequestUtils'; +import * as PersonalDetailsUtils from './PersonalDetailsUtils'; import {getTagNamesFromTagsLists} from './PolicyUtils'; import * as ReportUtils from './ReportUtils'; import * as searchParser from './SearchParser/searchParser'; @@ -163,21 +165,32 @@ function getFilters(queryJSON: SearchQueryJSON) { } /** - * Returns an updated amount value for query filters, correctly formatted to "backend" amount + * @private + * Returns an updated filter value for some query filters. + * - for `AMOUNT` it formats value to "backend" amount + * - for personal filters it tries to substitute any user emails with accountIDs */ -function getUpdatedAmountValue(filterName: ValueOf, filter: string | string[]) { - if (filterName !== CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { - return filter; +function getUpdatedFilterValue(filterName: ValueOf, filterValue: string | string[]) { + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) { + if (typeof filterValue === 'string') { + return PersonalDetailsUtils.getPersonalDetailByEmail(filterValue)?.accountID.toString() ?? filterValue; + } + + return filterValue.map((email) => PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? email); } - if (typeof filter === 'string') { - const backendAmount = CurrencyUtils.convertToBackendAmount(Number(filter)); - return Number.isNaN(backendAmount) ? filter : backendAmount.toString(); + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { + if (typeof filterValue === 'string') { + const backendAmount = CurrencyUtils.convertToBackendAmount(Number(filterValue)); + return Number.isNaN(backendAmount) ? filterValue : backendAmount.toString(); + } + return filterValue.map((amount) => { + const backendAmount = CurrencyUtils.convertToBackendAmount(Number(amount)); + return Number.isNaN(backendAmount) ? amount : backendAmount.toString(); + }); } - return filter.map((amount) => { - const backendAmount = CurrencyUtils.convertToBackendAmount(Number(amount)); - return Number.isNaN(backendAmount) ? amount : backendAmount.toString(); - }); + + return filterValue; } /** @@ -266,7 +279,7 @@ function buildSearchQueryString(queryJSON?: SearchQueryJSON) { for (const filter of filters) { const filterValueString = buildFilterValuesString(filter.key, filter.filters); - queryParts.push(filterValueString); + queryParts.push(filterValueString.trim()); } return queryParts.join(' '); @@ -625,6 +638,26 @@ function traverseAndUpdatedQuery(queryJSON: SearchQueryJSON, computeNodeValue: ( return standardQuery; } +/** + * Returns new string query, after parsing it and traversing to update some filter values. + * If there are any personal emails, it will try to substitute them with accountIDs + */ +function getQueryWithUpdatedValues(query: string, policyID?: string) { + const queryJSON = buildSearchQueryJSON(query); + + if (!queryJSON) { + Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} user query failed to parse`, {}, false); + return; + } + + if (policyID) { + queryJSON.policyID = policyID; + } + + const standardizedQuery = traverseAndUpdatedQuery(queryJSON, getUpdatedFilterValue); + return buildSearchQueryString(standardizedQuery); +} + export { buildSearchQueryJSON, buildSearchQueryString, @@ -635,7 +668,6 @@ export { getPolicyIDFromSearchQuery, buildCannedSearchQuery, isCannedSearchQuery, - traverseAndUpdatedQuery, - getUpdatedAmountValue, sanitizeSearchValue, + getQueryWithUpdatedValues, }; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index a7c6baae85a1..944c431aaad9 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -15,7 +15,6 @@ import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {toLocaleDigit} from '@libs/LocaleDigitUtils'; import * as Localize from '@libs/Localize'; import * as NumberUtils from '@libs/NumberUtils'; -import Permissions from '@libs/Permissions'; import {getCleanedTagName, getDistanceRateCustomUnitRate} from '@libs/PolicyUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; // eslint-disable-next-line import/no-cycle @@ -26,7 +25,7 @@ import type {IOURequestType} from '@userActions/IOU'; import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Beta, OnyxInputOrEntry, Policy, RecentWaypoint, Report, ReviewDuplicates, TaxRate, TaxRates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; +import type {OnyxInputOrEntry, Policy, RecentWaypoint, Report, ReviewDuplicates, TaxRate, TaxRates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import type {SearchPolicy, SearchReport} from '@src/types/onyx/SearchResults'; import type {Comment, Receipt, TransactionChanges, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; @@ -74,12 +73,6 @@ Onyx.connect({ }, }); -let allBetas: OnyxEntry; -Onyx.connect({ - key: ONYXKEYS.BETAS, - callback: (value) => (allBetas = value), -}); - function isDistanceRequest(transaction: OnyxEntry): boolean { // This is used during the expense creation flow before the transaction has been saved to the server if (lodashHas(transaction, 'iouRequestType')) { @@ -843,10 +836,6 @@ function getRecentTransactions(transactions: Record, size = 2): * @param checkDismissed - whether to check if the violation has already been dismissed as well */ function isDuplicate(transactionID: string, checkDismissed = false): boolean { - if (!Permissions.canUseDupeDetection(allBetas ?? [])) { - return false; - } - const hasDuplicatedViolation = !!allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]?.some( (violation: TransactionViolation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION, ); @@ -909,7 +898,7 @@ function hasWarningTypeViolation(transactionID: string, transactionViolations: O ) ?? []; const hasOnlyDupeDetectionViolation = warningTypeViolations?.every((violation: TransactionViolation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION); - if (!Permissions.canUseDupeDetection(allBetas ?? []) && hasOnlyDupeDetectionViolation) { + if (hasOnlyDupeDetectionViolation) { return false; } diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index ddd5d7851d1e..c02426ecdfb9 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -61,6 +61,7 @@ import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee, Participant, Split} from '@src/types/onyx/IOU'; import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; @@ -167,7 +168,7 @@ type GPSPoint = { }; type RequestMoneyTransactionParams = { - attendees: Attendee[] | undefined; + attendees?: Attendee[]; amount: number; currency: string; comment?: string; @@ -206,6 +207,16 @@ type RequestMoneyInformation = { transactionParams: RequestMoneyTransactionParams; }; +type MoneyRequestInformationParams = { + parentChatReport: OnyxEntry; + transactionParams: RequestMoneyTransactionParams; + participantParams: RequestMoneyParticipantParams; + policyParams?: RequestMoneyPolicyParams; + moneyRequestReportID?: string; + existingTransactionID?: string; + existingTransaction?: OnyxEntry; +}; + let allPersonalDetails: OnyxTypes.PersonalDetailsList = {}; Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -1785,10 +1796,21 @@ function getDeleteTrackExpenseInformation( if (shouldDeleteTransactionThread) { optimisticData.push( + // Use merge instead of set to avoid deleting the report too quickly, which could cause a brief "not found" page to appear. + // The remaining parts of the report object will be removed after the API call is successful. { - onyxMethod: Onyx.METHOD.SET, + onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`, - value: null, + value: { + reportID: null, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + participants: { + [userAccountID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + }, + }, + }, }, { onyxMethod: Onyx.METHOD.SET, @@ -1828,6 +1850,19 @@ function getDeleteTrackExpenseInformation( }, ]; + // Ensure that any remaining data is removed upon successful completion, even if the server sends a report removal response. + // This is done to prevent the removal update from lingering in the applyHTTPSOnyxUpdates function. + if (shouldDeleteTransactionThread && transactionThread) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`, + value: Object.keys(transactionThread).reduce>((acc, key) => { + acc[key] = null; + return acc; + }, {}), + }); + } + const failureData: OnyxUpdate[] = []; if (shouldDeleteTransactionFromOnyx) { @@ -2044,31 +2079,12 @@ function getSendInvoiceInformation( * Gathers all the data needed to submit an expense. It attempts to find existing reports, iouReports, and receipts. If it doesn't find them, then * it creates optimistic versions of them and uses those instead */ -function getMoneyRequestInformation( - parentChatReport: OnyxEntry, - participant: Participant, - comment: string, - amount: number, - currency: string, - created: string, - merchant: string, - receipt: Receipt | undefined, - existingTransactionID: string | undefined, - category: string | undefined, - tag: string | undefined, - taxCode: string | undefined, - taxAmount: number | undefined, - billable: boolean | undefined, - policy: OnyxEntry | undefined, - policyTagList: OnyxEntry | undefined, - policyCategories: OnyxEntry | undefined, - payeeAccountID = userAccountID, - payeeEmail = currentUserEmail, - moneyRequestReportID = '', - linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction, - attendees?: Attendee[], - existingTransaction: OnyxEntry | undefined = undefined, -): MoneyRequestInformation { +function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInformationParams): MoneyRequestInformation { + const {parentChatReport, transactionParams, participantParams, policyParams = {}, existingTransaction, existingTransactionID, moneyRequestReportID = ''} = moneyRequestInformation; + const {payeeAccountID = userAccountID, payeeEmail = currentUserEmail, participant} = participantParams; + const {policy, policyCategories, policyTagList} = policyParams; + const {attendees, amount, comment = '', currency, created, merchant, receipt, category, tag, taxCode, taxAmount, billable, linkedTrackedExpenseReportAction} = transactionParams; + const payerEmail = PhoneNumber.addSMSDomainIfPhoneNumber(participant.login ?? ''); const payerAccountID = Number(participant.accountID); const isPolicyExpenseChat = participant.isPolicyExpenseChat; @@ -3598,8 +3614,7 @@ function shareTrackedExpense( */ function requestMoney(requestMoneyInformation: RequestMoneyInformation) { const {report, participantParams, policyParams = {}, transactionParams, gpsPoints, action, reimbursible} = requestMoneyInformation; - const {participant, payeeAccountID, payeeEmail} = participantParams; - const {policy, policyCategories, policyTagList} = policyParams; + const {payeeAccountID} = participantParams; const { amount, currency, @@ -3637,32 +3652,17 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation) { transactionThreadReportID, createdReportActionIDForThread, onyxData, - } = getMoneyRequestInformation( - isMovingTransactionFromTrackExpense ? undefined : currentChatReport, - participant, - comment, - amount, - currency, - created, - merchant, - receipt, - isMovingTransactionFromTrackExpense && linkedTrackedExpenseReportAction && ReportActionsUtils.isMoneyRequestAction(linkedTrackedExpenseReportAction) - ? ReportActionsUtils.getOriginalMessage(linkedTrackedExpenseReportAction)?.IOUTransactionID - : undefined, - category, - tag, - taxCode, - taxAmount, - billable, - policy, - policyTagList, - policyCategories, - payeeAccountID, - payeeEmail, + } = getMoneyRequestInformation({ + parentChatReport: isMovingTransactionFromTrackExpense ? undefined : currentChatReport, + participantParams, + policyParams, + transactionParams, moneyRequestReportID, - linkedTrackedExpenseReportAction, - attendees, - ); + existingTransactionID: + isMovingTransactionFromTrackExpense && linkedTrackedExpenseReportAction && ReportActionsUtils.isMoneyRequestAction(linkedTrackedExpenseReportAction) + ? ReportActionsUtils.getOriginalMessage(linkedTrackedExpenseReportAction)?.IOUTransactionID + : undefined, + }); const activeReportID = isMoneyRequestReport ? report?.reportID : chatReport.reportID; switch (action) { @@ -5319,31 +5319,35 @@ function createDistanceRequest( createdReportActionIDForThread, payerEmail, onyxData: moneyRequestOnyxData, - } = getMoneyRequestInformation( - currentChatReport, - participant, - comment, - amount, - currency, - created, - merchant, - optimisticReceipt, - undefined, - category, - tag, - taxCode, - taxAmount, - billable, - policy, - policyTagList, - policyCategories, - userAccountID, - currentUserEmail, - moneyRequestReportID, - undefined, - undefined, + } = getMoneyRequestInformation({ + parentChatReport: currentChatReport, existingTransaction, - ); + moneyRequestReportID, + participantParams: { + participant, + payeeAccountID: userAccountID, + payeeEmail: currentUserEmail, + }, + policyParams: { + policy, + policyCategories, + policyTagList, + }, + transactionParams: { + amount, + currency, + comment, + created, + merchant, + receipt: optimisticReceipt, + category, + tag, + taxCode, + taxAmount, + billable, + }, + }); + onyxData = moneyRequestOnyxData; parameters = { @@ -5430,10 +5434,9 @@ function updateMoneyRequestAmountAndCurrency({ * * @param transactionID - The transactionID of IOU * @param reportAction - The reportAction of the transaction in the IOU report - * @param isSingleTransactionView - whether we are in the transaction thread report * @return the url to navigate back once the money request is deleted */ -function prepareToCleanUpMoneyRequest(transactionID: string, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false) { +function prepareToCleanUpMoneyRequest(transactionID: string, reportAction: OnyxTypes.ReportAction) { // STEP 1: Get all collections we're updating const allReports = ReportConnection.getAllReports(); const iouReportID = ReportActionsUtils.isMoneyRequestAction(reportAction) ? ReportActionsUtils.getOriginalMessage(reportAction)?.IOUReportID : '-1'; @@ -5553,19 +5556,6 @@ function prepareToCleanUpMoneyRequest(transactionID: string, reportAction: OnyxT updatedReportPreviewAction.childMoneyRequestCount = reportPreviewAction.childMoneyRequestCount - 1; } - // STEP 5: Calculate the url that the user will be navigated back to - // This depends on which page they are on and which resources were deleted - let reportIDToNavigateBack: string | undefined; - if (iouReport && isSingleTransactionView && shouldDeleteTransactionThread && !shouldDeleteIOUReport) { - reportIDToNavigateBack = iouReport.reportID; - } - - if (iouReport?.chatReportID && shouldDeleteIOUReport) { - reportIDToNavigateBack = iouReport.chatReportID; - } - - const urlToNavigateBack = reportIDToNavigateBack ? ROUTES.REPORT_WITH_ID.getRoute(reportIDToNavigateBack) : undefined; - return { shouldDeleteTransactionThread, shouldDeleteIOUReport, @@ -5579,10 +5569,59 @@ function prepareToCleanUpMoneyRequest(transactionID: string, reportAction: OnyxT transactionViolations, reportPreviewAction, iouReport, - urlToNavigateBack, }; } +/** + * Calculate the URL to navigate to after a money request deletion + * @param transactionID - The ID of the money request being deleted + * @param reportAction - The report action associated with the money request + * @param isSingleTransactionView - whether we are in the transaction thread report + * @returns The URL to navigate to + */ +function getNavigationUrlOnMoneyRequestDelete(transactionID: string, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false): Route | undefined { + const {shouldDeleteTransactionThread, shouldDeleteIOUReport, iouReport} = prepareToCleanUpMoneyRequest(transactionID, reportAction); + + // Determine which report to navigate back to + if (iouReport && isSingleTransactionView && shouldDeleteTransactionThread && !shouldDeleteIOUReport) { + return ROUTES.REPORT_WITH_ID.getRoute(iouReport.reportID); + } + + if (iouReport?.chatReportID && shouldDeleteIOUReport) { + return ROUTES.REPORT_WITH_ID.getRoute(iouReport.chatReportID); + } + + return undefined; +} + +/** + * Calculate the URL to navigate to after a track expense deletion + * @param chatReportID - The ID of the chat report containing the track expense + * @param transactionID - The ID of the track expense being deleted + * @param reportAction - The report action associated with the track expense + * @param isSingleTransactionView - Whether we're in single transaction view + * @returns The URL to navigate to + */ +function getNavigationUrlAfterTrackExpenseDelete(chatReportID: string, transactionID: string, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false): Route | undefined { + const chatReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] ?? null; + + // If not a self DM, handle it as a regular money request + if (!ReportUtils.isSelfDM(chatReport)) { + return getNavigationUrlOnMoneyRequestDelete(transactionID, reportAction, isSingleTransactionView); + } + + const transactionThreadID = reportAction.childReportID; + const shouldDeleteTransactionThread = transactionThreadID ? (reportAction?.childVisibleActionCount ?? 0) === 0 : false; + + // Only navigate if in single transaction view and the thread will be deleted + if (isSingleTransactionView && shouldDeleteTransactionThread && chatReport?.reportID) { + // Pop the deleted report screen before navigating. This prevents navigating to the Concierge chat due to the missing report. + return ROUTES.REPORT_WITH_ID.getRoute(chatReport.reportID); + } + + return undefined; +} + /** * * @param transactionID - The transactionID of IOU @@ -5601,9 +5640,9 @@ function cleanUpMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repo chatReport, iouReport, reportPreviewAction, - urlToNavigateBack, - } = prepareToCleanUpMoneyRequest(transactionID, reportAction, isSingleTransactionView); + } = prepareToCleanUpMoneyRequest(transactionID, reportAction); + const urlToNavigateBack = getNavigationUrlOnMoneyRequestDelete(transactionID, reportAction, isSingleTransactionView); // build Onyx data // Onyx operations to delete the transaction, update the IOU report action and chat report action @@ -5747,8 +5786,9 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor transactionViolations, iouReport, reportPreviewAction, - urlToNavigateBack, - } = prepareToCleanUpMoneyRequest(transactionID, reportAction, isSingleTransactionView); + } = prepareToCleanUpMoneyRequest(transactionID, reportAction); + + const urlToNavigateBack = getNavigationUrlOnMoneyRequestDelete(transactionID, reportAction, isSingleTransactionView); // STEP 2: Build Onyx data // The logic mostly resembles the cleanUpMoneyRequest function @@ -5768,10 +5808,21 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor if (shouldDeleteTransactionThread) { optimisticData.push( + // Use merge instead of set to avoid deleting the report too quickly, which could cause a brief "not found" page to appear. + // The remaining parts of the report object will be removed after the API call is successful. { - onyxMethod: Onyx.METHOD.SET, + onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`, - value: null, + value: { + reportID: null, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + participants: { + [userAccountID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + }, + }, + }, }, { onyxMethod: Onyx.METHOD.SET, @@ -5869,6 +5920,19 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor }, ]; + // Ensure that any remaining data is removed upon successful completion, even if the server sends a report removal response. + // This is done to prevent the removal update from lingering in the applyHTTPSOnyxUpdates function. + if (shouldDeleteTransactionThread && transactionThread) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`, + value: Object.keys(transactionThread).reduce>((acc, key) => { + acc[key] = null; + return acc; + }, {}), + }); + } + if (shouldDeleteIOUReport) { successData.push({ onyxMethod: Onyx.METHOD.SET, @@ -5972,15 +6036,18 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor } function deleteTrackExpense(chatReportID: string, transactionID: string, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false) { + const urlToNavigateBack = getNavigationUrlAfterTrackExpenseDelete(chatReportID, transactionID, reportAction, isSingleTransactionView); + // STEP 1: Get all collections we're updating const chatReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] ?? null; if (!ReportUtils.isSelfDM(chatReport)) { - return deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView); + deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView); + return urlToNavigateBack; } const whisperAction = ReportActionsUtils.getTrackExpenseActionableWhisper(transactionID, chatReportID); const actionableWhisperReportActionID = whisperAction?.reportActionID; - const {parameters, optimisticData, successData, failureData, shouldDeleteTransactionThread} = getDeleteTrackExpenseInformation( + const {parameters, optimisticData, successData, failureData} = getDeleteTrackExpenseInformation( chatReportID, transactionID, reportAction, @@ -5995,10 +6062,7 @@ function deleteTrackExpense(chatReportID: string, transactionID: string, reportA CachedPDFPaths.clearByKey(transactionID); // STEP 7: Navigate the user depending on which page they are on and which resources were deleted - if (isSingleTransactionView && shouldDeleteTransactionThread) { - // Pop the deleted report screen before navigating. This prevents navigating to the Concierge chat due to the missing report. - return ROUTES.REPORT_WITH_ID.getRoute(chatReport?.reportID ?? '-1'); - } + return urlToNavigateBack; } /** @@ -7328,6 +7392,8 @@ function submitReport(expenseReport: OnyxTypes.Report) { const adminAccountID = policy?.role === CONST.POLICY.ROLE.ADMIN ? currentUserPersonalDetails?.accountID : undefined; const optimisticSubmittedReportAction = ReportUtils.buildOptimisticSubmittedReportAction(expenseReport?.total ?? 0, expenseReport.currency ?? '', expenseReport.reportID, adminAccountID); const optimisticNextStep = NextStepUtils.buildNextStep(expenseReport, isSubmitAndClosePolicy ? CONST.REPORT.STATUS_NUM.CLOSED : CONST.REPORT.STATUS_NUM.SUBMITTED); + const approvalChain = ReportUtils.getApprovalChain(policy, expenseReport); + const managerID = PersonalDetailsUtils.getAccountIDsByLogins(approvalChain).at(0); const optimisticData: OnyxUpdate[] = !isSubmitAndClosePolicy ? [ @@ -7346,6 +7412,7 @@ function submitReport(expenseReport: OnyxTypes.Report) { key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, value: { ...expenseReport, + managerID, lastMessageText: ReportActionsUtils.getReportActionText(optimisticSubmittedReportAction), lastMessageHtml: ReportActionsUtils.getReportActionHtml(optimisticSubmittedReportAction), stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, @@ -8606,5 +8673,7 @@ export { updateLastLocationPermissionPrompt, resolveDuplicates, getIOUReportActionToApproveOrPay, + getNavigationUrlOnMoneyRequestDelete, + getNavigationUrlAfterTrackExpenseDelete, }; export type {GPSPoint as GpsPoint, IOURequestType}; diff --git a/src/libs/actions/OnyxUpdates.ts b/src/libs/actions/OnyxUpdates.ts index 1ba50d08e449..52dfa9dfd742 100644 --- a/src/libs/actions/OnyxUpdates.ts +++ b/src/libs/actions/OnyxUpdates.ts @@ -3,6 +3,7 @@ import Onyx from 'react-native-onyx'; import type {Merge} from 'type-fest'; import Log from '@libs/Log'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; +import Performance from '@libs/Performance'; import PusherUtils from '@libs/PusherUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -26,6 +27,7 @@ let pusherEventsPromise = Promise.resolve(); let airshipEventsPromise = Promise.resolve(); function applyHTTPSOnyxUpdates(request: Request, response: Response) { + Performance.markStart(CONST.TIMING.APPLY_HTTPS_UPDATES); console.debug('[OnyxUpdateManager] Applying https update'); // For most requests we can immediately update Onyx. For write requests we queue the updates and apply them after the sequential queue has flushed to prevent a replay effect in // the UI. See https://github.com/Expensify/App/issues/12775 for more info. @@ -61,12 +63,15 @@ function applyHTTPSOnyxUpdates(request: Request, response: Response) { return Promise.resolve(); }) .then(() => { + Performance.markEnd(CONST.TIMING.APPLY_HTTPS_UPDATES); console.debug('[OnyxUpdateManager] Done applying HTTPS update'); return Promise.resolve(response); }); } function applyPusherOnyxUpdates(updates: OnyxUpdateEvent[]) { + Performance.markStart(CONST.TIMING.APPLY_PUSHER_UPDATES); + pusherEventsPromise = pusherEventsPromise.then(() => { console.debug('[OnyxUpdateManager] Applying pusher update'); }); @@ -74,6 +79,7 @@ function applyPusherOnyxUpdates(updates: OnyxUpdateEvent[]) { pusherEventsPromise = updates .reduce((promise, update) => promise.then(() => PusherUtils.triggerMultiEventHandler(update.eventType, update.data)), pusherEventsPromise) .then(() => { + Performance.markEnd(CONST.TIMING.APPLY_PUSHER_UPDATES); console.debug('[OnyxUpdateManager] Done applying Pusher update'); }); @@ -81,6 +87,8 @@ function applyPusherOnyxUpdates(updates: OnyxUpdateEvent[]) { } function applyAirshipOnyxUpdates(updates: OnyxUpdateEvent[]) { + Performance.markStart(CONST.TIMING.APPLY_AIRSHIP_UPDATES); + airshipEventsPromise = airshipEventsPromise.then(() => { console.debug('[OnyxUpdateManager] Applying Airship updates'); }); @@ -88,6 +96,7 @@ function applyAirshipOnyxUpdates(updates: OnyxUpdateEvent[]) { airshipEventsPromise = updates .reduce((promise, update) => promise.then(() => Onyx.update(update.data)), airshipEventsPromise) .then(() => { + Performance.markEnd(CONST.TIMING.APPLY_AIRSHIP_UPDATES); console.debug('[OnyxUpdateManager] Done applying Airship updates'); }); diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index e15ff94326dd..f447911f7788 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -33,7 +33,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyCategory, RecentlyUsedCategories, Report} from '@src/types/onyx'; -import type {ApprovalRule, ExpenseRule} from '@src/types/onyx/Policy'; +import type {ApprovalRule, ExpenseRule, MccGroup} from '@src/types/onyx/Policy'; import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -549,8 +549,12 @@ function renamePolicyCategory(policyID: string, policyCategory: {oldName: string const policyCategoryExpenseRule = CategoryUtils.getCategoryExpenseRule(policy?.rules?.expenseRules ?? [], policyCategory.oldName); const approvalRules = policy?.rules?.approvalRules ?? []; const expenseRules = policy?.rules?.expenseRules ?? []; + const mccGroup = policy?.mccGroup ?? {}; const updatedApprovalRules: ApprovalRule[] = lodashCloneDeep(approvalRules); const updatedExpenseRules: ExpenseRule[] = lodashCloneDeep(expenseRules); + const clonedMccGroup: Record = lodashCloneDeep(mccGroup); + const updatedMccGroup = CategoryUtils.updateCategoryInMccGroup(clonedMccGroup, policyCategory.oldName, policyCategory.newName); + const updatedMccGroupWithClearedPendingAction = CategoryUtils.updateCategoryInMccGroup(clonedMccGroup, policyCategory.oldName, policyCategory.newName, true); if (policyCategoryExpenseRule) { const ruleIndex = updatedExpenseRules.findIndex((rule) => rule.id === policyCategoryExpenseRule.id); @@ -603,10 +607,18 @@ function renamePolicyCategory(policyID: string, policyCategory: {oldName: string approvalRules: updatedApprovalRules, expenseRules: updatedExpenseRules, }, + mccGroup: updatedMccGroup, }, }, ], successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + mccGroup: updatedMccGroupWithClearedPendingAction, + }, + }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, @@ -646,6 +658,7 @@ function renamePolicyCategory(policyID: string, policyCategory: {oldName: string rules: { approvalRules, }, + mccGroup, }, }, ], diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 229192e7a7bb..3cb3009c91dd 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -87,7 +87,6 @@ import * as ReportUtils from '@libs/ReportUtils'; import {doesReportBelongToWorkspace} from '@libs/ReportUtils'; import shouldSkipDeepLinkNavigation from '@libs/shouldSkipDeepLinkNavigation'; import {getNavatticURL} from '@libs/TourUtils'; -import {generateAccountID} from '@libs/UserUtils'; import Visibility from '@libs/Visibility'; import CONFIG from '@src/CONFIG'; import type {OnboardingAccounting, OnboardingCompanySize} from '@src/CONST'; @@ -2917,6 +2916,8 @@ function leaveGroupChat(reportID: string) { }); } + // Ensure that any remaining data is removed upon successful completion, even if the server sends a report removal response. + // This is done to prevent the removal update from lingering in the applyHTTPSOnyxUpdates function. const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -3490,27 +3491,10 @@ function prepareOnboardingOptimisticData( } } - // Guides are assigned and tasks are posted in the #admins room for the MANAGE_TEAM onboarding action, except for emails that have a '+'. - const shouldPostTasksInAdminsRoom = engagementChoice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM && !currentUserEmail?.includes('+'); const integrationName = userReportedIntegration ? CONST.ONBOARDING_ACCOUNTING_MAPPING[userReportedIntegration] : ''; - const adminsChatReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`]; - const targetChatReport = shouldPostTasksInAdminsRoom ? adminsChatReport : ReportUtils.getChatByParticipants([CONST.ACCOUNT_ID.CONCIERGE, currentUserAccountID]); + const actorAccountID = CONST.ACCOUNT_ID.CONCIERGE; + const targetChatReport = ReportUtils.getChatByParticipants([actorAccountID, currentUserAccountID]); const {reportID: targetChatReportID = '', policyID: targetChatPolicyID = ''} = targetChatReport ?? {}; - const assignedGuideEmail = getPolicy(targetChatPolicyID)?.assignedGuide?.email ?? 'Setup Specialist'; - const assignedGuidePersonalDetail = Object.values(allPersonalDetails ?? {}).find((personalDetail) => personalDetail?.login === assignedGuideEmail); - let assignedGuideAccountID: number; - if (assignedGuidePersonalDetail) { - assignedGuideAccountID = assignedGuidePersonalDetail.accountID; - } else { - assignedGuideAccountID = generateAccountID(assignedGuideEmail); - Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { - [assignedGuideAccountID]: { - login: assignedGuideEmail, - displayName: assignedGuideEmail, - }, - }); - } - const actorAccountID = shouldPostTasksInAdminsRoom ? assignedGuideAccountID : CONST.ACCOUNT_ID.CONCIERGE; // Text message const textComment = ReportUtils.buildOptimisticAddCommentReportAction(data.message, undefined, actorAccountID, 1); @@ -3572,9 +3556,7 @@ function prepareOnboardingOptimisticData( targetChatPolicyID, CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, ); - const emailCreatingAction = - engagementChoice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM ? allPersonalDetails?.[actorAccountID]?.login ?? CONST.EMAIL.CONCIERGE : CONST.EMAIL.CONCIERGE; - const taskCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(emailCreatingAction); + const taskCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(CONST.EMAIL.CONCIERGE); const taskReportAction = ReportUtils.buildOptimisticTaskCommentReportAction( currentTask.reportID, taskTitle, @@ -3754,27 +3736,21 @@ function prepareOnboardingOptimisticData( lastMentionedTime: DateUtils.getDBTime(), hasOutstandingChildTask, lastVisibleActionCreated, - lastActorAccountID: actorAccountID, }, }, { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.NVP_INTRO_SELECTED, - value: {choice: engagementChoice}, - }, - ); - - // If we post tasks in the #admins room, it means that a guide is assigned and all messages except tasks are handled by the backend - if (!shouldPostTasksInAdminsRoom) { - optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, value: { [textCommentAction.reportActionID]: textCommentAction as ReportAction, }, - }); - } - + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_INTRO_SELECTED, + value: {choice: engagementChoice}, + }, + ); if (!wasInvited) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -3784,17 +3760,13 @@ function prepareOnboardingOptimisticData( } const successData: OnyxUpdate[] = [...tasksForSuccessData]; - - // If we post tasks in the #admins room, it means that a guide is assigned and all messages except tasks are handled by the backend - if (!shouldPostTasksInAdminsRoom) { - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, - value: { - [textCommentAction.reportActionID]: {pendingAction: null}, - }, - }); - } + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [textCommentAction.reportActionID]: {pendingAction: null}, + }, + }); let failureReport: Partial = { lastMessageTranslationKey: '', @@ -3824,16 +3796,7 @@ function prepareOnboardingOptimisticData( key: `${ONYXKEYS.COLLECTION.REPORT}${targetChatReportID}`, value: failureReport, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.NVP_INTRO_SELECTED, - value: {choice: null}, - }, - ); - // If we post tasks in the #admins room, it means that a guide is assigned and all messages except tasks are handled by the backend - if (!shouldPostTasksInAdminsRoom) { - failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, value: { @@ -3841,8 +3804,13 @@ function prepareOnboardingOptimisticData( errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericAddCommentFailureMessage'), } as ReportAction, }, - }); - } + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_INTRO_SELECTED, + value: {choice: null}, + }, + ); if (!wasInvited) { failureData.push({ @@ -3884,10 +3852,9 @@ function prepareOnboardingOptimisticData( }); } - // If we post tasks in the #admins room, it means that a guide is assigned and all messages except tasks are handled by the backend - const guidedSetupData: GuidedSetupData = shouldPostTasksInAdminsRoom ? [] : [{type: 'message', ...textMessage}]; + const guidedSetupData: GuidedSetupData = [{type: 'message', ...textMessage}]; - if (!shouldPostTasksInAdminsRoom && 'video' in data && data.video && videoCommentAction && videoMessage) { + if ('video' in data && data.video && videoCommentAction && videoMessage) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, @@ -4401,6 +4368,14 @@ function exportReportToCSV({reportID, transactionIDList}: ExportReportCSVParams, fileDownload(ApiUtils.getCommandURL({command: WRITE_COMMANDS.EXPORT_REPORT_TO_CSV}), 'Expensify.csv', '', false, formData, CONST.NETWORK.METHOD.POST, onDownloadFailed); } +function setDeleteTransactionNavigateBackUrl(url: string) { + Onyx.set(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL, url); +} + +function clearDeleteTransactionNavigateBackUrl() { + Onyx.merge(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL, null); +} + export type {Video}; export { @@ -4490,4 +4465,6 @@ export { updateReportName, updateRoomVisibility, updateWriteCapability, + setDeleteTransactionNavigateBackUrl, + clearDeleteTransactionNavigateBackUrl, }; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index bf481cffcf73..bb64fe10db26 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -6,12 +6,13 @@ import type {PaymentData, SearchQueryJSON} from '@components/Search/types'; import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import * as API from '@libs/API'; import type {ExportSearchItemsToCSVParams} from '@libs/API/parameters'; -import {WRITE_COMMANDS} from '@libs/API/types'; +import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ApiUtils from '@libs/ApiUtils'; import fileDownload from '@libs/fileDownload'; import enhanceParameters from '@libs/Network/enhanceParameters'; import * as ReportUtils from '@libs/ReportUtils'; import {isReportListItemType, isTransactionListItemType} from '@libs/SearchUIUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; @@ -280,7 +281,15 @@ function payMoneyRequestOnSearch(hash: number, paymentData: PaymentData[], trans const optimisticData: OnyxUpdate[] = createActionLoadingData(true); const finallyData: OnyxUpdate[] = createActionLoadingData(false); - API.write(WRITE_COMMANDS.PAY_MONEY_REQUEST_ON_SEARCH, {hash, paymentData: JSON.stringify(paymentData)}, {optimisticData, finallyData}); + // eslint-disable-next-line rulesdir/no-api-side-effects-method + API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.PAY_MONEY_REQUEST_ON_SEARCH, {hash, paymentData: JSON.stringify(paymentData)}, {optimisticData, finallyData}).then( + (response) => { + if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { + return; + } + playSound(SOUNDS.SUCCESS); + }, + ); } function unholdMoneyRequestOnSearch(hash: number, transactionIDList: string[]) { diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 15fd39bd66e9..5668d91b4999 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -961,6 +961,36 @@ function getParentReport(report: OnyxEntry): OnyxEntry): string | undefined { + if (!report) { + return undefined; + } + + const shouldDeleteTaskReport = !ReportActionsUtils.doesReportHaveVisibleActions(report.reportID ?? '-1'); + if (!shouldDeleteTaskReport) { + return undefined; + } + + // First try to navigate to parent report + const parentReport = getParentReport(report); + if (parentReport?.reportID) { + return ROUTES.REPORT_WITH_ID.getRoute(parentReport.reportID); + } + + // If no parent report, try to navigate to most recent report + const mostRecentReportID = Report.getMostRecentReportID(report); + if (mostRecentReportID) { + return ROUTES.REPORT_WITH_ID.getRoute(mostRecentReportID); + } + + return undefined; +} + /** * Cancels a task by setting the report state to SUBMITTED and status to CLOSED */ @@ -1117,15 +1147,10 @@ function deleteTask(report: OnyxEntry) { API.write(WRITE_COMMANDS.CANCEL_TASK, parameters, {optimisticData, successData, failureData}); Report.notifyNewAction(report.reportID, currentUserAccountID); - if (shouldDeleteTaskReport) { + const urlToNavigateBack = getNavigationUrlOnTaskDelete(report); + if (urlToNavigateBack) { Navigation.goBack(); - if (parentReport?.reportID) { - return ROUTES.REPORT_WITH_ID.getRoute(parentReport.reportID); - } - const mostRecentReportID = Report.getMostRecentReportID(report); - if (mostRecentReportID) { - return ROUTES.REPORT_WITH_ID.getRoute(mostRecentReportID); - } + return urlToNavigateBack; } } @@ -1239,6 +1264,7 @@ export { canModifyTask, canActionTask, setNewOptimisticAssignee, + getNavigationUrlOnTaskDelete, }; export type {PolicyValue, Assignee, ShareDestination}; diff --git a/src/libs/focusComposerWithDelay/index.ts b/src/libs/focusComposerWithDelay/index.ts index cbd81b884d12..eef9d21d113d 100644 --- a/src/libs/focusComposerWithDelay/index.ts +++ b/src/libs/focusComposerWithDelay/index.ts @@ -31,7 +31,8 @@ function focusComposerWithDelay(textInput: InputType | null): FocusComposerWithD if (!textInput) { return; } - textInput.focus(); + // When the closing modal has a focused text input focus() needs a delay to properly work. + setTimeout(() => textInput.focus(), 0); if (forcedSelectionRange) { setTextInputSelection(textInput, forcedSelectionRange); } diff --git a/src/pages/Debug/DebugDetailsDateTimePickerPage.tsx b/src/pages/Debug/DebugDetailsDateTimePickerPage.tsx index b2be1429ee5b..c1bda9d4da8b 100644 --- a/src/pages/Debug/DebugDetailsDateTimePickerPage.tsx +++ b/src/pages/Debug/DebugDetailsDateTimePickerPage.tsx @@ -26,7 +26,7 @@ function DebugDetailsDateTimePickerPage({ }: DebugDetailsDateTimePickerPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const [date, setDate] = useState(DateUtils.extractDate(fieldValue)); + const [date, setDate] = useState(() => DateUtils.extractDate(fieldValue)); return ( diff --git a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx index fb6fa1b6b13e..2066ab71c639 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx @@ -48,7 +48,7 @@ function DebugReportActionCreatePage({ const styles = useThemeStyles(); const [session] = useOnyx(ONYXKEYS.SESSION); const [personalDetailsList] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const [draftReportAction, setDraftReportAction] = useState(getInitialReportAction(reportID, session, personalDetailsList)); + const [draftReportAction, setDraftReportAction] = useState(() => getInitialReportAction(reportID, session, personalDetailsList)); const [error, setError] = useState(); return ( diff --git a/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx b/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx index 4a036478f93e..b5f3d0d603d5 100644 --- a/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx +++ b/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx @@ -63,7 +63,7 @@ function DebugTransactionViolationCreatePage({ const {translate} = useLocalize(); const styles = useThemeStyles(); const [transactionViolations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); - const [draftTransactionViolation, setDraftTransactionViolation] = useState(getInitialTransactionViolation()); + const [draftTransactionViolation, setDraftTransactionViolation] = useState(() => getInitialTransactionViolation()); const [error, setError] = useState(); const editJSON = useCallback( diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx index 429d92440dd7..d05f0fa5c64a 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx @@ -203,7 +203,7 @@ function ReimbursementAccountPage({route, policy}: ReimbursementAccountPageProps which acts similarly to `componentDidUpdate` when the `reimbursementAccount` dependency changes. */ const [hasACHDataBeenLoaded, setHasACHDataBeenLoaded] = useState(reimbursementAccount !== CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA && isPreviousPolicy); - const [shouldShowContinueSetupButton, setShouldShowContinueSetupButton] = useState(getShouldShowContinueSetupButtonInitialValue()); + const [shouldShowContinueSetupButton, setShouldShowContinueSetupButton] = useState(() => getShouldShowContinueSetupButtonInitialValue()); const handleNextNonUSDBankAccountStep = () => { switch (nonUSDBankAccountStep) { diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index 3547da8bcc97..961d43bda012 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -795,16 +795,9 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta ); - // A flag to indicate whether the user choose to delete the transaction or not - const isTransactionDeleted = useRef(false); - // Where to go back after deleting the transaction and its report. It's empty if the transaction report isn't deleted. - const navigateBackToAfterDelete = useRef(); - const deleteTransaction = useCallback(() => { - setIsDeleteModalVisible(false); - if (caseID === CASES.DEFAULT) { - navigateBackToAfterDelete.current = Task.deleteTask(report); + Task.deleteTask(report); return; } @@ -812,14 +805,63 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta return; } - if (ReportActionsUtils.isTrackExpenseAction(requestParentReportAction)) { - navigateBackToAfterDelete.current = IOU.deleteTrackExpense(moneyRequestReport?.reportID ?? '', iouTransactionID, requestParentReportAction, isSingleTransactionView); + const isTrackExpense = ReportActionsUtils.isTrackExpenseAction(requestParentReportAction); + + if (isTrackExpense) { + IOU.deleteTrackExpense(moneyRequestReport?.reportID ?? '', iouTransactionID, requestParentReportAction, isSingleTransactionView); } else { - navigateBackToAfterDelete.current = IOU.deleteMoneyRequest(iouTransactionID, requestParentReportAction, isSingleTransactionView); + IOU.deleteMoneyRequest(iouTransactionID, requestParentReportAction, isSingleTransactionView); } + }, [caseID, iouTransactionID, isSingleTransactionView, moneyRequestReport?.reportID, report, requestParentReportAction]); - isTransactionDeleted.current = true; - }, [caseID, iouTransactionID, moneyRequestReport?.reportID, report, requestParentReportAction, isSingleTransactionView]); + // A flag to indicate whether the user chose to delete the transaction or not + const isTransactionDeleted = useRef(false); + + useEffect(() => { + return () => { + // Perform the actual deletion after the details page is unmounted. This prevents the [Deleted ...] text from briefly appearing when dismissing the modal. + if (!isTransactionDeleted.current) { + return; + } + + deleteTransaction(); + }; + }, [deleteTransaction]); + + // Where to navigate back to after deleting the transaction and its report. + const navigateToTargetUrl = useCallback(() => { + let urlToNavigateBack: string | undefined; + + if (!isTransactionDeleted.current) { + if (caseID === CASES.DEFAULT) { + urlToNavigateBack = Task.getNavigationUrlOnTaskDelete(report); + if (urlToNavigateBack) { + Report.setDeleteTransactionNavigateBackUrl(urlToNavigateBack); + Navigation.goBack(urlToNavigateBack as Route); + } else { + Navigation.dismissModal(); + } + return; + } + return; + } + + if (!isEmptyObject(requestParentReportAction)) { + const isTrackExpense = ReportActionsUtils.isTrackExpenseAction(requestParentReportAction); + if (isTrackExpense) { + urlToNavigateBack = IOU.getNavigationUrlAfterTrackExpenseDelete(moneyRequestReport?.reportID ?? '', iouTransactionID, requestParentReportAction, isSingleTransactionView); + } else { + urlToNavigateBack = IOU.getNavigationUrlOnMoneyRequestDelete(iouTransactionID, requestParentReportAction, isSingleTransactionView); + } + } + + if (!urlToNavigateBack) { + Navigation.dismissModal(); + } else { + Report.setDeleteTransactionNavigateBackUrl(urlToNavigateBack); + ReportUtils.navigateBackOnDeleteTransaction(urlToNavigateBack as Route, true); + } + }, [caseID, iouTransactionID, moneyRequestReport?.reportID, report, requestParentReportAction, isSingleTransactionView, isTransactionDeleted]); const mentionReportContextValue = useMemo(() => ({currentReportID: report.reportID, exactlyMatch: true}), [report.reportID]); @@ -912,32 +954,17 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta setIsDeleteModalVisible(false)} - onModalHide={() => { - // We use isTransactionDeleted to know if the modal hides because the user deletes the transaction. - if (!isTransactionDeleted.current) { - if (caseID === CASES.DEFAULT) { - if (navigateBackToAfterDelete.current) { - Navigation.goBack(navigateBackToAfterDelete.current); - } else { - Navigation.dismissModal(); - } - } - return; - } - - if (!navigateBackToAfterDelete.current) { - Navigation.dismissModal(); - } else { - ReportUtils.navigateBackAfterDeleteTransaction(navigateBackToAfterDelete.current, true); - } + onConfirm={() => { + setIsDeleteModalVisible(false); + isTransactionDeleted.current = true; }} + onCancel={() => setIsDeleteModalVisible(false)} prompt={caseID === CASES.DEFAULT ? translate('task.deleteConfirmation') : translate('iou.deleteConfirmation', {count: 1})} confirmText={translate('common.delete')} cancelText={translate('common.cancel')} danger shouldEnableNewFocusManagement + onModalHide={navigateToTargetUrl} /> (null); @@ -211,9 +211,9 @@ function SearchTypeMenuNarrow({typeMenuItems, activeItemIndex, queryJSON, title, onClose={closeMenu} onItemSelected={closeMenu} anchorRef={buttonRef} - innerContainerStyle={{paddingBottom: bottom}} - scrollContainerStyle={styles.pv4} shouldUseScrollView + shouldUseModalPaddingStyle={false} + innerContainerStyle={{paddingBottom: unmodifiedPaddings.bottom}} /> diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 561323aa069f..026e07a786ce 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -361,14 +361,30 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro () => !!linkedAction && ReportActionsUtils.isWhisperAction(linkedAction) && !(linkedAction?.whisperedToAccountIDs ?? []).includes(currentUserAccountID), [currentUserAccountID, linkedAction], ); + const [deleteTransactionNavigateBackUrl] = useOnyx(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL); + + useEffect(() => { + if (!isFocused || !deleteTransactionNavigateBackUrl) { + return; + } + // Clear the URL after all interactions are processed to ensure all updates are completed before hiding the skeleton + InteractionManager.runAfterInteractions(() => { + requestAnimationFrame(() => { + Report.clearDeleteTransactionNavigateBackUrl(); + }); + }); + }, [isFocused, deleteTransactionNavigateBackUrl]); const isLoading = isLoadingApp ?? (!reportIDFromRoute || (!isSidebarLoaded && !isInNarrowPaneModal) || PersonalDetailsUtils.isPersonalDetailsEmpty()); + const shouldShowSkeleton = (isLinkingToMessage && !isLinkedMessagePageReady) || (!isLinkingToMessage && !isInitialPageReady) || isEmptyObject(reportOnyx) || isLoadingReportOnyx || !isCurrentReportLoadedFromOnyx || + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + (deleteTransactionNavigateBackUrl && ReportUtils.getReportIDFromLink(deleteTransactionNavigateBackUrl) === report?.reportID) || isLoading; const isLinkedActionBecomesDeleted = prevIsLinkedActionDeleted !== undefined && !prevIsLinkedActionDeleted && isLinkedActionDeleted; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 59d1b4c00683..7634a5317eac 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -462,6 +462,12 @@ function ReportActionCompose({ raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLikelyLayoutTriggered} onAddActionPressed={onAddActionPressed} onItemSelected={onItemSelected} + onCanceledAttachmentPicker={() => { + if (!shouldFocusInputOnScreenFocus) { + return; + } + focus(); + }} actionButtonRef={actionButtonRef} shouldDisableAttachmentItem={hasExceededMaxCommentLength} /> diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index ff6f635e3c6f..688d0d675cd6 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -162,7 +162,7 @@ function ReportActionsList({ const reportScrollManager = useReportScrollManager(); const userActiveSince = useRef(DateUtils.getDBTime()); const lastMessageTime = useRef(null); - const [isVisible, setIsVisible] = useState(Visibility.isVisible()); + const [isVisible, setIsVisible] = useState(Visibility.isVisible); const isFocused = useIsFocused(); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`); diff --git a/src/pages/iou/request/step/IOURequestStepAttendees.tsx b/src/pages/iou/request/step/IOURequestStepAttendees.tsx index f1d253db0c18..409ef1cfe02d 100644 --- a/src/pages/iou/request/step/IOURequestStepAttendees.tsx +++ b/src/pages/iou/request/step/IOURequestStepAttendees.tsx @@ -40,7 +40,7 @@ function IOURequestStepAttendees({ }: IOURequestStepAttendeesProps) { const isEditing = action === CONST.IOU.ACTION.EDIT; const [transaction] = useOnyx(`${isEditing ? ONYXKEYS.COLLECTION.TRANSACTION : ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID || -1}`); - const [attendees, setAttendees] = useState(TransactionUtils.getAttendees(transaction)); + const [attendees, setAttendees] = useState(() => TransactionUtils.getAttendees(transaction)); const previousAttendees = usePrevious(attendees); const {translate} = useLocalize(); const [violations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); diff --git a/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/WebCamera.tsx b/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/WebCamera.tsx index db8c1656b3f8..fd20bb7aae5d 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/WebCamera.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/WebCamera.tsx @@ -1,23 +1,29 @@ import {useIsFocused} from '@react-navigation/native'; -import React from 'react'; +import React, {useState} from 'react'; import type {ForwardedRef} from 'react'; import {View} from 'react-native'; import type {Camera} from 'react-native-vision-camera'; import Webcam from 'react-webcam'; +import useThemeStyles from '@hooks/useThemeStyles'; import type {NavigationAwareCameraProps} from './types'; // Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused. -function WebCamera({torchOn, onTorchAvailability, cameraTabIndex, ...props}: NavigationAwareCameraProps, ref: ForwardedRef) { +function WebCamera(props: NavigationAwareCameraProps, ref: ForwardedRef) { + const [isInitialized, setIsInitialized] = useState(false); const shouldShowCamera = useIsFocused(); + const styles = useThemeStyles(); if (!shouldShowCamera) { return null; } + return ( - + // Hide the camera during initialization to prevent random failures on some iOS versions. + setIsInitialized(true)} ref={ref as unknown as ForwardedRef} /> diff --git a/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/types.ts b/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/types.ts index 555cb7a92367..0869ecf34199 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/types.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/types.ts @@ -1,16 +1,7 @@ import type {CameraProps} from 'react-native-vision-camera'; import type {WebcamProps} from 'react-webcam'; -type NavigationAwareCameraProps = WebcamProps & { - /** Flag to turn on/off the torch/flashlight - if available */ - torchOn?: boolean; - - /** The index of the tab that contains this camera */ - onTorchAvailability?: (torchAvailable: boolean) => void; - - /** Callback function when media stream becomes available - user granted camera permissions and camera starts to work */ - cameraTabIndex: number; -}; +type NavigationAwareCameraProps = WebcamProps; type NavigationAwareCameraNativeProps = Omit & { cameraTabIndex: number; diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 2304fe4d749e..8708d1d9ce54 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -93,7 +93,6 @@ function IOURequestStepScan({ const [isLoadingReceipt, setIsLoadingReceipt] = useState(false); const [videoConstraints, setVideoConstraints] = useState(); - const tabIndex = 1; const isTabActive = useIsFocused(); const isEditing = action === CONST.IOU.ACTION.EDIT; @@ -118,7 +117,7 @@ function IOURequestStepScan({ * The last deviceId is of regular len camera. */ const requestCameraPermission = useCallback(() => { - if (!isEmptyObject(videoConstraints) || !Browser.isMobile()) { + if (!Browser.isMobile()) { return; } @@ -129,7 +128,7 @@ function IOURequestStepScan({ setCameraPermissionState('granted'); stream.getTracks().forEach((track) => track.stop()); // Only Safari 17+ supports zoom constraint - if (Browser.isMobileSafari() && stream.getTracks().length > 0) { + if (Browser.isMobileWebKit() && stream.getTracks().length > 0) { let deviceId; for (const track of stream.getTracks()) { const setting = track.getSettings(); @@ -167,11 +166,11 @@ function IOURequestStepScan({ setVideoConstraints(defaultConstraints); setCameraPermissionState('denied'); }); - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); useEffect(() => { if (!Browser.isMobile() || !isTabActive) { + setVideoConstraints(undefined); return; } navigator.permissions @@ -665,7 +664,6 @@ function IOURequestStepScan({ screenshotFormat="image/png" videoConstraints={videoConstraints} forceScreenshotSourceSize - cameraTabIndex={tabIndex} audio={false} disablePictureInPicture={false} imageSmoothing={false} diff --git a/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx index db36e60de4d0..55c0100a84db 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx @@ -1,7 +1,6 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import FormProvider from '@components/Form/FormProvider'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -19,7 +18,6 @@ import * as ValidationUtils from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type * as OnyxTypes from '@src/types/onyx'; type CustomStatusTypes = ValueOf; @@ -30,13 +28,6 @@ type StatusType = { isSelected: boolean; }; -type StatusClearAfterPageOnyxProps = { - /** User's custom status */ - customStatus: OnyxEntry; -}; - -type StatusClearAfterPageProps = StatusClearAfterPageOnyxProps; - /** * @param data - either a value from CONST.CUSTOM_STATUS_TYPES or a dateTime string in the format YYYY-MM-DD HH:mm */ @@ -80,14 +71,15 @@ const useValidateCustomDate = (data: string) => { return {customDateError, customTimeError, validateCustomDate}; }; -function StatusClearAfterPage({customStatus}: StatusClearAfterPageProps) { +function StatusClearAfterPage() { const styles = useThemeStyles(); const {translate} = useLocalize(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const clearAfter = currentUserPersonalDetails.status?.clearAfter ?? ''; + const [customStatus] = useOnyx(ONYXKEYS.CUSTOM_STATUS_DRAFT); const draftClearAfter = customStatus?.clearAfter ?? ''; - const [draftPeriod, setDraftPeriod] = useState(getSelectedStatusType(draftClearAfter || clearAfter)); + const [draftPeriod, setDraftPeriod] = useState(() => getSelectedStatusType(draftClearAfter || clearAfter)); const statusType = useMemo( () => Object.entries(CONST.CUSTOM_STATUS_TYPES).map(([key, value]) => ({ @@ -222,8 +214,4 @@ function StatusClearAfterPage({customStatus}: StatusClearAfterPageProps) { StatusClearAfterPage.displayName = 'StatusClearAfterPage'; -export default withOnyx({ - customStatus: { - key: ONYXKEYS.CUSTOM_STATUS_DRAFT, - }, -})(StatusClearAfterPage); +export default StatusClearAfterPage; diff --git a/src/pages/settings/Security/SecuritySettingsPage.tsx b/src/pages/settings/Security/SecuritySettingsPage.tsx index ac66c368f631..a93a9f154276 100644 --- a/src/pages/settings/Security/SecuritySettingsPage.tsx +++ b/src/pages/settings/Security/SecuritySettingsPage.tsx @@ -14,7 +14,8 @@ import MenuItem from '@components/MenuItem'; import type {MenuItemProps} from '@components/MenuItem'; import MenuItemList from '@components/MenuItemList'; import {usePersonalDetails} from '@components/OnyxProvider'; -import Popover from '@components/Popover'; +import PopoverMenu from '@components/PopoverMenu'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; @@ -31,7 +32,7 @@ import getClickedTargetLocation from '@libs/getClickedTargetLocation'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Navigation from '@libs/Navigation/Navigation'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; -import variables from '@styles/variables'; +import type {AnchorPosition} from '@styles/index'; import * as Modal from '@userActions/Modal'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -54,43 +55,41 @@ function SecuritySettingsPage() { const [shouldShowDelegatePopoverMenu, setShouldShowDelegatePopoverMenu] = useState(false); const [shouldShowRemoveDelegateModal, setShouldShowRemoveDelegateModal] = useState(false); const [selectedDelegate, setSelectedDelegate] = useState(); + const [selectedEmail, setSelectedEmail] = useState(); + const errorFields = account?.delegatedAccess?.errorFields ?? {}; - const [anchorPosition, setAnchorPosition] = useState({ - anchorPositionHorizontal: 0, - anchorPositionVertical: 0, - anchorPositionTop: 0, - anchorPositionRight: 0, + const [anchorPosition, setAnchorPosition] = useState({ + horizontal: 0, + vertical: 0, }); + const isActingAsDelegate = !!account?.delegatedAccess?.delegate || false; + + const delegates = account?.delegatedAccess?.delegates ?? []; + const delegators = account?.delegatedAccess?.delegators ?? []; + + const hasDelegates = delegates.length > 0; + const hasDelegators = delegators.length > 0; + const setMenuPosition = useCallback(() => { if (!delegateButtonRef.current) { return; } const position = getClickedTargetLocation(delegateButtonRef.current); - setAnchorPosition({ - anchorPositionTop: position.top + position.height - variables.bankAccountActionPopoverTopSpacing, - // We want the position to be 23px to the right of the left border - anchorPositionRight: windowWidth - position.right + variables.bankAccountActionPopoverRightSpacing, - anchorPositionHorizontal: position.x + variables.addBankAccountLeftSpacing, - anchorPositionVertical: position.y, + horizontal: position.right - position.left, + vertical: position.y + position.height, }); - }, [windowWidth]); - const isActingAsDelegate = !!account?.delegatedAccess?.delegate || false; - - const delegates = account?.delegatedAccess?.delegates ?? []; - const delegators = account?.delegatedAccess?.delegators ?? []; - - const hasDelegates = delegates.length > 0; - const hasDelegators = delegators.length > 0; + }, [delegateButtonRef]); const showPopoverMenu = (nativeEvent: GestureResponderEvent | KeyboardEvent, delegate: Delegate) => { delegateButtonRef.current = nativeEvent?.currentTarget as HTMLDivElement; setMenuPosition(); setShouldShowDelegatePopoverMenu(true); setSelectedDelegate(delegate); + setSelectedEmail(delegate.email); }; useLayoutEffect(() => { @@ -174,10 +173,11 @@ function SecuritySettingsPage() { onPendingActionDismiss: () => clearDelegateErrorsByField(email, 'addDelegate'), error, onPress, + success: selectedEmail === email, }; }), // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - [delegates, translate, styles, personalDetails, errorFields], + [delegates, translate, styles, personalDetails, errorFields, windowWidth, selectedEmail], ); const delegatorMenuItems: MenuItemProps[] = useMemo( @@ -202,6 +202,29 @@ function SecuritySettingsPage() { [delegators, styles, translate, personalDetails], ); + const delegatePopoverMenuItems: PopoverMenuItem[] = [ + { + text: translate('delegate.changeAccessLevel'), + icon: Expensicons.Pencil, + onPress: () => { + Navigation.navigate(ROUTES.SETTINGS_UPDATE_DELEGATE_ROLE.getRoute(selectedDelegate?.email ?? '', selectedDelegate?.role ?? '')); + setShouldShowDelegatePopoverMenu(false); + setSelectedDelegate(undefined); + setSelectedEmail(undefined); + }, + }, + { + text: translate('delegate.removeCopilot'), + icon: Expensicons.Trashcan, + onPress: () => + Modal.close(() => { + setShouldShowDelegatePopoverMenu(false); + setShouldShowRemoveDelegateModal(true); + setSelectedEmail(undefined); + }), + }, + ]; + return ( - } anchorPosition={{ - top: anchorPosition.anchorPositionTop, - right: anchorPosition.anchorPositionRight, + horizontal: anchorPosition.horizontal, + vertical: anchorPosition.vertical, + }} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, }} + menuItems={delegatePopoverMenuItems} onClose={() => { setShouldShowDelegatePopoverMenu(false); + setSelectedEmail(undefined); }} - > - - { - Navigation.navigate(ROUTES.SETTINGS_UPDATE_DELEGATE_ROLE.getRoute(selectedDelegate?.email ?? '', selectedDelegate?.role ?? '')); - setShouldShowDelegatePopoverMenu(false); - setSelectedDelegate(undefined); - }} - wrapperStyle={[styles.pv3, styles.ph5, !shouldUseNarrowLayout ? styles.sidebarPopover : {}]} - /> - - Modal.close(() => { - setShouldShowDelegatePopoverMenu(false); - setShouldShowRemoveDelegateModal(true); - }) - } - wrapperStyle={[styles.pv3, styles.ph5, !shouldUseNarrowLayout ? styles.sidebarPopover : {}]} - /> - - + /> (CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData ?? {})); + const [billingStatus, setBillingStatus] = useState(() => CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData ?? {})); const nextPaymentDate = !isEmptyObject(privateSubscription) ? CardSectionUtils.getNextBillingDate() : undefined; diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage.tsx index 0f4968a43cfc..723242c55494 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage.tsx @@ -46,7 +46,7 @@ function WorkspaceCompanyCardFeedSelectorPage({route}: WorkspaceCompanyCardFeedS const feeds: CardFeedListItem[] = (Object.keys(availableCards) as CompanyCardFeed[]).map((feed) => ({ value: feed, - text: cardFeeds?.settings?.companyCardNicknames?.[feed] ?? CardUtils.getCardFeedName(feed), + text: CardUtils.getCustomOrFormattedFeedName(feed, cardFeeds?.settings?.companyCardNicknames), keyForList: feed, isSelected: feed === selectedFeed, brickRoadIndicator: companyFeeds[feed]?.errors ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx index fb4067ab9c8d..0f8893c5bce2 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx @@ -42,8 +42,7 @@ function WorkspaceCompanyCardsListHeaderButtons({policyID, selectedFeed, shouldS const workspaceAccountID = PolicyUtils.getWorkspaceAccountID(policyID); const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`); const shouldChangeLayout = isMediumScreenWidth || shouldUseNarrowLayout; - const feedName = CardUtils.getCardFeedName(selectedFeed); - const formattedFeedName = translate('workspace.companyCards.feedName', {feedName}); + const formattedFeedName = CardUtils.getCustomOrFormattedFeedName(selectedFeed, cardFeeds?.settings?.companyCardNicknames); const isCustomFeed = CardUtils.isCustomFeed(selectedFeed); const companyFeeds = CardUtils.getCompanyFeeds(cardFeeds); const currentFeedData = companyFeeds?.[selectedFeed]; @@ -58,7 +57,7 @@ function WorkspaceCompanyCardsListHeaderButtons({policyID, selectedFeed, shouldS Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_SELECT_FEED.getRoute(policyID))} style={[styles.flexRow, styles.alignItemsCenter, styles.gap3, shouldChangeLayout && styles.mb3]} - accessibilityLabel={formattedFeedName} + accessibilityLabel={formattedFeedName ?? ''} > !PolicyUtils.isDeletedPolicyEmployee(employee, isOffline)); - if (Object.keys(policy?.employeeList ?? {}).length === 1) { + if (employeeList.length === 1) { const userEmail = Object.keys(policy?.employeeList ?? {}).at(0) ?? ''; data.email = userEmail; const personalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(userEmail); diff --git a/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx b/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx index 9591f792ea59..2c6745fabe14 100644 --- a/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx @@ -99,7 +99,7 @@ function SelectBankStep() { )} diff --git a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx index fc0feb07848f..0b655548548f 100644 --- a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx @@ -27,7 +27,7 @@ function TransactionStartDateStep() { const [dateOptionSelected, setDateOptionSelected] = useState(data?.dateOption ?? CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING); const [isModalOpened, setIsModalOpened] = useState(false); - const [startDate, setStartDate] = useState(format(new Date(), CONST.DATE.FNS_FORMAT_STRING)); + const [startDate, setStartDate] = useState(() => format(new Date(), CONST.DATE.FNS_FORMAT_STRING)); const handleBackButtonPress = () => { if (isEditing) { diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx index d5919cadbda1..6e512160bd7f 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx @@ -121,7 +121,7 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true featureName={CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED} > , 'addWorkspaceRoom' | keyof ACHAccount | keyof Attributes >; @@ -1929,4 +1923,5 @@ export type { ApprovalRule, ExpenseRule, NetSuiteConnectionConfig, + MccGroup, }; diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 4430ec0ce052..e8cd65123656 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -2313,7 +2313,7 @@ describe('actions/IOU', () => { }); }); - expect(report).toBeFalsy(); + expect(report?.reportID).toBeFalsy(); mockFetch?.resume?.(); // Then After resuming fetch, the report for the given thread ID still does not exist @@ -2328,7 +2328,7 @@ describe('actions/IOU', () => { }); }); - expect(report).toBeFalsy(); + expect(report?.reportID).toBeFalsy(); }); it('delete the transaction thread if there are only changelogs (i.e. MODIFIED_EXPENSE actions) in the thread', async () => { @@ -2435,7 +2435,7 @@ describe('actions/IOU', () => { }); }); - expect(report).toBeFalsy(); + expect(report?.reportID).toBeFalsy(); }); it('does not delete the transaction thread if there are visible comments in the thread', async () => { diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts new file mode 100644 index 000000000000..f847e44c3315 --- /dev/null +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// we need "dirty" object key names in these tests +import {getQueryWithUpdatedValues} from '@src/libs/SearchQueryUtils'; + +const personalDetailsFakeData = { + 'johndoe@example.com': { + accountID: 12345, + }, + 'janedoe@example.com': { + accountID: 78901, + }, +} as Record; + +jest.mock('@libs/PersonalDetailsUtils', () => { + return { + getPersonalDetailByEmail(email: string) { + return personalDetailsFakeData[email]; + }, + }; +}); + +// The default query is generated by default values from parser, which are defined in grammar. +// We don't want to test or mock the grammar and the parser, so we're simply defining this string directly here. +const defaultQuery = `type:expense status:all sortBy:date sortOrder:desc`; + +describe('getQueryWithUpdatedValues', () => { + test('returns default query for empty value', () => { + const userQuery = ''; + + const result = getQueryWithUpdatedValues(userQuery); + + expect(result).toEqual(defaultQuery); + }); + + test('returns query with updated amounts', () => { + const userQuery = 'foo test amount:20000'; + + const result = getQueryWithUpdatedValues(userQuery); + + expect(result).toEqual(`${defaultQuery} amount:2000000 foo test`); + }); + + test('returns query with user emails substituted', () => { + const userQuery = 'from:johndoe@example.com hello'; + + const result = getQueryWithUpdatedValues(userQuery); + + expect(result).toEqual(`${defaultQuery} from:12345 hello`); + }); + + test('returns query with user emails substituted and preserves user ids', () => { + const userQuery = 'from:johndoe@example.com to:112233'; + + const result = getQueryWithUpdatedValues(userQuery); + + expect(result).toEqual(`${defaultQuery} from:12345 to:112233`); + }); + + test('returns query with all of the fields correctly substituted', () => { + const userQuery = 'from:9876,87654 to:janedoe@example.com hello amount:150 test'; + + const result = getQueryWithUpdatedValues(userQuery); + + expect(result).toEqual(`${defaultQuery} from:9876,87654 to:78901 amount:15000 hello test`); + }); +});