diff --git a/.eslintignore b/.eslintignore
index aa8b769dfede..3d966d096add 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -10,3 +10,4 @@ docs/vendor/**
docs/assets/**
web/gtm.js
**/.expo/**
+src/libs/SearchParser/searchParser.js
diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml
index ffce73644263..0d5879217ea0 100644
--- a/.github/workflows/e2ePerformanceTests.yml
+++ b/.github/workflows/e2ePerformanceTests.yml
@@ -257,12 +257,12 @@ jobs:
- name: Check if test failed, if so post the results and add the DeployBlocker label
id: checkIfRegressionDetected
run: |
- if grep -q 'đź”´' ./output.md; then
+ if grep -q 'đź”´' "./Host_Machine_Files/\$WORKING_DIRECTORY/output.md"; then
# Create an output to the GH action that the test failed:
echo "performanceRegressionDetected=true" >> "$GITHUB_OUTPUT"
gh pr edit ${{ inputs.PR_NUMBER }} --add-label DeployBlockerCash
- gh pr comment ${{ inputs.PR_NUMBER }} -F ./output.md
+ gh pr comment ${{ inputs.PR_NUMBER }} -F "./Host_Machine_Files/\$WORKING_DIRECTORY/output.md"
gh pr comment ${{ inputs.PR_NUMBER }} -b "@Expensify/mobile-deployers đź“Ł Please look into this performance regression as it's a deploy blocker."
else
echo "performanceRegressionDetected=false" >> "$GITHUB_OUTPUT"
diff --git a/.github/workflows/sendReassurePerfData.yml b/.github/workflows/sendReassurePerfData.yml
index 53b3d3374a9e..30a30918f4f6 100644
--- a/.github/workflows/sendReassurePerfData.yml
+++ b/.github/workflows/sendReassurePerfData.yml
@@ -25,7 +25,7 @@ jobs:
shell: bash
run: |
set -e
- npx reassure --baseline
+ NODE_OPTIONS=--experimental-vm-modules npx reassure --baseline
- name: Get merged pull request
id: getMergedPullRequest
diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml
index 476b01f87b07..3bfc0ed28d1a 100644
--- a/.github/workflows/typecheck.yml
+++ b/.github/workflows/typecheck.yml
@@ -34,7 +34,7 @@ jobs:
# - git diff is used to see the files that were added on this branch
# - gh pr view is used to list files touched by this PR. Git diff may give false positives if the branch isn't up-to-date with main
# - wc counts the words in the result of the intersection
- count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'desktop/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' 'workflow_tests/*.js' '.github/libs/*.js' '.github/scripts/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l)
+ count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'desktop/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' 'workflow_tests/*.js' '.github/libs/*.js' '.github/scripts/*.js' ':!src/libs/SearchParser/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l)
if [ "$count_new_js" -gt "0" ]; then
echo "ERROR: Found new JavaScript files in the project; use TypeScript instead."
exit 1
diff --git a/.prettierignore b/.prettierignore
index 09de20ba30b0..a9f7e1464529 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -19,3 +19,6 @@ package-lock.json
src/libs/E2E/reactNativeLaunchingTest.ts
# Temporary while we keep react-compiler in our repo
lib/**
+
+# Automatically generated files
+src/libs/SearchParser/searchParser.js
diff --git a/android/app/build.gradle b/android/app/build.gradle
index d4cc7471a72b..f722d8426b7e 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -107,8 +107,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1009000600
- versionName "9.0.6-0"
+ versionCode 1009000602
+ versionName "9.0.6-2"
// Supported language variants must be declared here to avoid from being removed during the compilation.
// This also helps us to not include unnecessary language variants in the APK.
resConfigs "en", "es"
diff --git a/assets/images/simple-illustrations/simple-illustration__receipt-location-marker.svg b/assets/images/simple-illustrations/simple-illustration__receipt-location-marker.svg
new file mode 100644
index 000000000000..01669d07c0f0
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__receipt-location-marker.svg
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/assets/images/simple-illustrations/simple-illustration__tire.svg b/assets/images/simple-illustrations/simple-illustration__tire.svg
new file mode 100644
index 000000000000..9107c88eb3e2
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__tire.svg
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/Pay-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Pay-Bills.md
deleted file mode 100644
index 81dcf3488462..000000000000
--- a/docs/articles/expensify-classic/bank-accounts-and-payments/Pay-Bills.md
+++ /dev/null
@@ -1,113 +0,0 @@
----
-title: Pay Bills
-description: How to receive and pay company bills in Expensify
----
-
-
-# Overview
-Simplify your back office by receiving bills from vendors and suppliers in Expensify. Anyone with or without an Expensify account can send you a bill, and Expensify will file it as a Bill and help you issue the payment.
-
-# How to Receive Vendor or Supplier Bills in Expensify
-
-There are three ways to get a vendor or supplier bill into Expensify:
-
-**Option 1:** Have vendors send bills to Expensify directly: Ask your vendors to email all bills to your Expensify billing intake email.
-
-**Option 2:** Forward bills to Expensify: If your bills are emailed to you, you can forward those bills to your Expensify billing intake email yourself.
-
-**Option 3:** Manually upload bills to Expensify: If you receive physical bills, you can manually create a Bill in Expensify on the web from the Reports page:
-1. Click **New Report** and choose **Bill**
-2. Add the expense details and vendor's email address to the pop-up window
-3. Upload a pdf/image of the bill
-4. Click **Submit**
-
-# How to Pay Bills
-
-There are multiple ways to pay Bills in Expensify. Let’s go over each method below:
-
-## ACH bank-to-bank transfer
-
-To use this payment method, you must have a business bank account connected to your Expensify account.
-
-To pay with an ACH bank-to-bank transfer:
-
-1. Sign in to your Expensify account on the web at www.expensify.com.
-2. Go to the Inbox or Reports page and locate the Bill that needs to be paid.
-3. Click the **Pay** button to be redirected to the Bill.
-4. Choose the ACH option from the drop-down list.
-5. Follow the prompts to connect your business bank account to Expensify.
-
-**Fees:** None
-
-## Pay using a credit or debit card
-
-This option is available to all US and International customers receiving an bill from a US vendor with a US business bank account.
-
-To pay with a credit or debit card:
-1. Sign-in to your Expensify account on the web app at www.expensify.com.
-2, Click on the Bill you’d like to pay to see the details.
-3, Click the **Pay** button.
-4. You’ll be prompted to enter your credit card or debit card details.
-
-**Fees:** Includes 2.9% credit card payment fee
-
-## Venmo
-
-If both you and the vendor have Venmo setup in their Expensify account, you can opt to pay the bill through Venmo.
-
-**Fees:** Venmo charges a 3% sender’s fee
-
-## Pay Outside of Expensify
-
-If you are not able to pay using one of the above methods, then you can mark the Bill as paid manually in Expensify to update its status and indicate that you have made payment outside Expensify.
-
-To mark a Bill as paid outside of Expensify:
-
-1. Sign-in to your Expensify account on the web app at www.expensify.com.
-2. Click on the Bill you’d like to pay to see the details.
-3. Click on the **Reimburse** button.
-4. Choose **I’ll do it manually**
-
-**Fees:** None
-
-# Deep Dive: How company bills and vendor invoices are processed in Expensify
-
-Here is how a vendor or supplier bill goes from received to paid in Expensify:
-
-1. When a vendor or supplier bill is received in Expensify via, the document is SmartScanned automatically and a Bill is created. The Bill is owned by the primary domain contact, who will see the Bill on the Reports page on their default group policy.
-2. When the Bill is ready for processing, it is submitted and follows the primary domain contact’s approval workflow. Each time the Bill is approved, it is visible in the next approver's Inbox.
-3. The final approver pays the Bill from their Expensify account on the web via one of the methods.
-4. The Bill is coded with the relevant imported GL codes from a connected accounting software. After it has finished going through the approval workflow the Bill can be exported back to the accounting package connected to the policy.
-
-
-{% include faq-begin.md %}
-
-## What is my company's billing intake email?
-Your billing intake email is [yourdomain.com]@expensify.cash. Example, if your domain is `company.io` your billing email is `company.io@expensify.cash`.
-
-## When a vendor or supplier bill is sent to Expensify, who receives it?
-
-Bills are received by the Primary Contact for the domain. This is the email address listed at **Settings > Domains > Domain Admins**.
-
-## Who can view a Bill in Expensify?
-
-Only the primary contact of the domain can view a Bill.
-
-## Who can pay a Bill?
-
-Only the primary domain contact (owner of the bill) will be able to pay the Mill.
-
-## How can you share access to Bills?
-
-To give others the ability to view a Bill, the primary contact can manually “share” the Bill under the Details section of the report via the Sharing Options button.
-To give someone else the ability to pay Bills, the primary domain contact will need to grant those individuals Copilot access to the primary domain contact's account.
-
-## Is Bill Pay supported internationally?
-
-Payments are currently only supported for users paying in United States Dollars (USD).
-
-## What’s the difference between a Bill and an Invoice in Expensify?
-
-A Bill is a payable which represents an amount owed to a payee (usually a vendor or supplier), and is usually created from a vendor invoice. An Invoice is a receivable, and indicates an amount owed to you by someone else.
-
-{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/Reimbursements.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Reimbursements.md
deleted file mode 100644
index a31c0a582fd7..000000000000
--- a/docs/articles/expensify-classic/bank-accounts-and-payments/Reimbursements.md
+++ /dev/null
@@ -1,42 +0,0 @@
-# Overview
-
-If you want to know more about how and when you’ll be reimbursed through Expensify, we’ve answered your questions below.
-
-# How to Get Reimbursed
-
-To get paid back after submitting a report for reimbursement, you’ll want to be sure to connect your bank account. You can do that under **Settings** > **Account** > **Payments** > **Add a Deposit Account**. Once your employer has approved your report, the reimbursement will be paid into the account you added.
-
-# Deep Dive
-
-## Reimbursement Timing
-
-### US Bank Accounts
-
-If your company uses Expensify's ACH reimbursement we'll first check to see if the report is eligible for Rapid Reimbursement (next business day). For a report to be eligible for Rapid Reimbursement it must fall under two limits:
-
- - $100 per deposit bank account per day or less for the individuals being reimbursed or businesses receiving payments for bills.
- - Less than $10,000 being disbursed in a 24-hour time period from the verified bank account being used to pay the reimbursement.
-
-If the request passes both checks, then you can expect to see funds deposited into your bank account on the next business day.
-
-If either limit has been reached, then you can expect to see funds deposited within your bank account within the typical ACH timeframe of 3-5 business days.
-
-### International Bank Accounts
-
-If receiving reimbursement to an international deposit account via Global Reimbursement, you should expect to see funds deposited in your bank account within 4 business days.
-
-## Bank Processing Timeframes
-
-Banks only process transactions and ACH activity on weekdays that are not bank holidays. These are considered business days. Additionally, the business day on which a transaction will be processed depends upon whether or not a request is created before or after the cutoff time, which is typically 3 pm PST.
-For example, if your reimbursement is initiated at 4 pm on Wednesday, this is past the bank's cutoff time, and it will not begin processing until the next business day.
-If that same reimbursement starts processing on Thursday, and it's estimated to take 3-5 business days, this will cover a weekend, and both days are not considered business days. So, assuming there are no bank holidays added into this mix, here is how that reimbursement timeline would play out:
-
-**Wednesday**: Reimbursement initiated after 3 pm PST; will be processed the next business day by your company’s bank.
-**Thursday**: Your company's bank will begin processing the withdrawal request
-**Friday**: Business day 1
-**Saturday**: Weekend
-**Sunday**: Weekend
-**Monday**: Business day 2
-**Tuesday**: Business day 3
-**Wednesday**: Business day 4
-**Thursday**: Business day 5
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/Reimbursing-Reports.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Reimbursing-Reports.md
deleted file mode 100644
index 69b39bae2874..000000000000
--- a/docs/articles/expensify-classic/bank-accounts-and-payments/Reimbursing-Reports.md
+++ /dev/null
@@ -1,81 +0,0 @@
----
-title: Reimbursing Reports
-description: How to reimburse employee expense reports
----
-# Overview
-
-One essential aspect of the Expensify workflow is the ability to reimburse reports. This process allows for the reimbursement of expenses that have been submitted for review to the person who made the request. Detailed explanations of the various methods for reimbursing reports within Expensify are provided below.
-
-# How to reimburse reports
-
-Reports can be reimbursed directly within Expensify by clicking the **Reimburse** button at the top of the report to reveal the available reimbursement options.
-
-## Direct Deposit
-
-To reimburse directly in Expensify, the following needs to be already configured:
-- The employee that's receiving reimbursement needs to add a deposit bank account to their Expensify account (under **Settings > Account > Payments > Add a Deposit-only Bank Account**)
-- The reimburser needs to add a business bank account to Expensify (under **Settings > Account > Payments > Add a Verified Business Bank Account**)
-- The reimburser needs to ensure Expensify is whitelisted to withdraw funds from the bank account
-
-If all of those settings are in place, to reimburse a report, you will click **Reimburse** on the report and then select **Via Direct Deposit (ACH)**.
-
-![Reimbursing Reports Dropdown]({{site.url}}/assets/images/Reimbursing Reports Dropdown.png){:width="100%"}
-
-## Indirect or Manual Reimbursement
-
-If you don't have the option to utilize direct reimbursement, you can choose to mark a report as reimbursed by clicking the **Reimburse** button at the top of the report and then selecting **I’ll do it manually – just mark as reimbursed**.
-
-This will effectively mark the report as reimbursed within Expensify, but you'll handle the payment elsewhere, outside of the platform.
-
-# Best Practices
-- Plan ahead! Consider sharing a business bank account with multiple workspace admins so they can reimburse employee reports if you're unavailable. We recommend having at least two workspace admins with reimbursement permissions.
-
-- Understand there is a verification process when sharing a business bank account. The new reimburser will need access to the business bank account’s transaction history (or access to someone who has access to it) to verify the set of test transactions sent from Expensify.
-
-- Get into the routine of having every new employee connect a deposit-only bank account to their Expensify account. This will ensure reimbursements happen in a timely manner.
-
-- Employees can see the expected date of their reimbursement at the top of and in the comments section of their report.
-
-# How to cancel a reimbursement
-
-Reimbursed a report by mistake? No worries! Any workspace admin with access to the same Verified Bank Account can cancel the reimbursement from within the report until it is withdrawn from the payment account.
-
-**Steps to Cancel an ACH Reimbursement:**
-1. On your web account, navigate to the Reports page
-2. Open the report
-3. Click **Cancel Reimbursement**
-4. After the prompt, "Are you sure you want to cancel the reimbursement?" click **Cancel Reimbursement**.
-
-It's important to note that there is a small window of time (roughly less than 24 hours) when a reimbursement can be canceled. If you don't see the **Cancel Reimbursement** button on a report, this means your bank has already begun withdrawing the funds from the reimbursement account and the withdrawal cannot be canceled.
-
-In that case, you’ll want to contact your bank directly to see if they can cancel the reimbursement on their end - or manage the return of funds directly with your employee, outside of Expensify.
-
-If you cancel a reimbursement after the withdrawal has started, it will be automatically returned to your Verified Bank Account within 3-5 business days.
-
-# Deep Dive
-
-## Rapid Reimbursement
-If your company uses Expensify's ACH reimbursement, we'll first check to see if the report is eligible for Rapid Reimbursement (next business day). For a report to be eligible for Rapid Reimbursement, it must fall under two limits:
-- $100 per deposit only bank account per day for the individuals being reimbursed or businesses receiving payments for bills
-- $10,000 per verified bank account for the company paying bills and reimbursing
-
-If neither limit is met, you can expect to see funds deposited into your bank account on the next business day.
-
-If either limit has been reached, you can expect funds deposited within your bank account within the typical ACH time frame of four to five business days.
-
-Rapid Reimbursement is not available for non-US-based reimbursement. If you are receiving a reimbursement to a non-US-based deposit account, you should expect to see the funds deposited in your bank account within four business days.
-
-{% include faq-begin.md %}
-
-## Who can reimburse reports?
-Only a workspace admin who has added a verified business bank account to their Expensify account can reimburse employees.
-
-## Why can’t I trigger direct ACH reimbursements in bulk?
-
-Instead of a bulk reimbursement option, you can set up automatic reimbursement. With this configured, reports below a certain threshold (defined by you) will be automatically reimbursed via ACH as soon as they're "final approved."
-
-To set your manual reimbursement threshold, head to **Settings > Workspace > Group > _[Workspace Name]_ > Reimbursement > Manual Reimbursement**.
-
-![Manual Reimbursement]({{site.url}}/assets/images/Reimbursing Manual.png){:width="100%"}
-
-{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Cancel-an-ACH-Reimbursement.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Cancel-an-ACH-Reimbursement.md
new file mode 100644
index 000000000000..ab75067b1a7f
--- /dev/null
+++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Cancel-an-ACH-Reimbursement.md
@@ -0,0 +1,17 @@
+---
+title: Cancel an ACH reimbursement
+description: Cancel an ACH payment after it has been sent
+---
+
+
+If a report was reimbursed with an ACH payment by mistake or otherwise needs to be canceled, a Workspace Admin with access to the verified bank account can cancel the reimbursement up until it is withdrawn from the payment account.
+
+To cancel an ACH reimbursement,
+
+1. Click the **Reports** tab.
+2. Open the report.
+3. Click **Cancel Reimbursement**.
+ - If you don’t see the Cancel Reimbursement button, this means your bank has already begun transferring the funds and it cannot be canceled. In this case, you’ll need to contact your bank for cancellation.
+4. Click **Cancel Reimbursement** to confirm cancellation.
+
+
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-Payments.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-Payments.md
new file mode 100644
index 000000000000..00fb236e1763
--- /dev/null
+++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-Payments.md
@@ -0,0 +1,36 @@
+---
+title: Receive payments
+description: Receive reimbursements from an employer
+---
+
+
+To get paid after submitting a report for reimbursement, you must first connect a personal U.S. bank account or a personal Australian bank account. Then once your employer approves your report or invoice, the reimbursement will be paid directly to your bank account.
+Funds for U.S. and global payments are generally deposited within a maximum of four to five business days: 2 days for the funds to be debited from the business bank account, and 2-3 business days for the ACH or wire to deposit into the employee account.
+
+However, banks only process ACH transactions before the daily cutoff (generally 3 p.m. PST) and only on business weekdays that are not bank holidays. This may affect when the payment is disbursed. If the payment qualifies for Rapid Reimbursement, you may receive the payment sooner.
+
+{% include info.html %}
+Companies also have the option to submit payments outside of Expensify via check, cash, or a third-party payment processor. Check with your Workspace Admin to know how you will be reimbursed.
+{% include end-info.html %}
+
+# Rapid Reimbursement (U.S. only)
+
+With Expensify’s ACH reimbursement, payments may be eligible for reimbursement by the next business day with Rapid Reimbursement if they meet the following qualifications:
+- **Deposit-only accounts**: Payment must not exceed $100
+- **Verified business bank accounts**: The account does not disburse more than $10,000 within a 24-hour time period.
+
+If the payment amount exceeds the limit, funds will be deposited within the typical ACH time frame of four to five business days.
+
+{% include faq-begin.md %}
+
+**Is there a way I can track my payment?**
+
+For U.S. ACH payments and global reimbursements, the expected date of reimbursement is provided at the top of the report and in the comments section of the report. Funds will be deposited within the typical ACH time frame of four to five business days unless the payment is eligible for Rapid Reimbursement.
+
+**For global payments, what currency is the payment provided in?**
+
+Global payments are reimbursed in the recipient's currency.
+
+{% include faq-end.md %}
+
+
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Australian-Reports.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Australian-Reports.md
new file mode 100644
index 000000000000..90a89ff3c75e
--- /dev/null
+++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Australian-Reports.md
@@ -0,0 +1,63 @@
+---
+title: Reimburse Australian reports
+description: Send payment for Australian expense reports
+---
+
+
+Workspace Admins can reimburse AUD expense reports by downloading an .aba file containing the accounts needing payment and uploading the file to the bank. This can be done for a single report or for a batch of payments at once.
+
+{% include info.html %}
+Your financial institution may require .aba files to include a self-balancing transaction. If you are unsure, check with your bank. Otherwise, the .aba file may not work with your bank’s internet banking platform.
+{% include end-info.html %}
+
+# Reimburse a single report
+
+1. Open the report, invoice, or bill from the email or Concierge notification, or from the **Reports** tab.
+2. Click the **Reimburse** dropdown and select **Via ABA File**.
+3. Click **Generate ABA and Mark as Reimbursed**.
+4. Click **Download**.
+5. Upload the .aba file to your bank. For additional guidance, use any of the following bank guides:
+ - [ANZ Bank](https://www.anz.com.au/support/internet-banking/pay-transfer-business/payroll/import-file/)
+ - [CommBank](https://www.commbank.com.au/business/pds/003-279-importing-a-de-file.pdf)
+ - [Westpac](https://www.westpac.com.au/business-banking/online-banking/support-faqs/import-files/)
+ - [NAB](https://www.nab.com.au/business/online-banking/nab-connect/help)
+ - [Bendigo Bank](https://www.bendigobank.com.au/globalassets/documents/business/bulk-payments-user-guide.pdf)
+ - [Bank of Queensland](https://www.boq.com.au/help-and-support/online-banking/ob-faqs-and-support/faq-pfuf)
+
+# Send batch payments
+
+Once employees submit their expense reports, a Workspace Admin exports the reports (which contains the employees’ bank account information) and uploads the .aba file to the bank.
+
+## Step 1: Verify currency & reimbursement settings
+
+1. Hover over **Settings**, then click **Workspaces**.
+2. Select the desired workspace.
+3. Click the **Reports** tab on the left.
+4. Click the Report Currency dropdown and select **AUD A$**.
+5. Click the **Reimbursement** tab on the left.
+6. Verify that **Indirect** is selected as the Reimbursement type or select it if not.
+
+## Step 2: Download and upload the ABA file
+
+1. Click the **Reports** tab.
+2. Use the checkbox on the left to select all the reports needing payment.
+3. Click **Bulk Actions** and select **Reimburse via ABA**.
+5. Click **Generate ABA and Mark as Reimbursed**.
+6. Click **Download Report**.
+7. Upload the .aba file to your bank. For additional guidance, use any of the following bank guides:
+ - [ANZ Bank](https://www.anz.com.au/support/internet-banking/pay-transfer-business/payroll/import-file/)
+ - [CommBank](https://www.commbank.com.au/business/pds/003-279-importing-a-de-file.pdf)
+ - [Westpac](https://www.westpac.com.au/business-banking/online-banking/support-faqs/import-files/)
+ - [NAB](https://www.nab.com.au/business/online-banking/nab-connect/help)
+ - [Bendigo Bank](https://www.bendigobank.com.au/globalassets/documents/business/bulk-payments-user-guide.pdf)
+ - [Bank of Queensland](https://www.boq.com.au/help-and-support/online-banking/ob-faqs-and-support/faq-pfuf)
+
+{% include faq-begin.md %}
+
+**Can I use direct deposit for an AUD bank account?**
+
+No, AUD bank accounts do not rely on direct deposit or ACH.
+
+{% include faq-end.md %}
+
+
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills.md
new file mode 100644
index 000000000000..b2cfbf833e13
--- /dev/null
+++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills.md
@@ -0,0 +1,108 @@
+---
+title: Reimburse reports, invoices, and bills
+description: Use direct deposit or indirect reimbursement to pay reports, invoices, and bills
+---
+
+
+Once a report, invoice, or bill has been submitted and approved for reimbursement, you can reimburse the expenses using direct deposit or an indirect reimbursement option outside of Expensify (like cash, a check, or a third-party payment processor).
+
+# Pay with direct deposit
+
+{% include info.html %}
+Before a report can be reimbursed with direct deposit, the employee or vendor receiving the reimbursement must connect their personal U.S. bank account, and the reimburser must connect a verified business bank account.
+
+Direct deposit is available for U.S. and global reimbursements. It is not available for Australian bank accounts. For Australian accounts, review the process for reimbursing Australian expenses.
+{% include end-info.html %}
+
+1. Open the report, invoice, or bill from the email or Concierge notification, or from the **Reports** tab.
+2. Click the **Reimburse** (for reports) or **Pay** (for bills and invoices) dropdown and select **Via Direct Deposit (ACH)**.
+3. Confirm that the correct VBA is selected or use the dropdown menu to select a different one.
+4. Click **Accept Terms & Pay**.
+
+The reimbursement is now queued in the daily batch.
+
+# Pay with indirect reimbursement
+
+When payments are submitted through Expensify, the report is automatically labeled as Reimbursed after it has been paid. However, if you are reimbursing reports via paper check, payroll, or any other method that takes place outside of Expensify, you’ll want to manually mark the bill as paid in Expensify to track the payment history.
+
+To label a report as Reimbursed after sending a payment outside of Expensify,
+
+1. Pay the report, invoice, or bill outside of Expensify.
+2. Open the report, invoice, or bill from the email or Concierge notification, or from the **Reports** tab.
+3. Click **Reimburse**.
+4. Select **I’ll do it manually - just mark it as reimbursed**. This changes the report status to Reimbursed.
+
+Once the recipient has received the payment, the submitter can return to the report and click **Confirm** at the top of the report. This will change the report status to Reimbursed: CONFIRMED.
+
+{% include faq-begin.md %}
+
+**Is there a maximum total report total?**
+
+Expensify cannot process a reimbursement for any single report over $20,000. If you have a report with expenses exceeding $20,000 we recommend splitting the expenses into multiple reports.
+
+**Why is my account locked?**
+
+When you reimburse a report, you authorize Expensify to withdraw the funds from your account and send them to the person requesting reimbursement. If your bank rejects Expensify’s withdrawal request, your verified bank account is locked until the issue is resolved.
+
+Withdrawal requests can be rejected if the bank account has not been enabled for direct debit or due to insufficient funds. If you need to enable direct debits from your verified bank account, your bank will require the following details:
+- The ACH CompanyIDs: 1270239450 and 4270239450
+- The ACH Originator Name: Expensify
+
+Once resolved, you can request to unlock the bank account by completing the following steps:
+
+1. Hover over **Settings**, then click **Account**.
+2. Click the **Payments** tab.
+3. Click **Bank Accounts**.
+4. Next to the bank account, click **Fix**.
+
+Our support team will review and process the request within 4-5 business days.
+
+**How are bills and invoices processed in Expensify?**
+
+Here is the process a vendor or supplier bill goes through from receipt to payment:
+
+1. A vendor or supplier bill is received in Expensify.
+2. Automatically, the document is SmartScanned and a bill is created for the primary domain contact. The bill will appear under the Reports tab on their default group policy.
+3. When the bill is ready for processing, it is submitted and follows the primary domain contact’s approval workflow until the bill has been fully approved.
+4. The final approver pays the bill from their Expensify account using one of the methods outlined in the article above.
+5. If the workspace is connected to an accounting integration, the bill is automatically coded with the relevant imported GL codes and can be exported back to the accounting software.
+
+**When a vendor or supplier bill is sent to Expensify, who receives it?**
+
+Bills are sent to the primary contact for the domain. They’ll see a notification from Concierge on their Home page, and they’ll also receive an email.
+
+**How can I share access to bills?**
+
+By default, only the primary contact for the domain can view and pay the bill. However, you can allow someone else to view or pay bills.
+
+- **To allow someone to view a bill**: The primary contact can manually share the bill with others to allow them to view it.
+ 1. Click the **Reports** tab.
+ 2. Click the report.
+ 3. Click **Details** in the top right.
+ 4. Click the **Add Person** icon.
+ 5. Enter the email address or phone number of the person you will share the report with.
+ 6. Enter a message, if desired.
+ 7. Click **Share Report**.
+
+- **To allow someone to pay bills**: The primary domain contact can allow others to pay bills on their behalf by [assigning those individuals as Copilots](https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot).
+
+**Is Bill Pay supported internationally?**
+
+Payments are currently only supported for users paying in United States Dollars (USD).
+
+**What’s the difference between a bill and an invoice?**
+
+- A **bill** is a payable that represents an amount owed to a payee (usually a vendor or supplier), and it is usually created from a vendor invoice.
+- An **invoice** is a receivable that indicates an amount owed to you by someone else.
+
+**Who can reimburse reports?**
+
+Only a Workspace Admin who has added a verified business bank account to their Expensify account can reimburse employee reports.
+
+**Why can’t I trigger direct ACH reimbursements in bulk?**
+
+Expensify does not offer bulk reimbursement, but you can set up automatic reimbursement to automatically reimburse approved reports via ACH that do not exceed the threshold that you define.
+
+{% include faq-end.md %}
+
+
diff --git a/docs/articles/expensify-classic/expensify-card/Unlimited-Virtual-Cards.md b/docs/articles/expensify-classic/expensify-card/Unlimited-Virtual-Cards.md
index 239da6518be7..fdbc178737e1 100644
--- a/docs/articles/expensify-classic/expensify-card/Unlimited-Virtual-Cards.md
+++ b/docs/articles/expensify-classic/expensify-card/Unlimited-Virtual-Cards.md
@@ -66,6 +66,8 @@ There are two different limit types that are best suited for their intended purp
- _Fixed limit_ spend cards are ideal for one-time expenses or providing employees access to a card for a designated purchase.
- _Monthly_ limit spend cards are perfect for managing recurring expenses such as subscriptions and memberships.
+A virtual card with either of these limit types doesn't share its limit with any other cards, including the cardholder's smart limit cards.
+
**Where can employees see their virtual cards?**
Employees can see their assigned virtual cards by navigating to **Settings** > **Account** > [**Credit Cards Import**](https://www.expensify.com/settings?param=%7B%22section%22:%22creditcards%22%7D) in their account.
diff --git a/docs/redirects.csv b/docs/redirects.csv
index 67ca238c1aed..1a60d52c1749 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -203,6 +203,9 @@ https://help.expensify.com/articles/new-expensify/chat/Expensify-Chat-For-Admins
https://help.expensify.com/articles/new-expensify/bank-accounts-and-payments/Connect-a-Bank-Account.html,https://help.expensify.com/articles/new-expensify/expenses/Connect-a-Business-Bank-Account
https://help.expensify.com/articles/expensify-classic/travel/Coming-Soon,https://help.expensify.com/expensify-classic/hubs/travel/
https://help.expensify.com/articles/new-expensify/expenses/Manually-submit-reports-for-approval,https://help.expensify.com/new-expensify/hubs/expenses/
+https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Reimbursements.html,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-Payments
+https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Pay-Bills.html,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills
+https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Reimbursing-Reports.html,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills
https://help.expensify.com/articles/expensify-classic/expensify-card/Auto-Reconciliation,https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Reconciliation.md
https://help.expensify.com/articles/new-expensify/expenses/Approve-and-pay-expenses,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses
https://help.expensify.com/articles/new-expensify/expenses/Connect-a-Business-Bank-Account,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Business-Bank-Account
diff --git a/ios/NewApp_AdHoc.mobileprovision.gpg b/ios/NewApp_AdHoc.mobileprovision.gpg
index 32ed6ba30059..c08a5aae1b73 100644
Binary files a/ios/NewApp_AdHoc.mobileprovision.gpg and b/ios/NewApp_AdHoc.mobileprovision.gpg differ
diff --git a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg
index 5712b0d86b19..61b6eeb84537 100644
Binary files a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg and b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg differ
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 2826f7e51db9..3473c2bfcec8 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 9.0.6.0
+ 9.0.6.2
FullStory
OrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index cf3e420de4e7..f413f0d1ae99 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 9.0.6.0
+ 9.0.6.2
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 58cdb65c40e9..212ae9c1aa43 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -13,7 +13,7 @@
CFBundleShortVersionString
9.0.6
CFBundleVersion
- 9.0.6.0
+ 9.0.6.2
NSExtension
NSExtensionPointIdentifier
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 29ab90c4b7db..50dfc65d07b2 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -1871,7 +1871,7 @@ PODS:
- RNGoogleSignin (10.0.1):
- GoogleSignIn (~> 7.0)
- React-Core
- - RNLiveMarkdown (0.1.91):
+ - RNLiveMarkdown (0.1.103):
- glog
- hermes-engine
- RCT-Folly (= 2022.05.16.00)
@@ -1889,9 +1889,9 @@ PODS:
- React-utils
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- - RNLiveMarkdown/common (= 0.1.91)
+ - RNLiveMarkdown/common (= 0.1.103)
- Yoga
- - RNLiveMarkdown/common (0.1.91):
+ - RNLiveMarkdown/common (0.1.103):
- glog
- hermes-engine
- RCT-Folly (= 2022.05.16.00)
@@ -1935,7 +1935,7 @@ PODS:
- ReactCommon/turbomodule/core
- Turf
- Yoga
- - RNPermissions (3.9.3):
+ - RNPermissions (3.10.1):
- glog
- hermes-engine
- RCT-Folly (= 2022.05.16.00)
@@ -2614,10 +2614,10 @@ SPEC CHECKSUMS:
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNGestureHandler: 74b7b3d06d667ba0bbf41da7718f2607ae0dfe8f
RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0
- RNLiveMarkdown: 24fbb7370eefee2f325fb64cfe904b111ffcd81b
+ RNLiveMarkdown: f12157fc91b72e19705c9cc8c98034c4c1669d5a
RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81
rnmapbox-maps: df8fe93dbd251f25022f4023d31bc04160d4d65c
- RNPermissions: 0b61d30d21acbeafe25baaa47d9bae40a0c65216
+ RNPermissions: d2392b754e67bc14491f5b12588bef2864e783f3
RNReactNativeHapticFeedback: 616c35bdec7d20d4c524a7949ca9829c09e35f37
RNReanimated: 323436b1a5364dca3b5f8b1a13458455e0de9efe
RNScreens: abd354e98519ed267600b7ee64fdcb8e060b1218
diff --git a/package-lock.json b/package-lock.json
index 64f5f7a6a94d..6a155219ba5d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,19 +1,19 @@
{
"name": "new.expensify",
- "version": "9.0.6-0",
+ "version": "9.0.6-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.6-0",
+ "version": "9.0.6-2",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@dotlottie/react-player": "^1.6.3",
- "@expensify/react-native-live-markdown": "0.1.91",
+ "@expensify/react-native-live-markdown": "0.1.103",
"@expo/metro-runtime": "~3.1.1",
"@formatjs/intl-datetimeformat": "^6.10.0",
"@formatjs/intl-listformat": "^7.2.2",
@@ -55,7 +55,7 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "2.0.26",
+ "expensify-common": "2.0.35",
"expo": "^50.0.3",
"expo-av": "~13.10.4",
"expo-image": "1.11.0",
@@ -106,7 +106,7 @@
"react-native-pager-view": "6.2.3",
"react-native-pdf": "6.7.3",
"react-native-performance": "^5.1.0",
- "react-native-permissions": "^3.9.3",
+ "react-native-permissions": "^3.10.0",
"react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf",
"react-native-plaid-link-sdk": "11.5.0",
"react-native-qrcode-svg": "^6.2.0",
@@ -233,6 +233,7 @@
"memfs": "^4.6.0",
"onchange": "^7.1.0",
"patch-package": "^8.0.0",
+ "peggy": "^4.0.3",
"portfinder": "^1.0.28",
"prettier": "^2.8.8",
"pusher-js-mock": "^0.3.3",
@@ -3784,9 +3785,9 @@
}
},
"node_modules/@expensify/react-native-live-markdown": {
- "version": "0.1.91",
- "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.91.tgz",
- "integrity": "sha512-6uQTgwhpvLqQKdtNqSgh45sRuQRXzv/WwyhdvQNge6EYtulyGFqT82GIP+LIGW8Xnl73nzFZTuMKwWxFFR/Cow==",
+ "version": "0.1.103",
+ "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.103.tgz",
+ "integrity": "sha512-w9jQoxBE9LghfL8UdYbG+8A+CApmER/XMH8N7/bINn7w57+FnnBa5ckPWx6/UYX7OYsmYxSaHJLQkJEXYlDRZg==",
"workspaces": [
"parser",
"example",
@@ -7879,6 +7880,33 @@
"react-native": ">=0.70.0 <1.0.x"
}
},
+ "node_modules/@peggyjs/from-mem": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@peggyjs/from-mem/-/from-mem-1.3.0.tgz",
+ "integrity": "sha512-kzGoIRJjkg3KuGI4bopz9UvF3KguzfxalHRDEIdqEZUe45xezsQ6cx30e0RKuxPUexojQRBfu89Okn7f4/QXsw==",
+ "dev": true,
+ "dependencies": {
+ "semver": "7.6.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@peggyjs/from-mem/node_modules/semver": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/@perf-profiler/android": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/@perf-profiler/android/-/android-0.12.1.tgz",
@@ -25974,9 +26002,9 @@
}
},
"node_modules/expensify-common": {
- "version": "2.0.26",
- "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.26.tgz",
- "integrity": "sha512-3GORs2xfx78SoKLDh4lXpk4Bx61sAVNnlo23VB803zs7qZz8/Oq3neKedtEJuRAmUps0C1Y5y9xZE8nrPO31nQ==",
+ "version": "2.0.35",
+ "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.35.tgz",
+ "integrity": "sha512-5Q4DK5pJIyPd3FmHrargm9fxiOMBdDLMb8rhwa2jN35DL9NiYX9mOTWc327PnjaFO07AeR6wNj8+BVgqOxqMGg==",
"dependencies": {
"awesome-phonenumber": "^5.4.0",
"classnames": "2.5.0",
@@ -35858,6 +35886,32 @@
"through2": "^2.0.3"
}
},
+ "node_modules/peggy": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/peggy/-/peggy-4.0.3.tgz",
+ "integrity": "sha512-v7/Pt6kGYsfXsCrfb52q7/yg5jaAwiVaUMAPLPvy4DJJU6Wwr72t6nDIqIDkGfzd1B4zeVuTnQT0RGeOhe/uSA==",
+ "dev": true,
+ "dependencies": {
+ "@peggyjs/from-mem": "1.3.0",
+ "commander": "^12.1.0",
+ "source-map-generator": "0.8.0"
+ },
+ "bin": {
+ "peggy": "bin/peggy.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/peggy/node_modules/commander": {
+ "version": "12.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
+ "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/pend": {
"version": "1.2.0",
"dev": true,
@@ -37435,8 +37489,9 @@
}
},
"node_modules/react-native-permissions": {
- "version": "3.9.3",
- "license": "MIT",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-3.10.1.tgz",
+ "integrity": "sha512-Gc5BxxpjZn4QNUDiVeHOO0vXh3AH7ToolmwTJozqC6DsxV7NAf3ttap+8BSmzDR8WxuAM3Cror+YNiBhHJx7/w==",
"peerDependencies": {
"react": ">=16.13.1",
"react-native": ">=0.63.3",
@@ -40226,6 +40281,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/source-map-generator": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/source-map-generator/-/source-map-generator-0.8.0.tgz",
+ "integrity": "sha512-psgxdGMwl5MZM9S3FWee4EgsEaIjahYV5AzGnwUvPhWeITz/j6rKpysQHlQ4USdxvINlb8lKfWGIXwfkrgtqkA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.0.2",
"license": "BSD-3-Clause",
diff --git a/package.json b/package.json
index 9f1c3ffca7a7..cadd22fd7332 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.6-0",
+ "version": "9.0.6-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.",
@@ -61,13 +61,14 @@
"workflow-test:generate": "ts-node workflow_tests/utils/preGenerateTest.ts",
"setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1",
"e2e-test-runner-build": "ncc build tests/e2e/testRunner.ts -o tests/e2e/dist/",
- "react-compiler-healthcheck": "react-compiler-healthcheck --verbose"
+ "react-compiler-healthcheck": "react-compiler-healthcheck --verbose",
+ "generate-search-parser": "peggy --format es -o src/libs/SearchParser/searchParser.js src/libs/SearchParser/searchParser.peggy "
},
"dependencies": {
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@dotlottie/react-player": "^1.6.3",
- "@expensify/react-native-live-markdown": "0.1.91",
+ "@expensify/react-native-live-markdown": "0.1.103",
"@expo/metro-runtime": "~3.1.1",
"@formatjs/intl-datetimeformat": "^6.10.0",
"@formatjs/intl-listformat": "^7.2.2",
@@ -109,7 +110,7 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "2.0.26",
+ "expensify-common": "2.0.35",
"expo": "^50.0.3",
"expo-av": "~13.10.4",
"expo-image": "1.11.0",
@@ -160,7 +161,7 @@
"react-native-pager-view": "6.2.3",
"react-native-pdf": "6.7.3",
"react-native-performance": "^5.1.0",
- "react-native-permissions": "^3.9.3",
+ "react-native-permissions": "^3.10.0",
"react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf",
"react-native-plaid-link-sdk": "11.5.0",
"react-native-qrcode-svg": "^6.2.0",
@@ -287,6 +288,7 @@
"memfs": "^4.6.0",
"onchange": "^7.1.0",
"patch-package": "^8.0.0",
+ "peggy": "^4.0.3",
"portfinder": "^1.0.28",
"prettier": "^2.8.8",
"pusher-js-mock": "^0.3.3",
diff --git a/patches/@expensify+react-native-live-markdown+0.1.91.patch b/patches/@expensify+react-native-live-markdown+0.1.91.patch
deleted file mode 100644
index c77e46accae3..000000000000
--- a/patches/@expensify+react-native-live-markdown+0.1.91.patch
+++ /dev/null
@@ -1,13 +0,0 @@
-diff --git a/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts b/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts
-index 1cda659..ba5c3c3 100644
---- a/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts
-+++ b/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts
-@@ -66,7 +66,7 @@ function setCursorPosition(target: HTMLElement, start: number, end: number | nul
- // 3. Caret at the end of whole input, when pressing enter
- // 4. All other placements
- if (prevChar === '\n' && prevTextLength !== undefined && prevTextLength < textCharacters.length) {
-- if (nextChar !== '\n') {
-+ if (nextChar && nextChar !== '\n' && i !== n - 1) {
- range.setStart(textNodes[i + 1] as Node, 0);
- } else if (i !== textNodes.length - 1) {
- range.setStart(textNodes[i] as Node, 1);
diff --git a/patches/@react-native-community+netinfo+11.2.1+002+turbomodule.patch b/patches/@react-native-community+netinfo+11.2.1+002+turbomodule.patch
index f8e171008e14..bf6decac0450 100644
--- a/patches/@react-native-community+netinfo+11.2.1+002+turbomodule.patch
+++ b/patches/@react-native-community+netinfo+11.2.1+002+turbomodule.patch
@@ -1,5 +1,5 @@
diff --git a/node_modules/@react-native-community/netinfo/android/build.gradle b/node_modules/@react-native-community/netinfo/android/build.gradle
-index 0d617ed..e93d64a 100644
+index 0d617ed..97439e6 100644
--- a/node_modules/@react-native-community/netinfo/android/build.gradle
+++ b/node_modules/@react-native-community/netinfo/android/build.gradle
@@ -3,9 +3,10 @@ buildscript {
@@ -105,7 +105,6 @@ index 0d617ed..e93d64a 100644
+ implementation 'com.facebook.react:react-native:+'
+ }
}
-\ No newline at end of file
diff --git a/node_modules/@react-native-community/netinfo/android/src/main/java/com/reactnativecommunity/netinfo/NetInfoModuleImpl.java b/node_modules/@react-native-community/netinfo/android/src/main/java/com/reactnativecommunity/netinfo/NetInfoModuleImpl.java
index 2c3280b..296bbfd 100644
--- a/node_modules/@react-native-community/netinfo/android/src/main/java/com/reactnativecommunity/netinfo/NetInfoModuleImpl.java
@@ -1609,10 +1608,10 @@ index 095dd3b..596ace1 100644
+{"version":3,"names":["NetInfoStateType","exports","NetInfoCellularGeneration"],"sources":["types.ts"],"sourcesContent":["/**\n * Copyright (c) Facebook, Inc. and its affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n * @format\n */\n\nexport enum NetInfoStateType {\n unknown = 'unknown',\n none = 'none',\n cellular = 'cellular',\n wifi = 'wifi',\n bluetooth = 'bluetooth',\n ethernet = 'ethernet',\n wimax = 'wimax',\n vpn = 'vpn',\n other = 'other',\n}\n\nexport type NetInfoMethodType = 'HEAD' | 'GET';\n\nexport enum NetInfoCellularGeneration {\n '2g' = '2g',\n '3g' = '3g',\n '4g' = '4g',\n '5g' = '5g',\n}\n\nexport interface NetInfoConnectedDetails {\n isConnectionExpensive: boolean;\n}\n\ninterface NetInfoConnectedState<\n T extends NetInfoStateType,\n D extends Record = Record,\n> {\n type: T;\n isConnected: true;\n isInternetReachable: boolean | null;\n details: D & NetInfoConnectedDetails;\n isWifiEnabled?: boolean;\n}\n\ninterface NetInfoDisconnectedState {\n type: T;\n isConnected: false;\n isInternetReachable: false;\n details: null;\n isWifiEnabled?: boolean;\n}\n\nexport interface NetInfoUnknownState {\n type: NetInfoStateType.unknown;\n isConnected: boolean | null;\n isInternetReachable: null;\n details: null;\n isWifiEnabled?: boolean;\n}\n\nexport type NetInfoNoConnectionState =\n NetInfoDisconnectedState;\nexport type NetInfoDisconnectedStates =\n | NetInfoUnknownState\n | NetInfoNoConnectionState;\n\nexport type NetInfoCellularState = NetInfoConnectedState<\n NetInfoStateType.cellular,\n {\n cellularGeneration: NetInfoCellularGeneration | null;\n carrier: string | null;\n }\n>;\nexport type NetInfoWifiState = NetInfoConnectedState<\n NetInfoStateType.wifi,\n {\n ssid: string | null;\n bssid: string | null;\n strength: number | null;\n ipAddress: string | null;\n subnet: string | null;\n frequency: number | null;\n linkSpeed: number | null;\n rxLinkSpeed: number | null;\n txLinkSpeed: number | null;\n }\n>;\nexport type NetInfoBluetoothState =\n NetInfoConnectedState;\nexport type NetInfoEthernetState = NetInfoConnectedState<\n NetInfoStateType.ethernet,\n {\n ipAddress: string | null;\n subnet: string | null;\n }\n>;\nexport type NetInfoWimaxState = NetInfoConnectedState;\nexport type NetInfoVpnState = NetInfoConnectedState;\nexport type NetInfoOtherState = NetInfoConnectedState;\nexport type NetInfoConnectedStates =\n | NetInfoCellularState\n | NetInfoWifiState\n | NetInfoBluetoothState\n | NetInfoEthernetState\n | NetInfoWimaxState\n | NetInfoVpnState\n | NetInfoOtherState;\n\nexport type NetInfoState = NetInfoDisconnectedStates | NetInfoConnectedStates;\n\nexport type NetInfoChangeHandler = (state: NetInfoState) => void;\nexport type NetInfoSubscription = () => void;\n\nexport interface NetInfoConfiguration {\n reachabilityUrl: string;\n reachabilityMethod?: NetInfoMethodType;\n reachabilityHeaders?: Record;\n reachabilityTest: (response: Response) => Promise;\n reachabilityLongTimeout: number;\n reachabilityShortTimeout: number;\n reachabilityRequestTimeout: number;\n reachabilityShouldRun: () => boolean;\n shouldFetchWiFiSSID: boolean;\n useNativeReachability: boolean;\n}\n"],"mappings":";;;;;;AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AAPA,IASYA,gBAAgB,GAAAC,OAAA,CAAAD,gBAAA,0BAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAA,OAAhBA,gBAAgB;AAAA;AAAA,IAchBE,yBAAyB,GAAAD,OAAA,CAAAC,yBAAA,0BAAzBA,yBAAyB;EAAzBA,yBAAyB;EAAzBA,yBAAyB;EAAzBA,yBAAyB;EAAzBA,yBAAyB;EAAA,OAAzBA,yBAAyB;AAAA"}
\ No newline at end of file
diff --git a/node_modules/@react-native-community/netinfo/lib/module/index.js b/node_modules/@react-native-community/netinfo/lib/module/index.js
-index 147c72e..02aa0db 100644
+index 147c72e..5de4e7c 100644
--- a/node_modules/@react-native-community/netinfo/lib/module/index.js
+++ b/node_modules/@react-native-community/netinfo/lib/module/index.js
-@@ -6,20 +6,23 @@
+@@ -6,20 +6,26 @@
*
* @format
*/
@@ -1635,11 +1634,14 @@ index 147c72e..02aa0db 100644
const createState = () => {
return new State(_configuration);
};
++
++// Track ongoing requests
++let isRequestInProgress = false;
+
/**
* Configures the library with the given configuration. Note that calling this will stop all
* previously added listeners from being called again. It is best to call this right when your
-@@ -27,23 +30,20 @@ const createState = () => {
+@@ -27,23 +33,20 @@ const createState = () => {
*
* @param configuration The new configuration to set.
*/
@@ -1666,7 +1668,7 @@ index 147c72e..02aa0db 100644
/**
* Returns a `Promise` that resolves to a `NetInfoState` object.
* This function operates on the global singleton instance configured using `configure()`
-@@ -52,27 +52,25 @@ export function configure(configuration) {
+@@ -52,27 +55,33 @@ export function configure(configuration) {
*
* @returns A Promise which contains the current connection state.
*/
@@ -1689,14 +1691,22 @@ index 147c72e..02aa0db 100644
if (!_state) {
_state = createState();
}
--
- return _state._fetchCurrentState();
+
+- return _state._fetchCurrentState();
++ if (isRequestInProgress) {
++ return _state.latest(); // Return the latest state if a request is already in progress
++ }
++
++ isRequestInProgress = true;
++ return _state._fetchCurrentState().finally(() => {
++ isRequestInProgress = false;
++ });
}
+
/**
* Subscribe to the global singleton's connection information. The callback is called with a parameter of type
* [`NetInfoState`](README.md#netinfostate) whenever the connection state changes. Your listener
-@@ -84,18 +82,16 @@ export function refresh() {
+@@ -84,18 +93,16 @@ export function refresh() {
*
* @returns A function which can be called to unsubscribe.
*/
@@ -1716,7 +1726,7 @@ index 147c72e..02aa0db 100644
/**
* A React Hook into this library's singleton which updates when the connection state changes.
*
-@@ -103,12 +99,10 @@ export function addEventListener(listener) {
+@@ -103,12 +110,10 @@ export function addEventListener(listener) {
*
* @returns The connection state.
*/
@@ -1729,7 +1739,7 @@ index 147c72e..02aa0db 100644
const [netInfo, setNetInfo] = useState({
type: Types.NetInfoStateType.unknown,
isConnected: null,
-@@ -120,6 +114,7 @@ export function useNetInfo(configuration) {
+@@ -120,6 +125,7 @@ export function useNetInfo(configuration) {
}, []);
return netInfo;
}
@@ -1737,7 +1747,7 @@ index 147c72e..02aa0db 100644
/**
* A React Hook which manages an isolated instance of the network info manager.
* This is not a hook into a singleton shared state. NetInfo.configure, NetInfo.addEventListener,
-@@ -129,7 +124,6 @@ export function useNetInfo(configuration) {
+@@ -129,7 +135,6 @@ export function useNetInfo(configuration) {
*
* @returns the netInfo state and a refresh function
*/
@@ -1745,7 +1755,7 @@ index 147c72e..02aa0db 100644
export function useNetInfoInstance(isPaused = false, configuration) {
const [networkInfoManager, setNetworkInfoManager] = useState();
const [netInfo, setNetInfo] = useState({
-@@ -142,8 +136,8 @@ export function useNetInfoInstance(isPaused = false, configuration) {
+@@ -142,8 +147,8 @@ export function useNetInfoInstance(isPaused = false, configuration) {
if (isPaused) {
return;
}
@@ -2609,32 +2619,6 @@ index 6982220..b515270 100644
+ readonly eventEmitter: NativeEventEmitter;
};
export default _default;
-diff --git a/node_modules/@react-native-community/netinfo/package.json b/node_modules/@react-native-community/netinfo/package.json
-index 3c80db2..61e6564 100644
---- a/node_modules/@react-native-community/netinfo/package.json
-+++ b/node_modules/@react-native-community/netinfo/package.json
-@@ -48,6 +48,7 @@
- "network info"
- ],
- "peerDependencies": {
-+ "react": "*",
- "react-native": ">=0.59"
- },
- "dependencies": {},
-@@ -121,5 +122,13 @@
- "yarn eslint --fix",
- "git add"
- ]
-+ },
-+ "codegenConfig": {
-+ "name": "RNCNetInfoSpec",
-+ "type": "modules",
-+ "jsSrcsDir": "src/internal",
-+ "android": {
-+ "javaPackageName": "com.reactnativecommunity.netinfo"
-+ }
- }
- }
diff --git a/node_modules/@react-native-community/netinfo/react-native-netinfo.podspec b/node_modules/@react-native-community/netinfo/react-native-netinfo.podspec
index e34e728..9090eb1 100644
--- a/node_modules/@react-native-community/netinfo/react-native-netinfo.podspec
@@ -2971,95 +2955,95 @@ index 878f7ba..0000000
--- a/node_modules/@react-native-community/netinfo/windows/.npmignore
+++ /dev/null
@@ -1,92 +0,0 @@
--*AppPackages*
--*BundleArtifacts*
--
--#OS junk files
--[Tt]humbs.db
--*.DS_Store
--
--#Visual Studio files
--*.[Oo]bj
--*.user
--*.aps
--*.pch
--*.vspscc
--*.vssscc
--*_i.c
--*_p.c
--*.ncb
--*.suo
--*.tlb
--*.tlh
--*.bak
--*.[Cc]ache
--*.ilk
--*.log
--*.lib
--*.sbr
--*.sdf
--*.opensdf
--*.opendb
--*.unsuccessfulbuild
--ipch/
--[Oo]bj/
--[Bb]in
--[Dd]ebug*/
--[Rr]elease*/
--Ankh.NoLoad
--
--# Visual C++ cache files
--ipch/
--*.aps
--*.ncb
--*.opendb
--*.opensdf
--*.sdf
--*.cachefile
--*.VC.db
--*.VC.VC.opendb
--
--#MonoDevelop
--*.pidb
--*.userprefs
--
--#Tooling
--_ReSharper*/
--*.resharper
--[Tt]est[Rr]esult*
--*.sass-cache
--
--#Project files
--[Bb]uild/
--
--#Subversion files
--.svn
--
--# Office Temp Files
--~$*
--
--# vim Temp Files
--*~
--
--#NuGet
--packages/
--*.nupkg
--
--#ncrunch
--*ncrunch*
--*crunch*.local.xml
--
--# visual studio database projects
--*.dbmdl
--
--#Test files
--*.testsettings
--
--#Other files
--*.DotSettings
--.vs/
--*project.lock.json
--
--#Files generated by the VS build
--**/Generated Files/**
--
+-*AppPackages*
+-*BundleArtifacts*
+-
+-#OS junk files
+-[Tt]humbs.db
+-*.DS_Store
+-
+-#Visual Studio files
+-*.[Oo]bj
+-*.user
+-*.aps
+-*.pch
+-*.vspscc
+-*.vssscc
+-*_i.c
+-*_p.c
+-*.ncb
+-*.suo
+-*.tlb
+-*.tlh
+-*.bak
+-*.[Cc]ache
+-*.ilk
+-*.log
+-*.lib
+-*.sbr
+-*.sdf
+-*.opensdf
+-*.opendb
+-*.unsuccessfulbuild
+-ipch/
+-[Oo]bj/
+-[Bb]in
+-[Dd]ebug*/
+-[Rr]elease*/
+-Ankh.NoLoad
+-
+-# Visual C++ cache files
+-ipch/
+-*.aps
+-*.ncb
+-*.opendb
+-*.opensdf
+-*.sdf
+-*.cachefile
+-*.VC.db
+-*.VC.VC.opendb
+-
+-#MonoDevelop
+-*.pidb
+-*.userprefs
+-
+-#Tooling
+-_ReSharper*/
+-*.resharper
+-[Tt]est[Rr]esult*
+-*.sass-cache
+-
+-#Project files
+-[Bb]uild/
+-
+-#Subversion files
+-.svn
+-
+-# Office Temp Files
+-~$*
+-
+-# vim Temp Files
+-*~
+-
+-#NuGet
+-packages/
+-*.nupkg
+-
+-#ncrunch
+-*ncrunch*
+-*crunch*.local.xml
+-
+-# visual studio database projects
+-*.dbmdl
+-
+-#Test files
+-*.testsettings
+-
+-#Other files
+-*.DotSettings
+-.vs/
+-*project.lock.json
+-
+-#Files generated by the VS build
+-**/Generated Files/**
+-
diff --git a/src/CONST.ts b/src/CONST.ts
index 50df9118a74e..bcf900bc4578 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -668,6 +668,7 @@ const CONST = {
LIMIT: 50,
// OldDot Actions render getMessage from Web-Expensify/lib/Report/Action PHP files via getMessageOfOldDotReportAction in ReportActionsUtils.ts
TYPE: {
+ ACTIONABLE_ADD_PAYMENT_CARD: 'ACTIONABLEADDPAYMENTCARD',
ACTIONABLE_JOIN_REQUEST: 'ACTIONABLEJOINREQUEST',
ACTIONABLE_MENTION_WHISPER: 'ACTIONABLEMENTIONWHISPER',
ACTIONABLE_REPORT_MENTION_WHISPER: 'ACTIONABLEREPORTMENTIONWHISPER',
@@ -1245,7 +1246,7 @@ const CONST = {
MAX_AMOUNT_OF_SUGGESTIONS: 20,
MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER: 5,
HERE_TEXT: '@here',
- SUGGESTION_BOX_MAX_SAFE_DISTANCE: 38,
+ SUGGESTION_BOX_MAX_SAFE_DISTANCE: 10,
BIG_SCREEN_SUGGESTION_WIDTH: 300,
},
COMPOSER_MAX_HEIGHT: 125,
@@ -2145,6 +2146,7 @@ const CONST = {
ACCESS_VARIANTS: {
PAID: 'paid',
ADMIN: 'admin',
+ CONTROL: 'control',
},
DEFAULT_MAX_EXPENSE_AGE: 90,
DEFAULT_MAX_EXPENSE_AMOUNT: 200000,
@@ -5243,6 +5245,7 @@ const CONST = {
},
},
+ MAX_LENGTH_256: 256,
WORKSPACE_CARDS_LIST_LABEL_TYPE: {
CURRENT_BALANCE: 'currentBalance',
REMAINING_LIMIT: 'remainingLimit',
@@ -5273,10 +5276,6 @@ const CONST = {
DATE: 'date',
LIST: 'dropdown',
},
-
- NAVIGATION_ACTIONS: {
- RESET: 'RESET',
- },
} as const;
type Country = keyof typeof CONST.ALL_COUNTRIES;
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 8abb7738289c..b06b05dac7e1 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -320,9 +320,6 @@ const ONYXKEYS = {
/** Onboarding Purpose selected by the user during Onboarding flow */
ONBOARDING_PURPOSE_SELECTED: 'onboardingPurposeSelected',
- /** Onboarding error message to be displayed to the user */
- ONBOARDING_ERROR_MESSAGE: 'onboardingErrorMessage',
-
/** Onboarding policyID selected by the user during Onboarding flow */
ONBOARDING_POLICY_ID: 'onboardingPolicyID',
@@ -801,7 +798,6 @@ type OnyxValuesMapping = {
[ONYXKEYS.MAX_CANVAS_HEIGHT]: number;
[ONYXKEYS.MAX_CANVAS_WIDTH]: number;
[ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: string;
- [ONYXKEYS.ONBOARDING_ERROR_MESSAGE]: string;
[ONYXKEYS.ONBOARDING_POLICY_ID]: string;
[ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string;
[ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: boolean;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 2189522e45ea..56006d599d6f 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -704,6 +704,10 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/categories/:categoryName/edit',
getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/edit` as const,
},
+ WORKSPACE_CATEGORY_GL_CODE: {
+ route: 'settings/workspaces/:policyID/categories/:categoryName/gl-code',
+ getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/gl-code` as const,
+ },
WORKSPACE_MORE_FEATURES: {
route: 'settings/workspaces/:policyID/more-features',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/more-features` as const,
@@ -736,6 +740,10 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/tag-list/:orderWeight',
getRoute: (policyID: string, orderWeight: number) => `settings/workspaces/${policyID}/tag-list/${orderWeight}` as const,
},
+ WORKSPACE_TAG_GL_CODE: {
+ route: 'settings/workspaces/:policyID/tag/:orderWeight/:tagName/gl-code',
+ getRoute: (policyID: string, orderWeight: number, tagName: string) => `settings/workspaces/${policyID}/tag/${orderWeight}/${encodeURIComponent(tagName)}/gl-code` as const,
+ },
WORKSPACE_TAXES: {
route: 'settings/workspaces/:policyID/taxes',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes` as const,
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 0768ca8bb291..4790ac3c6a32 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -372,6 +372,7 @@ const SCREENS = {
TAG_CREATE: 'Tag_Create',
TAG_SETTINGS: 'Tag_Settings',
TAG_LIST_VIEW: 'Tag_List_View',
+ TAG_GL_CODE: 'Tag_GL_Code',
CURRENCY: 'Workspace_Profile_Currency',
ADDRESS: 'Workspace_Profile_Address',
WORKFLOWS: 'Workspace_Workflows',
@@ -384,6 +385,7 @@ const SCREENS = {
NAME: 'Workspace_Profile_Name',
CATEGORY_CREATE: 'Category_Create',
CATEGORY_EDIT: 'Category_Edit',
+ CATEGORY_GL_CODE: 'Category_GL_Code',
CATEGORY_SETTINGS: 'Category_Settings',
CATEGORIES_SETTINGS: 'Categories_Settings',
MORE_FEATURES: 'Workspace_More_Features',
diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts
index 3ad9bbe7b152..acdc643b6b70 100644
--- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts
+++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts
@@ -1,5 +1,5 @@
function getBottomSuggestionPadding(): number {
- return 0;
+ return 6;
}
export default getBottomSuggestionPadding;
diff --git a/src/components/AutoCompleteSuggestions/index.tsx b/src/components/AutoCompleteSuggestions/index.tsx
index 8634d6dd0ca0..1aa486eccd4d 100644
--- a/src/components/AutoCompleteSuggestions/index.tsx
+++ b/src/components/AutoCompleteSuggestions/index.tsx
@@ -22,8 +22,16 @@ const measureHeightOfSuggestionRows = (numRows: number, canBeBig: boolean): numb
}
return numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT;
};
-function isSuggestionRenderedAbove(isEnoughSpaceAboveForBig: boolean, isEnoughSpaceAboveForSmall: boolean): boolean {
- return isEnoughSpaceAboveForBig || isEnoughSpaceAboveForSmall;
+function isSuggestionMenuRenderedAbove(isEnoughSpaceAboveForBigMenu: boolean, isEnoughSpaceAboveForSmallMenu: boolean): boolean {
+ return isEnoughSpaceAboveForBigMenu || isEnoughSpaceAboveForSmallMenu;
+}
+
+type IsEnoughSpaceToRenderMenuAboveCursor = Pick & {
+ contentHeight: number;
+ topInset: number;
+};
+function isEnoughSpaceToRenderMenuAboveCursor({y, cursorCoordinates, scrollValue, contentHeight, topInset}: IsEnoughSpaceToRenderMenuAboveCursor): boolean {
+ return y + (cursorCoordinates.y - scrollValue) > contentHeight + topInset + CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_BOX_MAX_SAFE_DISTANCE;
}
/**
@@ -35,7 +43,7 @@ function isSuggestionRenderedAbove(isEnoughSpaceAboveForBig: boolean, isEnoughSp
function AutoCompleteSuggestions({measureParentContainerAndReportCursor = () => {}, ...props}: AutoCompleteSuggestionsProps) {
const containerRef = React.useRef(null);
const isInitialRender = React.useRef(true);
- const isSuggestionAboveRef = React.useRef(false);
+ const isSuggestionMenuAboveRef = React.useRef(false);
const leftValue = React.useRef(0);
const prevLeftValue = React.useRef(0);
const {windowHeight, windowWidth, isSmallScreenWidth} = useWindowDimensions();
@@ -44,11 +52,12 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu
width: 0,
left: 0,
bottom: 0,
+ cursorCoordinates: {x: 0, y: 0},
});
const StyleUtils = useStyleUtils();
const insets = useSafeAreaInsets();
const {keyboardHeight} = useKeyboardState();
- const {paddingBottom: bottomInset} = StyleUtils.getSafeAreaPadding(insets ?? undefined);
+ const {paddingBottom: bottomInset, paddingTop: topInset} = StyleUtils.getSafeAreaPadding(insets ?? undefined);
useEffect(() => {
const container = containerRef.current;
@@ -73,51 +82,51 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu
measureParentContainerAndReportCursor(({x, y, width, scrollValue, cursorCoordinates}: MeasureParentContainerAndCursor) => {
const xCoordinatesOfCursor = x + cursorCoordinates.x;
- const leftValueForBigScreen =
+ const bigScreenLeftOffset =
xCoordinatesOfCursor + CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH > windowWidth
? windowWidth - CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH
: xCoordinatesOfCursor;
-
- let bottomValue = windowHeight - y - cursorCoordinates.y + scrollValue - (keyboardHeight || bottomInset);
- const widthValue = isSmallScreenWidth ? width : CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH;
-
const contentMaxHeight = measureHeightOfSuggestionRows(suggestionsLength, true);
const contentMinHeight = measureHeightOfSuggestionRows(suggestionsLength, false);
- const isEnoughSpaceAboveForBig = windowHeight - bottomValue - contentMaxHeight > CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_BOX_MAX_SAFE_DISTANCE;
- const isEnoughSpaceAboveForSmall = windowHeight - bottomValue - contentMinHeight > CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_BOX_MAX_SAFE_DISTANCE;
+ let bottomValue = windowHeight - (cursorCoordinates.y - scrollValue + y) - keyboardHeight;
+ const widthValue = isSmallScreenWidth ? width : CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH;
+
+ const isEnoughSpaceToRenderMenuAboveForBig = isEnoughSpaceToRenderMenuAboveCursor({y, cursorCoordinates, scrollValue, contentHeight: contentMaxHeight, topInset});
+ const isEnoughSpaceToRenderMenuAboveForSmall = isEnoughSpaceToRenderMenuAboveCursor({y, cursorCoordinates, scrollValue, contentHeight: contentMinHeight, topInset});
- const newLeftValue = isSmallScreenWidth ? x : leftValueForBigScreen;
+ const newLeftOffset = isSmallScreenWidth ? x : bigScreenLeftOffset;
// If the suggested word is longer than 150 (approximately half the width of the suggestion popup), then adjust a new position of popup
- const isAdjustmentNeeded = Math.abs(prevLeftValue.current - leftValueForBigScreen) > 150;
+ const isAdjustmentNeeded = Math.abs(prevLeftValue.current - bigScreenLeftOffset) > 150;
if (isInitialRender.current || isAdjustmentNeeded) {
- isSuggestionAboveRef.current = isSuggestionRenderedAbove(isEnoughSpaceAboveForBig, isEnoughSpaceAboveForSmall);
- leftValue.current = newLeftValue;
+ isSuggestionMenuAboveRef.current = isSuggestionMenuRenderedAbove(isEnoughSpaceToRenderMenuAboveForBig, isEnoughSpaceToRenderMenuAboveForSmall);
+ leftValue.current = newLeftOffset;
isInitialRender.current = false;
- prevLeftValue.current = newLeftValue;
+ prevLeftValue.current = newLeftOffset;
}
let measuredHeight = 0;
- if (isSuggestionAboveRef.current && isEnoughSpaceAboveForBig) {
+ if (isSuggestionMenuAboveRef.current && isEnoughSpaceToRenderMenuAboveForBig) {
// calculation for big suggestion box above the cursor
measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true);
- } else if (isSuggestionAboveRef.current && isEnoughSpaceAboveForSmall) {
+ } else if (isSuggestionMenuAboveRef.current && isEnoughSpaceToRenderMenuAboveForSmall) {
// calculation for small suggestion box above the cursor
measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, false);
} else {
// calculation for big suggestion box below the cursor
measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true);
- bottomValue = windowHeight - y - cursorCoordinates.y + scrollValue - measuredHeight - CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT;
+ bottomValue = windowHeight - y - cursorCoordinates.y + scrollValue - measuredHeight - CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT - keyboardHeight;
}
setSuggestionHeight(measuredHeight);
setContainerState({
left: leftValue.current,
bottom: bottomValue,
width: widthValue,
+ cursorCoordinates,
});
});
- }, [measureParentContainerAndReportCursor, windowHeight, windowWidth, keyboardHeight, isSmallScreenWidth, suggestionsLength, bottomInset]);
+ }, [measureParentContainerAndReportCursor, windowHeight, windowWidth, keyboardHeight, isSmallScreenWidth, suggestionsLength, bottomInset, topInset]);
- if (containerState.width === 0 && containerState.left === 0 && containerState.bottom === 0) {
+ if ((containerState.width === 0 && containerState.left === 0 && containerState.bottom === 0) || (containerState.cursorCoordinates.x === 0 && containerState.cursorCoordinates.y === 0)) {
return null;
}
return (
diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx
index e641a0c2218a..4cbf85cb0014 100644
--- a/src/components/Breadcrumbs.tsx
+++ b/src/components/Breadcrumbs.tsx
@@ -36,10 +36,11 @@ function Breadcrumbs({breadcrumbs, style}: BreadcrumbsProps) {
const theme = useTheme();
const styles = useThemeStyles();
const [primaryBreadcrumb, secondaryBreadcrumb] = breadcrumbs;
+ const isRootBreadcrumb = primaryBreadcrumb.type === CONST.BREADCRUMB_TYPE.ROOT;
const fontScale = PixelRatio.getFontScale() > CONST.LOGO_MAX_SCALE ? CONST.LOGO_MAX_SCALE : PixelRatio.getFontScale();
return (
- {primaryBreadcrumb.type === CONST.BREADCRUMB_TYPE.ROOT ? (
+ {isRootBreadcrumb ? (
/
{secondaryBreadcrumb.text}
diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx
index f8acec2b07dd..126c81961cee 100644
--- a/src/components/Button/index.tsx
+++ b/src/components/Button/index.tsx
@@ -121,6 +121,9 @@ type ButtonProps = Partial & {
/** Whether the button should use split style or not */
isSplitButton?: boolean;
+
+ /** Whether button's content should be centered */
+ isContentCentered?: boolean;
};
type KeyboardShortcutComponentProps = Pick;
@@ -206,6 +209,7 @@ function Button(
accessibilityLabel = '',
isSplitButton = false,
link = false,
+ isContentCentered = false,
...rest
}: ButtonProps,
ref: ForwardedRef,
@@ -248,7 +252,7 @@ function Button(
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if (icon || shouldShowRightIcon) {
return (
-
+
{icon && (
diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx
index 26331f92401c..36f24c2a3477 100644
--- a/src/components/ConfirmContent.tsx
+++ b/src/components/ConfirmContent.tsx
@@ -14,8 +14,11 @@ import type IconAsset from '@src/types/utils/IconAsset';
import Button from './Button';
import Header from './Header';
import Icon from './Icon';
+import {Close} from './Icon/Expensicons';
import ImageSVG from './ImageSVG';
+import {PressableWithoutFeedback} from './Pressable';
import Text from './Text';
+import Tooltip from './Tooltip';
type ConfirmContentProps = {
/** Title of the modal */
@@ -51,15 +54,36 @@ type ConfirmContentProps = {
/** Icon to display above the title */
iconSource?: IconAsset;
+ /** Fill color for the Icon */
+ iconFill?: string | false;
+
+ /** Icon width */
+ iconWidth?: number;
+
+ /** Icon height */
+ iconHeight?: number;
+
+ /** Should the icon be centered? */
+ shouldCenterIcon?: boolean;
+
/** Whether to center the icon / text content */
shouldCenterContent?: boolean;
+ /** Whether to show the dismiss icon */
+ shouldShowDismissIcon?: boolean;
+
/** Whether to stack the buttons */
shouldStackButtons?: boolean;
+ /** Whether to reverse the order of the stacked buttons */
+ shouldReverseStackedButtons?: boolean;
+
/** Styles for title */
titleStyles?: StyleProp;
+ /** Styles for title container */
+ titleContainerStyles?: StyleProp;
+
/** Styles for prompt */
promptStyles?: StyleProp;
@@ -85,13 +109,20 @@ function ConfirmContent({
shouldDisableConfirmButtonWhenOffline = false,
shouldShowCancelButton = false,
iconSource,
+ iconFill,
shouldCenterContent = false,
shouldStackButtons = true,
titleStyles,
promptStyles,
contentStyles,
iconAdditionalStyles,
+ iconWidth = variables.appModalAppIconSize,
+ iconHeight = variables.appModalAppIconSize,
+ shouldCenterIcon = false,
+ shouldShowDismissIcon = false,
image,
+ titleContainerStyles,
+ shouldReverseStackedButtons = false,
}: ConfirmContentProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
@@ -116,19 +147,35 @@ function ConfirmContent({
)}
+ {shouldShowDismissIcon && (
+
+
+
+
+
+
+
+ )}
- {typeof iconSource === 'function' && (
-
+ {iconSource && (
+
)}
-
+
+ {shouldShowCancelButton && shouldReverseStackedButtons && (
+
+ )}
- {shouldShowCancelButton && (
+ {shouldShowCancelButton && !shouldReverseStackedButtons && (
;
+
/** Styles for title */
titleStyles?: StyleProp;
@@ -67,6 +85,9 @@ type ConfirmModalProps = {
/** Whether to stack the buttons */
shouldStackButtons?: boolean;
+ /** Whether to reverse the order of the stacked buttons */
+ shouldReverseStackedButtons?: boolean;
+
/** Image to display with content */
image?: IconAsset;
@@ -101,6 +122,13 @@ function ConfirmModal({
isVisible,
onConfirm,
image,
+ iconWidth,
+ iconHeight,
+ iconFill,
+ shouldCenterIcon,
+ shouldShowDismissIcon,
+ titleContainerStyles,
+ shouldReverseStackedButtons,
shouldEnableNewFocusManagement,
restoreFocusType,
}: ConfirmModalProps) {
@@ -134,10 +162,18 @@ function ConfirmModal({
shouldShowCancelButton={shouldShowCancelButton}
shouldCenterContent={shouldCenterContent}
iconSource={iconSource}
+ contentStyles={isSmallScreenWidth && shouldShowDismissIcon ? styles.mt2 : undefined}
+ iconFill={iconFill}
+ iconHeight={iconHeight}
+ iconWidth={iconWidth}
+ shouldCenterIcon={shouldCenterIcon}
+ shouldShowDismissIcon={shouldShowDismissIcon}
+ titleContainerStyles={titleContainerStyles}
iconAdditionalStyles={iconAdditionalStyles}
titleStyles={titleStyles}
promptStyles={promptStyles}
shouldStackButtons={shouldStackButtons}
+ shouldReverseStackedButtons={shouldReverseStackedButtons}
image={image}
/>
diff --git a/src/components/Form/SafariFormWrapper.tsx b/src/components/Form/SafariFormWrapper.tsx
new file mode 100644
index 000000000000..8ad411e547be
--- /dev/null
+++ b/src/components/Form/SafariFormWrapper.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import {isSafari} from '@libs/Browser';
+import type ChildrenProps from '@src/types/utils/ChildrenProps';
+
+type SafariFormWrapperProps = ChildrenProps;
+
+/**
+ * If we used any without ;
+ }
+
+ return children;
+}
+
+export default SafariFormWrapper;
diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts
index da72b3025340..b4dd8f254e25 100644
--- a/src/components/Icon/Illustrations.ts
+++ b/src/components/Icon/Illustrations.ts
@@ -79,6 +79,7 @@ import PiggyBank from '@assets/images/simple-illustrations/simple-illustration__
import Profile from '@assets/images/simple-illustrations/simple-illustration__profile.svg';
import QRCode from '@assets/images/simple-illustrations/simple-illustration__qr-code.svg';
import ReceiptEnvelope from '@assets/images/simple-illustrations/simple-illustration__receipt-envelope.svg';
+import ReceiptLocationMarker from '@assets/images/simple-illustrations/simple-illustration__receipt-location-marker.svg';
import ReceiptWrangler from '@assets/images/simple-illustrations/simple-illustration__receipt-wrangler.svg';
import ReceiptUpload from '@assets/images/simple-illustrations/simple-illustration__receiptupload.svg';
import SanFrancisco from '@assets/images/simple-illustrations/simple-illustration__sanfrancisco.svg';
@@ -91,6 +92,7 @@ import SubscriptionPPU from '@assets/images/simple-illustrations/simple-illustra
import Tag from '@assets/images/simple-illustrations/simple-illustration__tag.svg';
import TeachersUnite from '@assets/images/simple-illustrations/simple-illustration__teachers-unite.svg';
import ThumbsUpStars from '@assets/images/simple-illustrations/simple-illustration__thumbsupstars.svg';
+import Tire from '@assets/images/simple-illustrations/simple-illustration__tire.svg';
import TrackShoe from '@assets/images/simple-illustrations/simple-illustration__track-shoe.svg';
import TrashCan from '@assets/images/simple-illustrations/simple-illustration__trashcan.svg';
import TreasureChest from '@assets/images/simple-illustrations/simple-illustration__treasurechest.svg';
@@ -189,6 +191,7 @@ export {
Pencil,
Tag,
CarIce,
+ ReceiptLocationMarker,
Lightbulb,
EmptyStateTravel,
SubscriptionAnnual,
@@ -202,4 +205,5 @@ export {
EmptyState,
FolderWithPapers,
VirtualCard,
+ Tire,
};
diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx
index 8f3d78546dd3..bb5fdb580aa7 100644
--- a/src/components/LHNOptionsList/OptionRowLHNData.tsx
+++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx
@@ -47,6 +47,7 @@ function OptionRowLHNData({
policy,
parentReportAction,
hasViolations: !!shouldDisplayViolations,
+ transactionViolations,
});
if (deepEqual(item, optionItemRef.current)) {
return optionItemRef.current;
diff --git a/src/components/LocationPermissionModal/index.android.tsx b/src/components/LocationPermissionModal/index.android.tsx
new file mode 100644
index 000000000000..811537e00e67
--- /dev/null
+++ b/src/components/LocationPermissionModal/index.android.tsx
@@ -0,0 +1,90 @@
+import React, {useEffect, useState} from 'react';
+import {Linking} from 'react-native';
+import {RESULTS} from 'react-native-permissions';
+import ConfirmModal from '@components/ConfirmModal';
+import * as Illustrations from '@components/Icon/Illustrations';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {getLocationPermission, requestLocationPermission} from '@pages/iou/request/step/IOURequestStepScan/LocationPermission';
+import type {LocationPermissionModalProps} from './types';
+
+function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDeny, onGrant}: LocationPermissionModalProps) {
+ const [hasError, setHasError] = useState(false);
+ const [showModal, setShowModal] = useState(false);
+
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ useEffect(() => {
+ if (!startPermissionFlow) {
+ return;
+ }
+
+ getLocationPermission().then((status) => {
+ if (status === RESULTS.GRANTED || status === RESULTS.LIMITED) {
+ return onGrant();
+ }
+
+ setShowModal(true);
+ setHasError(status === RESULTS.BLOCKED);
+ });
+ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- We only want to run this effect when startPermissionFlow changes
+ }, [startPermissionFlow]);
+
+ const handledBlockedPermission = (cb: () => void) => () => {
+ if (hasError && Linking.openSettings) {
+ Linking.openSettings();
+ setShowModal(false);
+ setHasError(false);
+ resetPermissionFlow();
+ return;
+ }
+ cb();
+ };
+
+ const grantLocationPermission = handledBlockedPermission(() => {
+ requestLocationPermission().then((status) => {
+ if (status === RESULTS.GRANTED || status === RESULTS.LIMITED) {
+ onGrant();
+ } else if (status === RESULTS.BLOCKED) {
+ setHasError(true);
+ return;
+ } else {
+ onDeny(status);
+ }
+ setShowModal(false);
+ setHasError(false);
+ });
+ });
+
+ const skipLocationPermission = () => {
+ onDeny(RESULTS.DENIED);
+ setShowModal(false);
+ setHasError(false);
+ };
+
+ return (
+
+ );
+}
+
+LocationPermissionModal.displayName = 'LocationPermissionModal';
+
+export default LocationPermissionModal;
diff --git a/src/components/LocationPermissionModal/index.tsx b/src/components/LocationPermissionModal/index.tsx
new file mode 100644
index 000000000000..2bc4a7393822
--- /dev/null
+++ b/src/components/LocationPermissionModal/index.tsx
@@ -0,0 +1,90 @@
+import React, {useEffect, useState} from 'react';
+import {Linking} from 'react-native';
+import {RESULTS} from 'react-native-permissions';
+import ConfirmModal from '@components/ConfirmModal';
+import * as Illustrations from '@components/Icon/Illustrations';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {getLocationPermission, requestLocationPermission} from '@pages/iou/request/step/IOURequestStepScan/LocationPermission';
+import type {LocationPermissionModalProps} from './types';
+
+function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDeny, onGrant}: LocationPermissionModalProps) {
+ const [hasError, setHasError] = useState(false);
+ const [showModal, setShowModal] = useState(false);
+
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ useEffect(() => {
+ if (!startPermissionFlow) {
+ return;
+ }
+
+ getLocationPermission().then((status) => {
+ if (status === RESULTS.GRANTED || status === RESULTS.LIMITED) {
+ return onGrant();
+ }
+
+ setShowModal(true);
+ setHasError(status === RESULTS.BLOCKED);
+ });
+ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- We only want to run this effect when startPermissionFlow changes
+ }, [startPermissionFlow]);
+
+ const handledBlockedPermission = (cb: () => void) => () => {
+ if (hasError && Linking.openSettings) {
+ Linking.openSettings();
+ setShowModal(false);
+ setHasError(false);
+ resetPermissionFlow();
+ return;
+ }
+ cb();
+ };
+
+ const grantLocationPermission = handledBlockedPermission(() => {
+ requestLocationPermission()
+ .then((status) => {
+ if (status === RESULTS.GRANTED || status === RESULTS.LIMITED) {
+ onGrant();
+ } else {
+ onDeny(status);
+ }
+ })
+ .finally(() => {
+ setShowModal(false);
+ setHasError(false);
+ });
+ });
+
+ const skipLocationPermission = () => {
+ onDeny(RESULTS.DENIED);
+ setShowModal(false);
+ setHasError(false);
+ };
+
+ return (
+
+ );
+}
+
+LocationPermissionModal.displayName = 'LocationPermissionModal';
+
+export default LocationPermissionModal;
diff --git a/src/components/LocationPermissionModal/types.ts b/src/components/LocationPermissionModal/types.ts
new file mode 100644
index 000000000000..ec603bfdb8c1
--- /dev/null
+++ b/src/components/LocationPermissionModal/types.ts
@@ -0,0 +1,19 @@
+import type {PermissionStatus} from 'react-native-permissions';
+
+type LocationPermissionModalProps = {
+ /** A callback to call when the permission has been granted */
+ onGrant: () => void;
+
+ /** A callback to call when the permission has been denied */
+ onDeny: (permission: PermissionStatus) => void;
+
+ /** Should start the permission flow? */
+ startPermissionFlow: boolean;
+
+ /** Reset the permission flow */
+ resetPermissionFlow: () => void;
+};
+
+export default {};
+
+export type {LocationPermissionModalProps};
diff --git a/src/components/Lottie/index.tsx b/src/components/Lottie/index.tsx
index 6395a715f339..a9b223a87a54 100644
--- a/src/components/Lottie/index.tsx
+++ b/src/components/Lottie/index.tsx
@@ -1,7 +1,7 @@
-import type {LottieViewProps} from 'lottie-react-native';
+import type {AnimationObject, LottieViewProps} from 'lottie-react-native';
import LottieView from 'lottie-react-native';
import type {ForwardedRef} from 'react';
-import React, {forwardRef} from 'react';
+import React, {forwardRef, useEffect, useState} from 'react';
import {View} from 'react-native';
import type DotLottieAnimation from '@components/LottieAnimations/types';
import useAppState from '@hooks/useAppState';
@@ -19,6 +19,12 @@ function Lottie({source, webStyle, ...props}: Props, ref: ForwardedRef setIsError(false)});
+ const [animationFile, setAnimationFile] = useState();
+
+ useEffect(() => {
+ setAnimationFile(source.file);
+ }, [setAnimationFile, source.file]);
+
const aspectRatioStyle = styles.aspectRatioLottie(source);
// If the image fails to load or app is in background state, we'll just render an empty view
@@ -28,17 +34,17 @@ function Lottie({source, webStyle, ...props}: Props, ref: ForwardedRef ;
}
- return (
+ return animationFile ? (
setIsError(true)}
/>
- );
+ ) : null;
}
Lottie.displayName = 'Lottie';
diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx
index 791b3e4d5e48..85bb79f85518 100644
--- a/src/components/MoneyRequestAmountInput.tsx
+++ b/src/components/MoneyRequestAmountInput.tsx
@@ -88,6 +88,18 @@ type MoneyRequestAmountInputProps = {
* Autogrow input container length based on the entered text.
*/
autoGrow?: boolean;
+
+ /**
+ * Determines whether the amount should be reset.
+ */
+ shouldResetAmount?: boolean;
+
+ /**
+ * Callback function triggered when the amount is reset.
+ *
+ * @param resetValue - A boolean indicating whether the amount should be reset.
+ */
+ onResetAmount?: (resetValue: boolean) => void;
};
type Selection = {
@@ -123,6 +135,8 @@ function MoneyRequestAmountInput(
hideFocusedState = true,
shouldKeepUserInput = false,
autoGrow = true,
+ shouldResetAmount,
+ onResetAmount,
...props
}: MoneyRequestAmountInputProps,
forwardedRef: ForwardedRef,
@@ -202,10 +216,21 @@ function MoneyRequestAmountInput(
}));
useEffect(() => {
+ const frontendAmount = onFormatAmount(amount, currency);
+ setCurrentAmount(frontendAmount);
+ if (shouldResetAmount) {
+ setSelection({
+ start: frontendAmount.length,
+ end: frontendAmount.length,
+ });
+ onResetAmount?.(false);
+ return;
+ }
+
if ((!currency || typeof amount !== 'number' || (formatAmountOnBlur && isTextInputFocused(textInput))) ?? shouldKeepUserInput) {
return;
}
- const frontendAmount = onFormatAmount(amount, currency);
+
setCurrentAmount(frontendAmount);
// Only update selection if the amount prop was changed from the outside and is not the same as the current amount we just computed
@@ -216,10 +241,7 @@ function MoneyRequestAmountInput(
end: frontendAmount.length,
});
}
-
- // we want to re-initialize the state only when the amount changes
- // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- }, [amount, shouldKeepUserInput]);
+ }, [amount, currency, formatAmountOnBlur, shouldKeepUserInput, onFormatAmount, shouldResetAmount, onResetAmount, currentAmount]);
// Modifies the amount to match the decimals for changed currency.
useEffect(() => {
diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx
index 1fbd6a6b2630..98ed13a08363 100755
--- a/src/components/MoneyRequestConfirmationList.tsx
+++ b/src/components/MoneyRequestConfirmationList.tsx
@@ -162,6 +162,9 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps &
/** The action to take */
action?: IOUAction;
+
+ /** Should play sound on confirmation */
+ shouldPlaySound?: boolean;
};
type MoneyRequestConfirmationListItem = Participant | ReportUtils.OptionData;
@@ -204,6 +207,7 @@ function MoneyRequestConfirmationList({
action = CONST.IOU.ACTION.CREATE,
currencyList,
shouldDisplayReceipt = false,
+ shouldPlaySound = true,
}: MoneyRequestConfirmationListProps) {
const policy = policyReal ?? policyDraft;
const policyCategories = policyCategoriesReal ?? policyCategoriesDraft;
@@ -240,10 +244,7 @@ function MoneyRequestConfirmationList({
const {unit, rate} = mileageRate ?? {};
- const distance = TransactionUtils.getDistance(transaction);
const prevRate = usePrevious(rate);
- const prevDistance = usePrevious(distance);
- const shouldCalculateDistanceAmount = isDistanceRequest && (iouAmount === 0 || prevRate !== rate || prevDistance !== distance);
const currency = (mileageRate as MileageRate)?.currency ?? policyCurrency;
@@ -257,6 +258,18 @@ function MoneyRequestConfirmationList({
const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest) && !isTypeInvoice;
const isMovingTransactionFromTrackExpense = IOUUtils.isMovingTransactionFromTrackExpense(action);
+
+ const distance = useMemo(() => {
+ const value = TransactionUtils.getDistance(transaction);
+ if (canUseP2PDistanceRequests && isMovingTransactionFromTrackExpense && unit && !TransactionUtils.isFetchingWaypointsFromServer(transaction)) {
+ return DistanceRequestUtils.convertToDistanceInMeters(value, unit);
+ }
+ return value;
+ }, [isMovingTransactionFromTrackExpense, unit, transaction, canUseP2PDistanceRequests]);
+ const prevDistance = usePrevious(distance);
+
+ const shouldCalculateDistanceAmount = isDistanceRequest && (iouAmount === 0 || prevRate !== rate || prevDistance !== distance);
+
const hasRoute = TransactionUtils.hasRoute(transaction, isDistanceRequest);
const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !rate) && !isMovingTransactionFromTrackExpense;
const formattedAmount = isDistanceRequestWithPendingRoute
@@ -287,7 +300,7 @@ function MoneyRequestConfirmationList({
const isMerchantRequired = isPolicyExpenseChat && (!isScanRequest || isEditingSplitBill) && shouldShowMerchant;
const isCategoryRequired = !!policy?.requiresCategory;
-
+ const [shouldResetAmount, setShouldResetAmount] = useState(false);
useEffect(() => {
if (shouldDisplayFieldError && didConfirmSplit) {
setFormError('iou.error.genericSmartscanFailureMessage');
@@ -466,6 +479,8 @@ function MoneyRequestConfirmationList({
onFormatAmount={CurrencyUtils.convertToDisplayStringWithoutCurrency}
onAmountChange={(value: string) => onSplitShareChange(participantOption.accountID ?? -1, Number(value))}
maxLength={formattedTotalAmount.length}
+ shouldResetAmount={shouldResetAmount}
+ onResetAmount={(resetValue) => setShouldResetAmount(resetValue)}
/>
),
}));
@@ -488,6 +503,7 @@ function MoneyRequestConfirmationList({
transaction?.comment?.splits,
transaction?.splitShares,
onSplitShareChange,
+ shouldResetAmount,
]);
const isSplitModified = useMemo(() => {
@@ -505,6 +521,7 @@ function MoneyRequestConfirmationList({
{
IOU.resetSplitShares(transaction);
+ setShouldResetAmount(true);
}}
accessibilityLabel={CONST.ROLE.BUTTON}
role={CONST.ROLE.BUTTON}
@@ -689,7 +706,9 @@ function MoneyRequestConfirmationList({
return;
}
- playSound(SOUNDS.DONE);
+ if (shouldPlaySound) {
+ playSound(SOUNDS.DONE);
+ }
setDidConfirm(true);
onConfirm?.(selectedParticipants);
} else {
@@ -723,6 +742,7 @@ function MoneyRequestConfirmationList({
isDistanceRequestWithPendingRoute,
iouAmount,
onConfirm,
+ shouldPlaySound,
],
);
diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx
index 8dfff6466ab9..48190fb3c759 100644
--- a/src/components/MoneyRequestConfirmationListFooter.tsx
+++ b/src/components/MoneyRequestConfirmationListFooter.tsx
@@ -256,7 +256,8 @@ function MoneyRequestConfirmationListFooter({
// Do not hide fields in case of paying someone
const shouldShowAllFields = !!isDistanceRequest || shouldExpandFields || !shouldShowSmartScanFields || isTypeSend || !!isEditingSplitBill;
// Calculate the formatted tax amount based on the transaction's tax amount and the IOU currency code
- const formattedTaxAmount = CurrencyUtils.convertToDisplayString(transaction?.taxAmount, iouCurrencyCode);
+ const taxAmount = TransactionUtils.getTaxAmount(transaction, false);
+ const formattedTaxAmount = CurrencyUtils.convertToDisplayString(taxAmount, iouCurrencyCode);
// Get the tax rate title based on the policy and transaction
const taxRateTitle = TransactionUtils.getTaxName(policy, transaction);
// Determine if the merchant error should be displayed
diff --git a/src/components/Search/SearchListWithHeader.tsx b/src/components/Search/SearchListWithHeader.tsx
index 48d9a2b4ae3a..02da657609ba 100644
--- a/src/components/Search/SearchListWithHeader.tsx
+++ b/src/components/Search/SearchListWithHeader.tsx
@@ -1,7 +1,12 @@
import type {ForwardedRef} from 'react';
-import React, {forwardRef, useEffect, useMemo, useState} from 'react';
+import React, {forwardRef, useCallback, useEffect, useMemo, useState} from 'react';
+import * as Expensicons from '@components/Icon/Expensicons';
+import MenuItem from '@components/MenuItem';
+import Modal from '@components/Modal';
import SelectionList from '@components/SelectionList';
import type {BaseSelectionListProps, ReportListItemType, SelectionListHandle, TransactionListItemType} from '@components/SelectionList/types';
+import useLocalize from '@hooks/useLocalize';
+import useWindowDimensions from '@hooks/useWindowDimensions';
import * as SearchUtils from '@libs/SearchUtils';
import CONST from '@src/CONST';
import type {SearchDataTypes, SearchQuery} from '@src/types/onyx/SearchResults';
@@ -13,6 +18,8 @@ type SearchListWithHeaderProps = Omit void;
};
function mapTransactionItemToSelectedEntry(item: TransactionListItemType): [string, SelectedTransactionInfo] {
@@ -33,7 +40,14 @@ function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListIt
};
}
-function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchType, ...props}: SearchListWithHeaderProps, ref: ForwardedRef) {
+function SearchListWithHeader(
+ {ListItem, onSelectRow, query, hash, data, searchType, isMobileSelectionModeActive, setIsMobileSelectionModeActive, ...props}: SearchListWithHeaderProps,
+ ref: ForwardedRef,
+) {
+ const {isSmallScreenWidth} = useWindowDimensions();
+ const {translate} = useLocalize();
+ const [isModalVisible, setIsModalVisible] = useState(false);
+ const [longPressedItem, setLongPressedItem] = useState(null);
const [selectedItems, setSelectedItems] = useState({});
const clearSelectedItems = () => setSelectedItems({});
@@ -42,39 +56,72 @@ function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchT
clearSelectedItems();
}, [hash]);
- const toggleTransaction = (item: TransactionListItemType | ReportListItemType) => {
- if (SearchUtils.isTransactionListItemType(item)) {
- if (!item.keyForList) {
+ const toggleTransaction = useCallback(
+ (item: TransactionListItemType | ReportListItemType) => {
+ if (SearchUtils.isTransactionListItemType(item)) {
+ if (!item.keyForList) {
+ return;
+ }
+
+ setSelectedItems((prev) => {
+ if (prev[item.keyForList]?.isSelected) {
+ const {[item.keyForList]: omittedTransaction, ...transactions} = prev;
+ return transactions;
+ }
+ return {...prev, [item.keyForList]: {isSelected: true, canDelete: item.canDelete, action: item.action}};
+ });
+
return;
}
- setSelectedItems((prev) => {
- if (prev[item.keyForList]?.isSelected) {
- const {[item.keyForList]: omittedTransaction, ...transactions} = prev;
- return transactions;
- }
- return {...prev, [item.keyForList]: {isSelected: true, canDelete: item.canDelete, action: item.action}};
+ if (item.transactions.every((transaction) => selectedItems[transaction.keyForList]?.isSelected)) {
+ const reducedSelectedItems: SelectedTransactions = {...selectedItems};
+
+ item.transactions.forEach((transaction) => {
+ delete reducedSelectedItems[transaction.keyForList];
+ });
+
+ setSelectedItems(reducedSelectedItems);
+ return;
+ }
+
+ setSelectedItems({
+ ...selectedItems,
+ ...Object.fromEntries(item.transactions.map(mapTransactionItemToSelectedEntry)),
});
+ },
+ [selectedItems],
+ );
+ const openBottomModal = (item: TransactionListItemType | ReportListItemType | null) => {
+ if (!isSmallScreenWidth) {
return;
}
- if (item.transactions.every((transaction) => selectedItems[transaction.keyForList]?.isSelected)) {
- const reducedSelectedItems: SelectedTransactions = {...selectedItems};
+ setLongPressedItem(item);
+ setIsModalVisible(true);
+ };
- item.transactions.forEach((transaction) => {
- delete reducedSelectedItems[transaction.keyForList];
- });
+ const turnOnSelectionMode = useCallback(() => {
+ setIsMobileSelectionModeActive?.(true);
+ setIsModalVisible(false);
+
+ if (longPressedItem) {
+ toggleTransaction(longPressedItem);
+ }
+ }, [longPressedItem, setIsMobileSelectionModeActive, toggleTransaction]);
- setSelectedItems(reducedSelectedItems);
+ const closeBottomModal = useCallback(() => {
+ setIsModalVisible(false);
+ }, []);
+
+ useEffect(() => {
+ if (isMobileSelectionModeActive) {
return;
}
- setSelectedItems({
- ...selectedItems,
- ...Object.fromEntries(item.transactions.map(mapTransactionItemToSelectedEntry)),
- });
- };
+ setSelectedItems({});
+ }, [setSelectedItems, isMobileSelectionModeActive]);
const toggleAllTransactions = () => {
const areItemsOfReportType = searchType === CONST.SEARCH.DATA_TYPES.REPORT;
@@ -104,6 +151,8 @@ function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchT
clearSelectedItems={clearSelectedItems}
query={query}
hash={hash}
+ isMobileSelectionModeActive={isMobileSelectionModeActive}
+ setIsMobileSelectionModeActive={setIsMobileSelectionModeActive}
/>
// eslint-disable-next-line react/jsx-props-no-spreading
@@ -111,10 +160,24 @@ function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchT
sections={[{data: sortedSelectedData, isDisabled: false}]}
ListItem={ListItem}
onSelectRow={onSelectRow}
+ onLongPressRow={openBottomModal}
ref={ref}
onCheckboxPress={toggleTransaction}
onSelectAll={toggleAllTransactions}
+ isMobileSelectionModeActive={isMobileSelectionModeActive}
/>
+
+
+
+
>
);
}
diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx
index 8d42f9e6da36..b0f2acfb57d1 100644
--- a/src/components/Search/SearchPageHeader.tsx
+++ b/src/components/Search/SearchPageHeader.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback} from 'react';
+import React, {useMemo} from 'react';
import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
@@ -10,6 +10,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as SearchActions from '@libs/actions/Search';
+import SearchSelectedNarrow from '@pages/Search/SearchSelectedNarrow';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import type {SearchQuery} from '@src/types/onyx/SearchResults';
@@ -22,11 +23,13 @@ type SearchHeaderProps = {
selectedItems?: SelectedTransactions;
clearSelectedItems?: () => void;
hash: number;
+ isMobileSelectionModeActive?: boolean;
+ setIsMobileSelectionModeActive?: (isMobileSelectionModeActive: boolean) => void;
};
type SearchHeaderOptionValue = DeepValueOf | undefined;
-function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: SearchHeaderProps) {
+function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems, isMobileSelectionModeActive, setIsMobileSelectionModeActive}: SearchHeaderProps) {
const {translate} = useLocalize();
const theme = useTheme();
const styles = useThemeStyles();
@@ -39,12 +42,13 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}:
finished: {icon: Illustrations.CheckmarkCircle, title: translate('common.finished')},
};
- const getHeaderButtons = useCallback(() => {
+ const selectedItemsKeys = Object.keys(selectedItems ?? []);
+
+ const headerButtonsOptions = useMemo(() => {
const options: Array> = [];
- const selectedItemsKeys = Object.keys(selectedItems ?? []);
if (selectedItemsKeys.length === 0) {
- return null;
+ return options;
}
const itemsToDelete = selectedItemsKeys.filter((id) => selectedItems[id].canDelete);
@@ -56,6 +60,9 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}:
value: CONST.SEARCH.BULK_ACTION_TYPES.DELETE,
onSelected: () => {
clearSelectedItems?.();
+ if (isMobileSelectionModeActive) {
+ setIsMobileSelectionModeActive?.(false);
+ }
SearchActions.deleteMoneyRequestOnSearch(hash, itemsToDelete);
},
});
@@ -70,6 +77,9 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}:
value: CONST.SEARCH.BULK_ACTION_TYPES.HOLD,
onSelected: () => {
clearSelectedItems?.();
+ if (isMobileSelectionModeActive) {
+ setIsMobileSelectionModeActive?.(false);
+ }
SearchActions.holdMoneyRequestOnSearch(hash, itemsToHold, '');
},
});
@@ -84,6 +94,9 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}:
value: CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD,
onSelected: () => {
clearSelectedItems?.();
+ if (isMobileSelectionModeActive) {
+ setIsMobileSelectionModeActive?.(false);
+ }
SearchActions.unholdMoneyRequestOnSearch(hash, itemsToUnhold);
},
});
@@ -107,21 +120,18 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}:
});
}
- return (
- null}
- shouldAlwaysShowDropdownMenu
- pressOnEnter
- buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM}
- customText={translate('workspace.common.selected', {selectedNumber: selectedItemsKeys.length})}
- options={options}
- isSplitButton={false}
- isDisabled={isOffline}
- />
- );
- }, [clearSelectedItems, hash, isOffline, selectedItems, styles.colorMuted, styles.fontWeightNormal, theme.icon, translate]);
+ return options;
+ }, [clearSelectedItems, hash, selectedItems, selectedItemsKeys, styles, theme, translate, isMobileSelectionModeActive, setIsMobileSelectionModeActive]);
if (isSmallScreenWidth) {
+ if (isMobileSelectionModeActive) {
+ return (
+
+ );
+ }
return null;
}
@@ -131,11 +141,23 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}:
icon={headerContent[query]?.icon}
shouldShowBackButton={false}
>
- {getHeaderButtons()}
+ {headerButtonsOptions.length > 0 && (
+ null}
+ shouldAlwaysShowDropdownMenu
+ pressOnEnter
+ buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM}
+ customText={translate('workspace.common.selected', {selectedNumber: selectedItemsKeys.length})}
+ options={headerButtonsOptions}
+ isSplitButton={false}
+ isDisabled={isOffline}
+ />
+ )}
);
}
SearchPageHeader.displayName = 'SearchPageHeader';
+export type {SearchHeaderOptionValue};
export default SearchPageHeader;
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index 7d54d65b310e..78992496f031 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -24,7 +24,6 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SearchResults from '@src/types/onyx/SearchResults';
import type {SearchDataTypes, SearchQuery} from '@src/types/onyx/SearchResults';
-import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
import {useSearchContext} from './SearchContext';
import SearchListWithHeader from './SearchListWithHeader';
import SearchPageHeader from './SearchPageHeader';
@@ -35,6 +34,8 @@ type SearchProps = {
policyIDs?: string;
sortBy?: SearchColumnType;
sortOrder?: SortOrder;
+ isMobileSelectionModeActive?: boolean;
+ setIsMobileSelectionModeActive?: (isMobileSelectionModeActive: boolean) => void;
};
const sortableSearchTabs: SearchQuery[] = [CONST.SEARCH.TAB.ALL];
@@ -42,17 +43,16 @@ const transactionItemMobileHeight = 100;
const reportItemTransactionHeight = 52;
const listItemPadding = 12; // this is equivalent to 'mb3' on every transaction/report list item
const searchHeaderHeight = 54;
-
-function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) {
+function Search({query, policyIDs, sortBy, sortOrder, isMobileSelectionModeActive, setIsMobileSelectionModeActive}: SearchProps) {
const {isOffline} = useNetwork();
const styles = useThemeStyles();
- const {isLargeScreenWidth} = useWindowDimensions();
+ const {isLargeScreenWidth, isSmallScreenWidth} = useWindowDimensions();
const navigation = useNavigation>();
const lastSearchResultsRef = useRef>();
const {setCurrentSearchHash} = useSearchContext();
const hash = SearchUtils.getQueryHash(query, policyIDs, sortBy, sortOrder);
- const [currentSearchResults, searchResultsMeta] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`);
+ const [currentSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`);
const getItemHeight = useCallback(
(item: TransactionListItemType | ReportListItemType) => {
@@ -101,9 +101,8 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) {
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [hash, isOffline]);
- const isLoadingItems = (!isOffline && isLoadingOnyxValue(searchResultsMeta)) || searchResults?.data === undefined;
+ const isLoadingItems = !isOffline && searchResults?.data === undefined;
const isLoadingMoreItems = !isLoadingItems && searchResults?.search?.isLoading && searchResults?.search?.offset > 0;
- const shouldShowEmptyState = !isLoadingItems && SearchUtils.isSearchResultsEmpty(searchResults);
if (isLoadingItems) {
return (
@@ -117,7 +116,9 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) {
);
}
- if (shouldShowEmptyState) {
+ const shouldShowEmptyState = searchResults && SearchUtils.isSearchResultsEmpty(searchResults);
+
+ if (shouldShowEmptyState ?? !searchResults) {
return (
<>
+ !isLargeScreenWidth ? null : (
+
+ )
}
- canSelectMultiple={isLargeScreenWidth}
+ canSelectMultiple={canSelectMultiple}
customListHeaderHeight={searchHeaderHeight}
// To enhance the smoothness of scrolling and minimize the risk of encountering blank spaces during scrolling,
// we have configured a larger windowSize and a longer delay between batch renders.
@@ -216,6 +221,8 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) {
showScrollIndicator={false}
onEndReachedThreshold={0.75}
onEndReached={fetchMoreResults}
+ setIsMobileSelectionModeActive={setIsMobileSelectionModeActive}
+ isMobileSelectionModeActive={isMobileSelectionModeActive}
listFooterContent={
isLoadingMoreItems ? (
({
shouldSyncFocus = true,
onFocus = () => {},
hoverStyle,
+ onLongPressRow,
}: BaseListItemProps) {
const theme = useTheme();
const styles = useThemeStyles();
@@ -42,7 +43,7 @@ function BaseListItem({
// Sync focus on an item
useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus);
- const handleMouseUp = (e: React.MouseEvent) => {
+ const handleMouseLeave = (e: React.MouseEvent) => {
e.stopPropagation();
setMouseUp();
};
@@ -71,6 +72,9 @@ function BaseListItem({
// eslint-disable-next-line react/jsx-props-no-spreading
{...bind}
ref={pressableRef}
+ onLongPress={() => {
+ onLongPressRow?.(item);
+ }}
onPress={(e) => {
if (isMouseDownOnInput) {
e?.stopPropagation(); // Preventing the click action
@@ -92,8 +96,7 @@ function BaseListItem({
id={keyForList ?? ''}
style={pressableStyle}
onFocus={onFocus}
- onMouseUp={handleMouseUp}
- onMouseLeave={handleMouseUp}
+ onMouseLeave={handleMouseLeave}
tabIndex={item.tabIndex}
>
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index 8b6ba790e6b0..eb2e66ad9a78 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -93,6 +93,8 @@ function BaseSelectionList(
updateCellsBatchingPeriod = 50,
removeClippedSubviews = true,
shouldDelayFocus = true,
+ onLongPressRow,
+ isMobileSelectionModeActive,
}: BaseSelectionListProps,
ref: ForwardedRef,
) {
@@ -447,6 +449,8 @@ function BaseSelectionList(
isDisabled={isDisabled}
showTooltip={showTooltip}
canSelectMultiple={canSelectMultiple}
+ onLongPressRow={onLongPressRow}
+ isMobileSelectionModeActive={isMobileSelectionModeActive}
onSelectRow={() => selectRow(item)}
onCheckboxPress={handleOnCheckboxPress()}
onDismissError={() => onDismissError?.(item)}
diff --git a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx
index 1fb08a7a81ef..cfffa2629f43 100644
--- a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx
+++ b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx
@@ -2,15 +2,18 @@ import React, {memo} from 'react';
import {View} from 'react-native';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
+import {PressableWithFeedback} from '@components/Pressable';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
+import CONST from '@src/CONST';
import type {SearchAccountDetails, SearchTransactionAction} from '@src/types/onyx/SearchResults';
import ActionCell from './ActionCell';
import UserInfoCell from './UserInfoCell';
type ExpenseItemHeaderNarrowProps = {
+ text?: string;
participantFrom: SearchAccountDetails;
participantTo: SearchAccountDetails;
participantFromDisplayName: string;
@@ -18,9 +21,28 @@ type ExpenseItemHeaderNarrowProps = {
action?: SearchTransactionAction;
transactionID?: string;
onButtonPress: () => void;
+ canSelectMultiple?: boolean;
+ isSelected?: boolean;
+ isDisabled?: boolean | null;
+ isDisabledCheckbox?: boolean;
+ handleCheckboxPress?: () => void;
};
-function ExpenseItemHeaderNarrow({participantFrom, participantFromDisplayName, participantTo, participantToDisplayName, action, transactionID, onButtonPress}: ExpenseItemHeaderNarrowProps) {
+function ExpenseItemHeaderNarrow({
+ participantFrom,
+ participantFromDisplayName,
+ participantTo,
+ participantToDisplayName,
+ onButtonPress,
+ action,
+ canSelectMultiple,
+ isDisabledCheckbox,
+ isSelected,
+ isDisabled,
+ handleCheckboxPress,
+ text,
+ transactionID,
+}: ExpenseItemHeaderNarrowProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const theme = useTheme();
@@ -28,6 +50,26 @@ function ExpenseItemHeaderNarrow({participantFrom, participantFromDisplayName, p
return (
+ {canSelectMultiple && (
+ handleCheckboxPress?.()}
+ style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), isDisabledCheckbox && styles.cursorDisabled, styles.mr1]}
+ >
+
+ {isSelected && (
+
+ )}
+
+
+ )}
({
onSelectRow,
onDismissError,
onFocus,
+ onLongPressRow,
shouldSyncFocus,
}: ReportListItemProps) {
const reportItem = item as unknown as ReportListItemType;
@@ -108,6 +109,7 @@ function ReportListItem({
onSelectRow={() => openReportInRHP(transactionItem)}
onDismissError={onDismissError}
onFocus={onFocus}
+ onLongPressRow={onLongPressRow}
shouldSyncFocus={shouldSyncFocus}
/>
);
@@ -124,6 +126,7 @@ function ReportListItem({
showTooltip={showTooltip}
canSelectMultiple={canSelectMultiple}
onSelectRow={onSelectRow}
+ onLongPressRow={onLongPressRow}
onDismissError={onDismissError}
errors={item.errors}
pendingAction={item.pendingAction}
@@ -153,7 +156,7 @@ function ReportListItem({
containerStyle={[StyleUtils.getCheckboxContainerStyle(20), StyleUtils.getMultiselectListStyles(!!item.isSelected, !!item.isDisabled)]}
disabled={!!isDisabled || item.isDisabledCheckbox}
accessibilityLabel={item.text ?? ''}
- style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled]}
+ style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled, !isLargeScreenWidth && styles.mr3]}
/>
)}
@@ -193,6 +196,7 @@ function ReportListItem({
isDisabled={!!isDisabled}
canSelectMultiple={!!canSelectMultiple}
isButtonSelected={item.isSelected}
+ shouldShowTransactionCheckbox
/>
))}
diff --git a/src/components/SelectionList/Search/TransactionListItem.tsx b/src/components/SelectionList/Search/TransactionListItem.tsx
index 6db308831baa..a10552ca9ad8 100644
--- a/src/components/SelectionList/Search/TransactionListItem.tsx
+++ b/src/components/SelectionList/Search/TransactionListItem.tsx
@@ -15,6 +15,7 @@ function TransactionListItem({
onCheckboxPress,
onDismissError,
onFocus,
+ onLongPressRow,
shouldSyncFocus,
}: TransactionListItemProps) {
const transactionItem = item as unknown as TransactionListItemType;
@@ -46,6 +47,7 @@ function TransactionListItem({
pendingAction={item.pendingAction}
keyForList={item.keyForList}
onFocus={onFocus}
+ onLongPressRow={onLongPressRow}
shouldSyncFocus={shouldSyncFocus}
hoverStyle={item.isSelected && styles.activeComponentBG}
>
@@ -59,6 +61,7 @@ function TransactionListItem({
isDisabled={!!isDisabled}
canSelectMultiple={!!canSelectMultiple}
isButtonSelected={item.isSelected}
+ shouldShowTransactionCheckbox={false}
/>
);
diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx
index fc036691a562..08936645367e 100644
--- a/src/components/SelectionList/Search/TransactionListItemRow.tsx
+++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx
@@ -4,6 +4,7 @@ import {View} from 'react-native';
import Checkbox from '@components/Checkbox';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
+import {PressableWithFeedback} from '@components/Pressable';
import ReceiptImage from '@components/ReceiptImage';
import type {TransactionListItemType} from '@components/SelectionList/types';
import TextWithTooltip from '@components/TextWithTooltip';
@@ -53,6 +54,7 @@ type TransactionListItemRowProps = {
canSelectMultiple: boolean;
isButtonSelected?: boolean;
parentAction?: string;
+ shouldShowTransactionCheckbox?: boolean;
};
const getTypeIcon = (type?: SearchTransactionType) => {
@@ -242,27 +244,55 @@ function TransactionListItemRow({
isChildListItem = false,
isButtonSelected = false,
parentAction = '',
+ shouldShowTransactionCheckbox,
}: TransactionListItemRowProps) {
const styles = useThemeStyles();
const {isLargeScreenWidth} = useWindowDimensions();
const StyleUtils = useStyleUtils();
+ const theme = useTheme();
if (!isLargeScreenWidth) {
return (
{showItemHeaderOnNarrowLayout && (
)}
-
+
+ {canSelectMultiple && shouldShowTransactionCheckbox && (
+
+
+ {item.isSelected && (
+
+ )}
+
+
+ )}
= {
/** Handles what to do when the item is focused */
onFocus?: () => void;
+
+ /** Callback to fire when the item is long pressed */
+ onLongPressRow?: (item: TItem) => void;
+
+ /** Whether Selection Mode is active - used only on small screens */
+ isMobileSelectionModeActive?: boolean;
} & TRightHandSideComponent;
type ListItem = {
@@ -465,6 +471,12 @@ type BaseSelectionListProps = Partial & {
* https://reactnative.dev/docs/optimizing-flatlist-configuration#windowsize
*/
windowSize?: number;
+
+ /** Callback to fire when the item is long pressed */
+ onLongPressRow?: (item: TItem) => void;
+
+ /** Whether Selection Mode is active - used only on small screens */
+ isMobileSelectionModeActive?: boolean;
} & TRightHandSideComponent;
type SelectionListHandle = {
diff --git a/src/hooks/useActiveBottomTabRoute.ts b/src/hooks/useActiveBottomTabRoute.ts
new file mode 100644
index 000000000000..434cca0cd815
--- /dev/null
+++ b/src/hooks/useActiveBottomTabRoute.ts
@@ -0,0 +1,8 @@
+import {useContext} from 'react';
+import ActiveBottomTabRouteContext from '@libs/Navigation/AppNavigator/Navigators/ActiveBottomTabRouteContext';
+
+function useActiveBottomTabRoute() {
+ return useContext(ActiveBottomTabRouteContext);
+}
+
+export default useActiveBottomTabRoute;
diff --git a/src/hooks/useActiveRoute.ts b/src/hooks/useActiveRoute.ts
deleted file mode 100644
index 812e7c634ee8..000000000000
--- a/src/hooks/useActiveRoute.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import {useContext} from 'react';
-import ActiveRouteContext from '@libs/Navigation/AppNavigator/Navigators/ActiveRouteContext';
-import type {AuthScreensParamList, NavigationPartialRoute} from '@libs/Navigation/types';
-
-function useActiveRoute(): NavigationPartialRoute | undefined {
- return useContext(ActiveRouteContext);
-}
-
-export default useActiveRoute;
diff --git a/src/hooks/useHtmlPaste/index.ts b/src/hooks/useHtmlPaste/index.ts
index 5888f96d1c15..022d6178877d 100644
--- a/src/hooks/useHtmlPaste/index.ts
+++ b/src/hooks/useHtmlPaste/index.ts
@@ -1,5 +1,6 @@
import {useNavigation} from '@react-navigation/native';
import {useCallback, useEffect} from 'react';
+import type {ClipboardEvent as PasteEvent} from 'react';
import Parser from '@libs/Parser';
import type UseHtmlPaste from './types';
@@ -20,8 +21,10 @@ const insertAtCaret = (target: HTMLElement, text: string) => {
range.setEnd(node, node.length);
selection.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset);
- // Dispatch paste event to simulate real browser behavior
- target.dispatchEvent(new Event('paste', {bubbles: true}));
+ // Dispatch paste event to make Markdown Input properly set cursor position
+ const pasteEvent = new ClipboardEvent('paste', {bubbles: true, cancelable: true});
+ (pasteEvent as unknown as PasteEvent).isDefaultPrevented = () => false;
+ target.dispatchEvent(pasteEvent);
// Dispatch input event to trigger Markdown Input to parse the new text
target.dispatchEvent(new Event('input', {bubbles: true}));
} else {
@@ -142,18 +145,18 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, removeLi
let unsubscribeFocus: () => void;
let unsubscribeBlur: () => void;
if (removeListenerOnScreenBlur) {
- unsubscribeFocus = navigation.addListener('focus', () => document.addEventListener('paste', handlePaste));
- unsubscribeBlur = navigation.addListener('blur', () => document.removeEventListener('paste', handlePaste));
+ unsubscribeFocus = navigation.addListener('focus', () => document.addEventListener('paste', handlePaste, true));
+ unsubscribeBlur = navigation.addListener('blur', () => document.removeEventListener('paste', handlePaste, true));
}
- document.addEventListener('paste', handlePaste);
+ document.addEventListener('paste', handlePaste, true);
return () => {
if (removeListenerOnScreenBlur) {
unsubscribeFocus();
unsubscribeBlur();
}
- document.removeEventListener('paste', handlePaste);
+ document.removeEventListener('paste', handlePaste, true);
};
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, []);
diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts
new file mode 100644
index 000000000000..b806c0dea95a
--- /dev/null
+++ b/src/hooks/usePaginatedReportActions.ts
@@ -0,0 +1,39 @@
+import {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import PaginationUtils from '@libs/PaginationUtils';
+import * as ReportActionsUtils from '@libs/ReportActionsUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+/**
+ * Get the longest continuous chunk of reportActions including the linked reportAction. If not linking to a specific action, returns the continuous chunk of newest reportActions.
+ */
+function usePaginatedReportActions(reportID?: string, reportActionID?: string) {
+ // Use `||` instead of `??` to handle empty string.
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const reportIDWithDefault = reportID || '-1';
+
+ const [sortedAllReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportIDWithDefault}`, {
+ canEvict: false,
+ selector: (allReportActions) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true),
+ });
+ const [reportActionPages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${reportIDWithDefault}`);
+
+ const reportActions = useMemo(() => {
+ if (!sortedAllReportActions?.length) {
+ return [];
+ }
+ return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, reportActionID);
+ }, [reportActionID, reportActionPages, sortedAllReportActions]);
+
+ const linkedAction = useMemo(
+ () => sortedAllReportActions?.find((reportAction) => String(reportAction.reportActionID) === String(reportActionID)),
+ [reportActionID, sortedAllReportActions],
+ );
+
+ return {
+ reportActions,
+ linkedAction,
+ };
+}
+
+export default usePaginatedReportActions;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index c7b5125d02fa..3170d0ebb777 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -135,6 +135,7 @@ export default {
yes: 'Yes',
no: 'No',
ok: 'OK',
+ notNow: 'Not now',
learnMore: 'Learn more',
buttonConfirm: 'Got it',
name: 'Name',
@@ -606,7 +607,7 @@ export default {
saveTheWorld: 'Save the world',
},
allSettingsScreen: {
- subscriptions: 'Subscriptions',
+ subscription: 'Subscription',
cardsAndDomains: 'Cards & Domains',
},
tabSelector: {
@@ -626,6 +627,10 @@ export default {
cameraAccess: 'Camera access is required to take pictures of receipts.',
cameraErrorTitle: 'Camera error',
cameraErrorMessage: 'An error occurred while taking a photo. Please try again.',
+ locationAccessTitle: 'Allow location access',
+ locationAccessMessage: 'We’ll use your location to accurately determine your default currency and timezone. You can edit access in your device’s settings anytime.',
+ locationErrorTitle: 'Enable location in settings',
+ locationErrorMessage: 'Allowing location access is required to help accurately determine your default currency and timezone. Tap Settings to update permissions.',
dropTitle: 'Let it go',
dropMessage: 'Drop your file here',
flash: 'flash',
@@ -1154,9 +1159,9 @@ export default {
deleteAccount: 'Delete account',
deleteConfirmation: 'Are you sure you want to delete this account?',
error: {
- notOwnerOfBankAccount: 'There was an error setting this bank account as your default payment method.',
+ notOwnerOfBankAccount: 'An error occurred while setting this bank account as your default payment method.',
invalidBankAccount: 'This bank account is temporarily suspended.',
- notOwnerOfFund: 'There was an error setting this card as your default payment method.',
+ notOwnerOfFund: 'An error occurred while setting this card as your default payment method.',
setDefaultFailure: 'Something went wrong. Please chat with Concierge for further assistance.',
},
addBankAccountFailure: 'An unexpected error occurred while trying to add your bank account. Please try again.',
@@ -1464,7 +1469,6 @@ export default {
title: 'What do you want to do today?',
errorSelection: 'Please make a selection to continue.',
errorContinue: 'Please press continue to get set up.',
- errorBackButton: 'Please finish the setup questions to start using the app.',
[CONST.ONBOARDING_CHOICES.EMPLOYER]: 'Get paid back by my employer',
[CONST.ONBOARDING_CHOICES.MANAGE_TEAM]: "Manage my team's expenses",
[CONST.ONBOARDING_CHOICES.PERSONAL_SPEND]: 'Track and budget expenses',
@@ -1620,7 +1624,7 @@ export default {
phrase4: 'verify your account here',
},
hasPhoneLoginError: 'To add a verified bank account please ensure your primary login is a valid email and try again. You can add your phone number as a secondary login.',
- hasBeenThrottledError: 'There was an error adding your bank account. Please wait a few minutes and try again.',
+ hasBeenThrottledError: 'An error occurred while adding your bank account. Please wait a few minutes and try again.',
hasCurrencyError: 'Oops! It appears that your workspace currency is set to a different currency than USD. To proceed, please set it to USD and try again.',
error: {
youNeedToSelectAnOption: 'You need to select an option to proceed.',
@@ -1686,7 +1690,7 @@ export default {
verifyIdentity: 'Verify identity',
letsVerifyIdentity: "Let's verify your identity.",
butFirst: `But first, the boring stuff. Read up on the legalese in the next step and click "Accept" when you're ready.`,
- genericError: 'There was an error while processing this step. Please try again.',
+ genericError: 'An error occurred while processing this step. Please try again.',
cameraPermissionsNotGranted: 'Enable camera access',
cameraRequestMessage: 'We need access to your camera to complete bank account verification. Please enable via Settings > New Expensify.',
microphonePermissionsNotGranted: 'Enable microphone access',
@@ -2666,6 +2670,8 @@ export default {
existingCategoryError: 'A category with this name already exists.',
invalidCategoryName: 'Invalid category name.',
importedFromAccountingSoftware: 'The categories below are imported from your',
+ glCode: 'GL code',
+ updateGLCodeFailureMessage: 'An error occurred while updating the GL code, please try again.',
},
moreFeatures: {
spendSection: {
@@ -2803,6 +2809,8 @@ export default {
existingTagError: 'A tag with this name already exists.',
genericFailureMessage: 'An error occurred while updating the tag, please try again.',
importedFromAccountingSoftware: 'The tags below are imported from your',
+ glCode: 'GL code',
+ updateGLCodeFailureMessage: 'An error occurred while updating the GL code, please try again.',
},
taxes: {
subtitle: 'Add tax names, rates, and set defaults.',
@@ -3175,7 +3183,7 @@ export default {
member: 'Invite member',
members: 'Invite members',
invitePeople: 'Invite new members',
- genericFailureMessage: 'An error occurred inviting the member to the workspace. Please try again.',
+ genericFailureMessage: 'An error occurred while inviting the member to the workspace. Please try again.',
pleaseEnterValidLogin: `Please ensure the email or phone number is valid (e.g. ${CONST.EXAMPLE_PHONE_NUMBER}).`,
user: 'user',
users: 'users',
@@ -3189,7 +3197,7 @@ export default {
inviteMessageTitle: 'Add message',
inviteMessagePrompt: 'Make your invitation extra special by adding a message below',
personalMessagePrompt: 'Message',
- genericFailureMessage: 'An error occurred inviting the member to the workspace. Please try again.',
+ genericFailureMessage: 'An error occurred while inviting the member to the workspace. Please try again.',
inviteNoMembersError: 'Please select at least one member to invite.',
},
distanceRates: {
@@ -3223,7 +3231,7 @@ export default {
currencyInputHelpText: 'All expenses on this workspace will be converted to this currency.',
currencyInputDisabledText: "The default currency can't be changed because this workspace is linked to a USD bank account.",
save: 'Save',
- genericFailureMessage: 'An error occurred updating the workspace. Please try again.',
+ genericFailureMessage: 'An error occurred while updating the workspace. Please try again.',
avatarUploadFailureMessage: 'An error occurred uploading the avatar. Please try again.',
addressContext: 'A Workspace Address is required to enable Expensify Travel. Please enter an address associated with your business.',
},
@@ -3418,7 +3426,7 @@ export default {
},
markAsComplete: 'Mark as complete',
markAsIncomplete: 'Mark as incomplete',
- assigneeError: 'There was an error assigning this task. Please try another assignee.',
+ assigneeError: 'An error occurred while assigning this task. Please try another assignee.',
genericCreateTaskFailureMessage: 'There was an error creating this task. Please try again later.',
deleteTask: 'Delete task',
deleteConfirmation: 'Are you sure you want to delete this task?',
@@ -3444,6 +3452,7 @@ export default {
screenShareRequest: 'Expensify is inviting you to a screen share',
},
search: {
+ selectMultiple: 'Select multiple',
resultsAreLimited: 'Search results are limited.',
searchResults: {
emptyResults: {
@@ -3909,7 +3918,7 @@ export default {
"You appear to be offline. Unfortunately, Expensify Classic doesn't work offline, but New Expensify does. If you prefer to use Expensify Classic, try again when you have an internet connection.",
},
listBoundary: {
- errorMessage: 'There was an error loading more messages.',
+ errorMessage: 'An error occurred while loading more messages.',
tryAgain: 'Try again',
},
systemMessage: {
@@ -3979,6 +3988,10 @@ export default {
title: ({numOfDays}) => `Free trial: ${numOfDays} ${numOfDays === 1 ? 'day' : 'days'} left!`,
subtitle: 'Add a payment card to continue using all of your favorite features.',
},
+ trialEnded: {
+ title: 'Your free trial has ended',
+ subtitle: 'Add a payment card to continue using all of your favorite features.',
+ },
},
cardSection: {
title: 'Payment',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 075903d0f324..75fb44a5c394 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -125,6 +125,7 @@ export default {
yes: 'SĂ',
no: 'No',
ok: 'OK',
+ notNow: 'Ahora no',
learnMore: 'Más información',
buttonConfirm: 'Ok, entendido',
name: 'Nombre',
@@ -372,8 +373,8 @@ export default {
cameraPermissionRequired: 'Permiso para acceder a la cámara',
expensifyDoesntHaveAccessToCamera: 'Expensify no puede tomar fotos sin acceso a la cámara. Haz click en configuración para actualizar los permisos.',
attachmentError: 'Error al adjuntar archivo',
- errorWhileSelectingAttachment: 'Ha ocurrido un error al seleccionar un archivo adjunto. Por favor, inténtalo de nuevo.',
- errorWhileSelectingCorruptedAttachment: 'Ha ocurrido un error al seleccionar un archivo adjunto corrupto. Por favor, inténtalo con otro archivo.',
+ errorWhileSelectingAttachment: 'Se ha producido un error al seleccionar un archivo adjunto. Por favor, inténtalo de nuevo.',
+ errorWhileSelectingCorruptedAttachment: 'Se ha producido un error al seleccionar un archivo adjunto corrupto. Por favor, inténtalo con otro archivo.',
takePhoto: 'Hacer una foto',
chooseFromGallery: 'Elegir de la galerĂa',
chooseDocument: 'Elegir un archivo',
@@ -599,7 +600,7 @@ export default {
saveTheWorld: 'Salvar el mundo',
},
allSettingsScreen: {
- subscriptions: 'Suscripciones',
+ subscription: 'Suscripcion',
cardsAndDomains: 'Tarjetas y Dominios',
},
tabSelector: {
@@ -618,7 +619,13 @@ export default {
takePhoto: 'Haz una foto',
cameraAccess: 'Se requiere acceso a la cámara para hacer fotos de los recibos.',
cameraErrorTitle: 'Error en la cámara',
- cameraErrorMessage: 'Se produjo un error al hacer una foto. Por favor, inténtalo de nuevo.',
+ locationAccessTitle: 'Permitir acceso a la ubicaciĂłn',
+ locationAccessMessage:
+ 'Usaremos tu ubicaciĂłn para determinar con precisiĂłn la moneda y zona horaria predeterminadas. Puedes editar el acceso en la configuraciĂłn de tu dispositivo en cualquier momento.',
+ locationErrorTitle: 'Habilitar ubicaciĂłn en la configuraciĂłn',
+ locationErrorMessage:
+ 'Es necesario permitir el acceso a la ubicaciĂłn para ayudar a determinar con precisiĂłn su moneda y zona horaria predeterminadas. Haz click en ConfiguraciĂłn para actualizar los permisos.',
+ cameraErrorMessage: 'Se ha producido un error al hacer una foto. Por favor, inténtalo de nuevo.',
dropTitle: 'Suéltalo',
dropMessage: 'Suelta tu archivo aquĂ',
flash: 'flash',
@@ -900,13 +907,13 @@ export default {
'Este es tu método de contacto predeterminado. Antes de poder eliminarlo, tendrás que elegir otro método de contacto y haz clic en "Establecer como predeterminado".',
removeContactMethod: 'Eliminar método de contacto',
removeAreYouSure: '¿Estás seguro de que quieres eliminar este método de contacto? Esta acción no se puede deshacer.',
- failedNewContact: 'Hubo un error al añadir este método de contacto.',
+ failedNewContact: 'Se ha producido un error al añadir este método de contacto.',
genericFailureMessages: {
requestContactMethodValidateCode: 'No se ha podido enviar un nuevo código mágico. Espera un rato y vuelve a intentarlo.',
validateSecondaryLogin: 'Código mágico incorrecto o no válido. Inténtalo de nuevo o solicita otro código.',
deleteContactMethod: 'No se ha podido eliminar este método de contacto. Por favor, contacta con Concierge para obtener ayuda.',
setDefaultContactMethod: 'No se pudo establecer un nuevo método de contacto predeterminado. Por favor contacta con Concierge para obtener ayuda.',
- addContactMethod: 'Hubo un error al añadir este método de contacto. Por favor, contacta con Concierge para obtener ayuda.',
+ addContactMethod: 'Se ha producido un error al añadir este método de contacto. Por favor, contacta con Concierge para obtener ayuda.',
enteredMethodIsAlreadySubmited: 'El método de contacto ingresado ya existe.',
passwordRequired: 'Se requiere contraseña',
contactMethodRequired: 'Se requiere método de contacto.',
@@ -1125,7 +1132,7 @@ export default {
addressStreet: 'Por favor, introduce una dirección de facturación válida que no sea un apartado postal.',
addressState: 'Por favor, selecciona un estado.',
addressCity: 'Por favor, introduce una ciudad.',
- genericFailureMessage: 'Se produjo un error al añadir tu tarjeta. Por favor, vuelva a intentarlo.',
+ genericFailureMessage: 'Se ha producido un error al añadir tu tarjeta. Por favor, vuelva a intentarlo.',
password: 'Por favor, introduce tu contraseña de Expensify.',
},
},
@@ -1148,7 +1155,7 @@ export default {
addressStreet: 'Por favor, introduce una dirección de facturación válida que no sea un apartado postal.',
addressState: 'Por favor, selecciona un estado.',
addressCity: 'Por favor, introduce una ciudad.',
- genericFailureMessage: 'Se produjo un error al añadir tu tarjeta. Por favor, vuelva a intentarlo.',
+ genericFailureMessage: 'Se ha producido un error al añadir tu tarjeta. Por favor, vuelva a intentarlo.',
password: 'Por favor, introduce tu contraseña de Expensify.',
},
},
@@ -1159,9 +1166,9 @@ export default {
deleteAccount: 'Eliminar cuenta',
deleteConfirmation: '¿Estás seguro de que quieres eliminar esta cuenta?',
error: {
- notOwnerOfBankAccount: 'Ha ocurrido un error al establecer esta cuenta bancaria como método de pago predeterminado.',
+ notOwnerOfBankAccount: 'Se ha producido un error al establecer esta cuenta bancaria como método de pago predeterminado.',
invalidBankAccount: 'Esta cuenta bancaria está temporalmente suspendida.',
- notOwnerOfFund: 'Ha ocurrido un error al establecer esta tarjeta de crédito como método de pago predeterminado.',
+ notOwnerOfFund: 'Se ha producido un error al establecer esta tarjeta de crédito como método de pago predeterminado.',
setDefaultFailure: 'No se ha podido configurar el método de pago.',
},
addBankAccountFailure: 'Ocurrió un error inesperado al intentar añadir la cuenta bancaria. Inténtalo de nuevo.',
@@ -1472,7 +1479,6 @@ export default {
title: '¿Qué quieres hacer hoy?',
errorSelection: 'Por favor selecciona una opciĂłn para continuar.',
errorContinue: 'Por favor, haz click en continuar para configurar tu cuenta.',
- errorBackButton: 'Por favor, finaliza las preguntas de configuraciĂłn para empezar a utilizar la aplicaciĂłn.',
[CONST.ONBOARDING_CHOICES.EMPLOYER]: 'Cobrar de mi empresa',
[CONST.ONBOARDING_CHOICES.MANAGE_TEAM]: 'Gestionar los gastos de mi equipo',
[CONST.ONBOARDING_CHOICES.PERSONAL_SPEND]: 'Controlar y presupuestar gastos',
@@ -1646,7 +1652,7 @@ export default {
},
hasPhoneLoginError:
'Para añadir una cuenta bancaria verificada, asegúrate de que tu nombre de usuario principal sea un correo electrónico válido y vuelve a intentarlo. Puedes añadir tu número de teléfono como nombre de usuario secundario.',
- hasBeenThrottledError: 'Se produjo un error al intentar añadir tu cuenta bancaria. Por favor, espera unos minutos e inténtalo de nuevo.',
+ hasBeenThrottledError: 'Se ha producido un error al intentar añadir tu cuenta bancaria. Por favor, espera unos minutos e inténtalo de nuevo.',
hasCurrencyError:
'¡Ups! Parece que la moneda de tu espacio de trabajo está configurada en una moneda diferente a USD. Para continuar, por favor configúrala en USD e inténtalo nuevamente.',
error: {
@@ -1692,7 +1698,7 @@ export default {
unknownFilename: 'Archivo desconocido',
passwordRequired: 'Por favor, introduce tu contraseña',
passwordIncorrect: 'Contraseña incorrecta. Por favor, inténtalo de nuevo.',
- failedToLoadPDF: 'Hubo un error al intentar cargar el PDF.',
+ failedToLoadPDF: 'Se ha producido un error al intentar cargar el PDF.',
pdfPasswordForm: {
title: 'PDF protegido con contraseña',
infoText: 'Este PDF esta protegido con contraseña.',
@@ -1714,7 +1720,7 @@ export default {
verifyIdentity: 'Verificar identidad',
letsVerifyIdentity: '¡Vamos a verificar tu identidad!',
butFirst: 'Pero primero, lo aburrido. Lee la jerga legal en el siguiente paso y haz clic en "Aceptar" cuando estés listo.',
- genericError: 'Hubo un error al procesar este paso. Inténtalo de nuevo.',
+ genericError: 'Se ha producido un error al procesar este paso. Inténtalo de nuevo.',
cameraPermissionsNotGranted: 'Permiso para acceder a la cámara',
cameraRequestMessage: 'Necesitamos acceso a tu cámara para completar la verificación de tu cuenta de banco. Por favor habilita los permisos en Configuración > Nuevo Expensify.',
microphonePermissionsNotGranted: 'Permiso para acceder al micrĂłfono',
@@ -2712,6 +2718,8 @@ export default {
existingCategoryError: 'Ya existe una categorĂa con este nombre.',
invalidCategoryName: 'Lo nombre de la categorĂa es invalido.',
importedFromAccountingSoftware: 'CategorĂas importadas desde',
+ glCode: 'CĂłdigo GL',
+ updateGLCodeFailureMessage: 'Se produjo un error al actualizar el código GL. Inténtelo nuevamente.',
},
moreFeatures: {
spendSection: {
@@ -2847,8 +2855,10 @@ export default {
deleteFailureMessage: 'Se ha producido un error al intentar eliminar la etiqueta. Por favor, inténtalo más tarde.',
tagRequiredError: 'Lo nombre de la etiqueta es obligatorio.',
existingTagError: 'Ya existe una etiqueta con este nombre.',
- genericFailureMessage: 'Se produjo un error al actualizar la etiqueta. Por favor, inténtelo nuevamente.',
+ genericFailureMessage: 'Se ha producido un error al actualizar la etiqueta. Por favor, inténtelo nuevamente.',
importedFromAccountingSoftware: 'Etiquetas importadas desde',
+ glCode: 'CĂłdigo GL',
+ updateGLCodeFailureMessage: 'Se produjo un error al actualizar el código GL. Por favor, inténtelo nuevamente.',
},
taxes: {
subtitle: 'Añade nombres, tasas y establezca valores por defecto para los impuestos.',
@@ -3222,7 +3232,7 @@ export default {
member: 'Invitar miembros',
members: 'Invitar miembros',
invitePeople: 'Invitar nuevos miembros',
- genericFailureMessage: 'Se produjo un error al invitar al miembro al espacio de trabajo. Vuelva a intentarlo..',
+ genericFailureMessage: 'Se ha producido un error al invitar al miembro al espacio de trabajo. Vuelva a intentarlo..',
pleaseEnterValidLogin: `Asegúrese de que el correo electrónico o el número de teléfono sean válidos (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER}).`,
user: 'miembro',
users: 'miembros',
@@ -3237,7 +3247,7 @@ export default {
inviteMessagePrompt: 'Añadir un mensaje para hacer tu invitación destacar',
personalMessagePrompt: 'Mensaje',
inviteNoMembersError: 'Por favor, selecciona al menos un miembro a invitar.',
- genericFailureMessage: 'Se produjo un error al invitar al miembro al espacio de trabajo. Por favor, vuelva a intentarlo..',
+ genericFailureMessage: 'Se ha producido un error al invitar al miembro al espacio de trabajo. Por favor, vuelva a intentarlo..',
},
distanceRates: {
oopsNotSoFast: 'Ups! No tan rápido...',
@@ -3270,7 +3280,7 @@ export default {
currencyInputHelpText: 'Todas los gastos en este espacio de trabajo serán convertidos a esta moneda.',
currencyInputDisabledText: 'La moneda predeterminada no se puede cambiar porque este espacio de trabajo está vinculado a una cuenta bancaria en USD.',
save: 'Guardar',
- genericFailureMessage: 'Se produjo un error al guardar el espacio de trabajo. Por favor, inténtalo de nuevo.',
+ genericFailureMessage: 'Se ha producido un error al guardar el espacio de trabajo. Por favor, inténtalo de nuevo.',
avatarUploadFailureMessage: 'No se pudo subir el avatar. Por favor, inténtalo de nuevo.',
addressContext: 'Se requiere una direcciĂłn para habilitar Expensify Travel. Por favor, introduce una direcciĂłn asociada con tu negocio.',
},
@@ -3467,7 +3477,7 @@ export default {
},
markAsComplete: 'Marcar como completada',
markAsIncomplete: 'Marcar como incompleta',
- assigneeError: 'Hubo un error al asignar esta tarea. Por favor, inténtalo con otro miembro.',
+ assigneeError: 'Se ha producido un error al asignar esta tarea. Por favor, inténtalo con otro miembro.',
genericCreateTaskFailureMessage: 'Error inesperado al crear el tarea. Por favor, inténtalo más tarde.',
deleteTask: 'Eliminar tarea',
deleteConfirmation: '¿Estás seguro de que quieres eliminar esta tarea?',
@@ -3493,6 +3503,7 @@ export default {
screenShareRequest: 'Expensify te está invitando a compartir la pantalla',
},
search: {
+ selectMultiple: 'Seleccionar mĂşltiples',
resultsAreLimited: 'Los resultados de búsqueda están limitados.',
searchResults: {
emptyResults: {
@@ -4422,7 +4433,7 @@ export default {
'Parece que estás desconectado. Desafortunadamente, Expensify Classic no funciona sin conexiĂłn, pero New Expensify sĂ. Si prefieres utilizar Expensify Classic, intĂ©ntalo de nuevo cuando tengas conexiĂłn a internet.',
},
listBoundary: {
- errorMessage: 'Se produjo un error al cargar más mensajes.',
+ errorMessage: 'Se ha producido un error al cargar más mensajes.',
tryAgain: 'Inténtalo de nuevo',
},
systemMessage: {
@@ -4494,6 +4505,10 @@ export default {
title: ({numOfDays}) => `Prueba gratuita: ¡${numOfDays === 1 ? `queda 1 dĂa` : `quedan ${numOfDays} dĂas`}!`,
subtitle: 'Añade una tarjeta de pago para seguir utilizando tus funciones favoritas.',
},
+ trialEnded: {
+ title: 'Tu prueba gratuita ha terminado',
+ subtitle: 'Añade una tarjeta de pago para seguir utilizando tus funciones favoritas.',
+ },
},
cardSection: {
title: 'Pago',
diff --git a/src/libs/API/parameters/UpdatePolicyCategoryGLCodeParams.ts b/src/libs/API/parameters/UpdatePolicyCategoryGLCodeParams.ts
new file mode 100644
index 000000000000..f5e4d4ab7eca
--- /dev/null
+++ b/src/libs/API/parameters/UpdatePolicyCategoryGLCodeParams.ts
@@ -0,0 +1,7 @@
+type UpdatePolicyCategoryGLCodeParams = {
+ policyID: string;
+ categoryName: string;
+ glCode: string;
+};
+
+export default UpdatePolicyCategoryGLCodeParams;
diff --git a/src/libs/API/parameters/UpdatePolicyTagGLCodeParams.ts b/src/libs/API/parameters/UpdatePolicyTagGLCodeParams.ts
new file mode 100644
index 000000000000..f720864a8f68
--- /dev/null
+++ b/src/libs/API/parameters/UpdatePolicyTagGLCodeParams.ts
@@ -0,0 +1,9 @@
+type UpdatePolicyTagGLCodeParams = {
+ policyID: string;
+ tagListName: string;
+ tagListIndex: number;
+ tagName: string;
+ glCode: string;
+};
+
+export default UpdatePolicyTagGLCodeParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index ff62d9b69ea6..6510be4940d4 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -169,6 +169,7 @@ export type {default as CreateWorkspaceCategoriesParams} from './CreateWorkspace
export type {default as RenameWorkspaceCategoriesParams} from './RenameWorkspaceCategoriesParams';
export type {default as SetWorkspaceRequiresCategoryParams} from './SetWorkspaceRequiresCategoryParams';
export type {default as DeleteWorkspaceCategoriesParams} from './DeleteWorkspaceCategoriesParams';
+export type {default as UpdatePolicyCategoryGLCodeParams} from './UpdatePolicyCategoryGLCodeParams';
export type {default as SetWorkspaceAutoReportingFrequencyParams} from './SetWorkspaceAutoReportingFrequencyParams';
export type {default as SetWorkspaceAutoReportingMonthlyOffsetParams} from './SetWorkspaceAutoReportingMonthlyOffsetParams';
export type {default as SetWorkspaceApprovalModeParams} from './SetWorkspaceApprovalModeParams';
@@ -210,6 +211,7 @@ export type {default as DeletePolicyTaxesParams} from './DeletePolicyTaxesParams
export type {default as UpdatePolicyTaxValueParams} from './UpdatePolicyTaxValueParams';
export type {default as RenamePolicyTagsParams} from './RenamePolicyTagsParams';
export type {default as DeletePolicyTagsParams} from './DeletePolicyTagsParams';
+export type {default as UpdatePolicyTagGLCodeParams} from './UpdatePolicyTagGLCodeParams';
export type {default as AddSubscriptionPaymentCardParams} from './AddSubscriptionPaymentCardParams';
export type {default as SetPolicyCustomTaxNameParams} from './SetPolicyCustomTaxNameParams';
export type {default as SetPolicyForeignCurrencyDefaultParams} from './SetPolicyForeignCurrencyDefaultParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 948ed7f76373..341f7c5033e6 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -130,12 +130,14 @@ const WRITE_COMMANDS = {
CREATE_POLICY_TAG: 'CreatePolicyTag',
RENAME_POLICY_TAG: 'RenamePolicyTag',
SET_WORKSPACE_REQUIRES_CATEGORY: 'SetWorkspaceRequiresCategory',
+ UPDATE_POLICY_CATEGORY_GL_CODE: 'UpdatePolicyCategoryGLCode',
DELETE_WORKSPACE_CATEGORIES: 'DeleteWorkspaceCategories',
DELETE_POLICY_REPORT_FIELD: 'DeletePolicyReportField',
SET_POLICY_TAGS_REQUIRED: 'SetPolicyTagsRequired',
SET_POLICY_REQUIRES_TAG: 'SetPolicyRequiresTag',
RENAME_POLICY_TAG_LIST: 'RenamePolicyTaglist',
DELETE_POLICY_TAGS: 'DeletePolicyTags',
+ UPDATE_POLICY_TAG_GL_CODE: 'UpdatePolicyTagGLCode',
CREATE_TASK: 'CreateTask',
CANCEL_TASK: 'CancelTask',
EDIT_TASK_ASSIGNEE: 'EditTaskAssignee',
@@ -424,12 +426,14 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.RENAME_WORKSPACE_CATEGORY]: Parameters.RenameWorkspaceCategoriesParams;
[WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY]: Parameters.SetWorkspaceRequiresCategoryParams;
[WRITE_COMMANDS.DELETE_WORKSPACE_CATEGORIES]: Parameters.DeleteWorkspaceCategoriesParams;
+ [WRITE_COMMANDS.UPDATE_POLICY_CATEGORY_GL_CODE]: Parameters.UpdatePolicyCategoryGLCodeParams;
[WRITE_COMMANDS.DELETE_POLICY_REPORT_FIELD]: Parameters.DeletePolicyReportField;
[WRITE_COMMANDS.SET_POLICY_REQUIRES_TAG]: Parameters.SetPolicyRequiresTag;
[WRITE_COMMANDS.SET_POLICY_TAGS_REQUIRED]: Parameters.SetPolicyTagsRequired;
[WRITE_COMMANDS.RENAME_POLICY_TAG_LIST]: Parameters.RenamePolicyTaglistParams;
[WRITE_COMMANDS.CREATE_POLICY_TAG]: Parameters.CreatePolicyTagsParams;
[WRITE_COMMANDS.RENAME_POLICY_TAG]: Parameters.RenamePolicyTagsParams;
+ [WRITE_COMMANDS.UPDATE_POLICY_TAG_GL_CODE]: Parameters.UpdatePolicyTagGLCodeParams;
[WRITE_COMMANDS.SET_POLICY_TAGS_ENABLED]: Parameters.SetPolicyTagsEnabled;
[WRITE_COMMANDS.DELETE_POLICY_TAGS]: Parameters.DeletePolicyTagsParams;
[WRITE_COMMANDS.CREATE_TASK]: Parameters.CreateTaskParams;
diff --git a/src/libs/E2E/reactNativeLaunchingTest.ts b/src/libs/E2E/reactNativeLaunchingTest.ts
index f61bee8dae6a..f952998f0aad 100644
--- a/src/libs/E2E/reactNativeLaunchingTest.ts
+++ b/src/libs/E2E/reactNativeLaunchingTest.ts
@@ -36,7 +36,6 @@ const tests: Tests = {
[E2EConfig.TEST_NAMES.ChatOpening]: require('./tests/chatOpeningTest.e2e').default,
[E2EConfig.TEST_NAMES.ReportTyping]: require('./tests/reportTypingTest.e2e').default,
[E2EConfig.TEST_NAMES.Linking]: require('./tests/linkingTest.e2e').default,
- [E2EConfig.TEST_NAMES.PreloadedLinking]: require('./tests/preloadedLinkingTest.e2e').default,
};
// Once we receive the TII measurement we know that the app is initialized and ready to be used:
diff --git a/src/libs/E2E/tests/preloadedLinkingTest.e2e.ts b/src/libs/E2E/tests/preloadedLinkingTest.e2e.ts
deleted file mode 100644
index a36200b1a702..000000000000
--- a/src/libs/E2E/tests/preloadedLinkingTest.e2e.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import {DeviceEventEmitter} from 'react-native';
-import type {NativeConfig} from 'react-native-config';
-import Config from 'react-native-config';
-import Timing from '@libs/actions/Timing';
-import E2ELogin from '@libs/E2E/actions/e2eLogin';
-import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded';
-import E2EClient from '@libs/E2E/client';
-import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow';
-import getPromiseWithResolve from '@libs/E2E/utils/getPromiseWithResolve';
-import Navigation from '@libs/Navigation/Navigation';
-import Performance from '@libs/Performance';
-import CONST from '@src/CONST';
-import ROUTES from '@src/ROUTES';
-
-type ViewableItem = {
- reportActionID?: string;
-};
-type ViewableItemResponse = Array<{item?: ViewableItem}>;
-
-const test = (config: NativeConfig) => {
- console.debug('[E2E] Logging in for comment linking');
-
- const reportID = getConfigValueOrThrow('reportID', config);
- const linkedReportActionID = getConfigValueOrThrow('linkedReportActionID', config);
-
- E2ELogin().then((neededLogin) => {
- if (neededLogin) {
- return waitForAppLoaded().then(() => E2EClient.submitTestDone());
- }
-
- const [appearMessagePromise, appearMessageResolve] = getPromiseWithResolve();
- const [switchReportPromise, switchReportResolve] = getPromiseWithResolve();
-
- Promise.all([appearMessagePromise, switchReportPromise])
- .then(() => {
- console.debug('[E2E] Test completed successfully, exiting…');
- E2EClient.submitTestDone();
- })
- .catch((err) => {
- console.debug('[E2E] Error while submitting test results:', err);
- });
-
- const subscription = DeviceEventEmitter.addListener('onViewableItemsChanged', (res: ViewableItemResponse) => {
- console.debug('[E2E] Viewable items retrieved, verifying correct message…', res);
- if (!!res && res?.[0]?.item?.reportActionID === linkedReportActionID) {
- appearMessageResolve();
- subscription.remove();
- } else {
- console.debug(`[E2E] Provided message id '${res?.[0]?.item?.reportActionID}' doesn't match to an expected '${linkedReportActionID}'. Waiting for a next one…`);
- }
- });
-
- Performance.subscribeToMeasurements((entry) => {
- if (entry.name === CONST.TIMING.SIDEBAR_LOADED) {
- console.debug('[E2E] Sidebar loaded, navigating to a report…');
- Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID));
- return;
- }
-
- if (entry.name === CONST.TIMING.REPORT_INITIAL_RENDER) {
- console.debug('[E2E] Navigating to linked report action…');
- Timing.start(CONST.TIMING.SWITCH_REPORT);
- Performance.markStart(CONST.TIMING.SWITCH_REPORT);
-
- Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID, linkedReportActionID));
- return;
- }
-
- if (entry.name === CONST.TIMING.CHAT_RENDER) {
- E2EClient.submitTestResults({
- branch: Config.E2E_BRANCH,
- name: 'Comment linking',
- metric: entry.duration,
- });
-
- switchReportResolve();
- }
- });
- });
-};
-
-export default test;
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index ce43c78a6fee..ce4028b87ea8 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -237,6 +237,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/members/WorkspaceOwnerChangeErrorPage').default,
[SCREENS.WORKSPACE.CATEGORY_CREATE]: () => require('../../../../pages/workspace/categories/CreateCategoryPage').default,
[SCREENS.WORKSPACE.CATEGORY_EDIT]: () => require('../../../../pages/workspace/categories/EditCategoryPage').default,
+ [SCREENS.WORKSPACE.CATEGORY_GL_CODE]: () => require('../../../../pages/workspace/categories/CategoryGLCodePage').default,
[SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: () => require('../../../../pages/workspace/distanceRates/CreateDistanceRatePage').default,
[SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage').default,
[SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRateDetailsPage').default,
@@ -250,6 +251,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/tags/WorkspaceEditTagsPage').default,
[SCREENS.WORKSPACE.TAG_CREATE]: () => require('../../../../pages/workspace/tags/WorkspaceCreateTagPage').default,
[SCREENS.WORKSPACE.TAG_EDIT]: () => require('../../../../pages/workspace/tags/EditTagPage').default,
+ [SCREENS.WORKSPACE.TAG_GL_CODE]: () => require('../../../../pages/workspace/tags/TagGLCodePage').default,
[SCREENS.WORKSPACE.TAXES_SETTINGS]: () => require('../../../../pages/workspace/taxes/WorkspaceTaxesSettingsPage').default,
[SCREENS.WORKSPACE.TAXES_SETTINGS_CUSTOM_TAX_NAME]: () => require('../../../../pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName').default,
[SCREENS.WORKSPACE.TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT]: () => require('../../../../pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency').default,
diff --git a/src/libs/Navigation/AppNavigator/Navigators/ActiveBottomTabRouteContext.ts b/src/libs/Navigation/AppNavigator/Navigators/ActiveBottomTabRouteContext.ts
new file mode 100644
index 000000000000..ce55da8e4bde
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/Navigators/ActiveBottomTabRouteContext.ts
@@ -0,0 +1,6 @@
+import React from 'react';
+import type {BottomTabScreensParamList, NavigationPartialRoute} from '@libs/Navigation/types';
+
+const ActiveBottomTabRouteContext = React.createContext | undefined>(undefined);
+
+export default ActiveBottomTabRouteContext;
diff --git a/src/libs/Navigation/AppNavigator/Navigators/ActiveRouteContext.ts b/src/libs/Navigation/AppNavigator/Navigators/ActiveRouteContext.ts
deleted file mode 100644
index c319aeca3e04..000000000000
--- a/src/libs/Navigation/AppNavigator/Navigators/ActiveRouteContext.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import React from 'react';
-import type {AuthScreensParamList, NavigationPartialRoute} from '@libs/Navigation/types';
-
-const ActiveRouteContext = React.createContext | undefined>(undefined);
-
-export default ActiveRouteContext;
diff --git a/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx
index 46212f3bc41f..372c1ce478cc 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx
@@ -2,13 +2,15 @@ import {useNavigationState} from '@react-navigation/native';
import type {StackNavigationOptions} from '@react-navigation/stack';
import React from 'react';
import createCustomBottomTabNavigator from '@libs/Navigation/AppNavigator/createCustomBottomTabNavigator';
+import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute';
import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute';
-import type {BottomTabNavigatorParamList, CentralPaneName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types';
+import type {BottomTabNavigatorParamList, BottomTabScreensParamList, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types';
+import {isBottomTabName} from '@libs/NavigationUtils';
import SidebarScreen from '@pages/home/sidebar/SidebarScreen';
import SearchPageBottomTab from '@pages/Search/SearchPageBottomTab';
import SCREENS from '@src/SCREENS';
import type ReactComponentModule from '@src/types/utils/ReactComponentModule';
-import ActiveRouteContext from './ActiveRouteContext';
+import ActiveBottomTabRouteContext from './ActiveBottomTabRouteContext';
const loadInitialSettingsPage = () => require('../../../../pages/settings/InitialSettingsPage').default;
const Tab = createCustomBottomTabNavigator();
@@ -19,10 +21,22 @@ const screenOptions: StackNavigationOptions = {
};
function BottomTabNavigator() {
- const activeRoute = useNavigationState | undefined>(getTopmostCentralPaneRoute);
+ const activeRoute = useNavigationState | undefined>((state) => {
+ if (!state) {
+ return undefined;
+ }
+ let route: NavigationPartialRoute | undefined;
+ for (const selector of [getTopmostBottomTabRoute, getTopmostCentralPaneRoute]) {
+ const selectedRoute = selector(state);
+ if (isBottomTabName(selectedRoute?.name)) {
+ route = selectedRoute as NavigationPartialRoute;
+ }
+ }
+ return route;
+ });
return (
-
+
-
+
);
}
diff --git a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx
index 61adcd77da76..29a2205b2e37 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx
@@ -4,11 +4,9 @@ import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import NoDropZone from '@components/DragAndDrop/NoDropZone';
import FocusTrapForScreens from '@components/FocusTrap/FocusTrapForScreen';
-import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useOnboardingLayout from '@hooks/useOnboardingLayout';
import useThemeStyles from '@hooks/useThemeStyles';
-import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector';
import OnboardingModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator/OnboardingModalNavigatorScreenOptions';
import Navigation from '@libs/Navigation/Navigation';
import type {OnboardingModalNavigatorParamList} from '@libs/Navigation/types';
@@ -28,11 +26,15 @@ function OnboardingModalNavigator() {
const styles = useThemeStyles();
const {shouldUseNarrowLayout} = useOnboardingLayout();
const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
- selector: hasCompletedGuidedSetupFlowSelector,
+ selector: (onboarding) => {
+ // onboarding is an array for old accounts and accounts created from olddot
+ if (Array.isArray(onboarding)) {
+ return true;
+ }
+ return onboarding?.hasCompletedGuidedSetupFlow;
+ },
});
- useDisableModalDismissOnEscape();
-
useEffect(() => {
if (!hasCompletedGuidedSetupFlow) {
return;
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.website.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.website.tsx
index 2e1c4c012156..556365b473c3 100644
--- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.website.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.website.tsx
@@ -3,10 +3,12 @@ import React, {useCallback, useEffect} from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
+import type {TupleToUnion} from 'type-fest';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import {PressableWithFeedback} from '@components/Pressable';
import Tooltip from '@components/Tooltip';
+import useActiveBottomTabRoute from '@hooks/useActiveBottomTabRoute';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
@@ -15,11 +17,9 @@ import * as Session from '@libs/actions/Session';
import interceptAnonymousUser from '@libs/interceptAnonymousUser';
import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute';
import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute';
-import linkingConfig from '@libs/Navigation/linkingConfig';
-import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath';
-import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
+import Navigation from '@libs/Navigation/Navigation';
import type {RootStackParamList, State} from '@libs/Navigation/types';
-import {isCentralPaneName} from '@libs/NavigationUtils';
+import {isCentralPaneName, isHomeTabName, isSearchTabName, isSettingTabName} from '@libs/NavigationUtils';
import {getChatTabBrickRoad} from '@libs/WorkspacesSettingsUtils';
import BottomTabAvatar from '@pages/home/sidebar/BottomTabAvatar';
import BottomTabBarFloatingActionButton from '@pages/home/sidebar/BottomTabBarFloatingActionButton';
@@ -42,6 +42,7 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps
const styles = useThemeStyles();
const {translate} = useLocalize();
const navigation = useNavigation();
+ const HOME_SCREENS = [SCREENS.HOME, SCREENS.REPORT];
const {activeWorkspaceID: contextActiveWorkspaceID} = useActiveWorkspace();
const activeWorkspaceID = sessionStorage.getItem(CONST.SESSION_STORAGE_KEYS.ACTIVE_WORKSPACE_ID) ?? contextActiveWorkspaceID;
@@ -55,12 +56,7 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps
return;
}
- Welcome.isOnboardingFlowCompleted({
- onNotCompleted: () => {
- const {adaptedState} = getAdaptedStateFromPath(ROUTES.ONBOARDING_ROOT, linkingConfig.config);
- navigationRef.resetRoot(adaptedState);
- },
- });
+ Welcome.isOnboardingFlowCompleted({onNotCompleted: () => Navigation.navigate(ROUTES.ONBOARDING_ROOT)});
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [isLoadingApp]);
@@ -76,6 +72,7 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps
return topmostBottomTabRoute?.name ?? SCREENS.HOME;
});
+ const activeBottomTabRoute = useActiveBottomTabRoute();
const chatTabBrickRoad = getChatTabBrickRoad(activeWorkspaceID);
const navigateToChats = useCallback(() => {
@@ -99,7 +96,7 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps
) ? theme.iconMenu : theme.icon}
width={variables.iconBottomBar}
height={variables.iconBottomBar}
/>
@@ -112,7 +109,7 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps
{
- if (currentTabName === SCREENS.SEARCH.BOTTOM_TAB || currentTabName === SCREENS.SEARCH.CENTRAL_PANE) {
+ if (isSearchTabName(activeBottomTabRoute?.name)) {
return;
}
interceptAnonymousUser(() => Navigation.navigate(ROUTES.SEARCH.getRoute(CONST.SEARCH.TAB.ALL)));
@@ -125,14 +122,14 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps
-
+
diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts
index 5b3cefb63a2d..a1768df5e0d6 100644
--- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts
+++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts
@@ -1,16 +1,13 @@
-import type {CommonActions, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native';
-import {findFocusedRoute, getPathFromState, StackRouter} from '@react-navigation/native';
+import type {RouterConfigOptions, StackNavigationState} from '@react-navigation/native';
+import {getPathFromState, StackRouter} from '@react-navigation/native';
import type {ParamListBase} from '@react-navigation/routers';
import getIsNarrowLayout from '@libs/getIsNarrowLayout';
-import * as Localize from '@libs/Localize';
import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute';
import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute';
import linkingConfig from '@libs/Navigation/linkingConfig';
import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath';
import type {NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types';
-import {isCentralPaneName, isOnboardingFlowName} from '@libs/NavigationUtils';
-import * as Welcome from '@userActions/Welcome';
-import CONST from '@src/CONST';
+import {isCentralPaneName} from '@libs/NavigationUtils';
import NAVIGATORS from '@src/NAVIGATORS';
import SCREENS from '@src/SCREENS';
import type {ResponsiveStackNavigatorRouterOptions} from './types';
@@ -100,23 +97,6 @@ function compareAndAdaptState(state: StackNavigationState) {
}
}
-function shouldPreventReset(state: StackNavigationState, action: CommonActions.Action | StackActionType) {
- if (action.type !== CONST.NAVIGATION_ACTIONS.RESET || !action?.payload) {
- return false;
- }
- const currentFocusedRoute = findFocusedRoute(state);
- const targetFocusedRoute = findFocusedRoute(action?.payload);
-
- // We want to prevent the user from navigating back to a non-onboarding screen if they are currently on an onboarding screen
- if (isOnboardingFlowName(currentFocusedRoute?.name) && !isOnboardingFlowName(targetFocusedRoute?.name)) {
- Welcome.setOnboardingErrorMessage(Localize.translateLocal('onboarding.purpose.errorBackButton'));
- // We reset the URL as the browser sets it in a way that doesn't match the navigation state
- // eslint-disable-next-line no-restricted-globals
- history.replaceState({}, '', getPathFromState(state, linkingConfig.config));
- return true;
- }
-}
-
function CustomRouter(options: ResponsiveStackNavigatorRouterOptions) {
const stackRouter = StackRouter(options);
@@ -127,12 +107,6 @@ function CustomRouter(options: ResponsiveStackNavigatorRouterOptions) {
const state = stackRouter.getRehydratedState(partialState, {routeNames, routeParamList, routeGetIdList});
return state;
},
- getStateForAction(state: StackNavigationState, action: CommonActions.Action | StackActionType, configOptions: RouterConfigOptions) {
- if (shouldPreventReset(state, action)) {
- return state;
- }
- return stackRouter.getStateForAction(state, action, configOptions);
- },
};
}
diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx
index a225831b56ff..63792be4f79f 100644
--- a/src/libs/Navigation/NavigationRoot.tsx
+++ b/src/libs/Navigation/NavigationRoot.tsx
@@ -1,7 +1,6 @@
import type {NavigationState} from '@react-navigation/native';
import {DefaultTheme, findFocusedRoute, NavigationContainer} from '@react-navigation/native';
import React, {useContext, useEffect, useMemo, useRef} from 'react';
-import {useOnyx} from 'react-native-onyx';
import HybridAppMiddleware from '@components/HybridAppMiddleware';
import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
@@ -9,14 +8,11 @@ import useCurrentReportID from '@hooks/useCurrentReportID';
import useTheme from '@hooks/useTheme';
import useWindowDimensions from '@hooks/useWindowDimensions';
import {FSPage} from '@libs/Fullstory';
-import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector';
import Log from '@libs/Log';
import {getPathFromURL} from '@libs/Url';
import {updateLastVisitedPath} from '@userActions/App';
import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
import type {Route} from '@src/ROUTES';
-import ROUTES from '@src/ROUTES';
import AppNavigator from './AppNavigator';
import getPolicyIDFromState from './getPolicyIDFromState';
import linkingConfig from './linkingConfig';
@@ -81,37 +77,25 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
const {isSmallScreenWidth} = useWindowDimensions();
const {setActiveWorkspaceID} = useActiveWorkspace();
- const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
- selector: hasCompletedGuidedSetupFlowSelector,
- });
+ const initialState = useMemo(
+ () => {
+ if (!lastVisitedPath) {
+ return undefined;
+ }
- const initialState = useMemo(() => {
- // If the user haven't completed the flow, we want to always redirect them to the onboarding flow.
- if (!hasCompletedGuidedSetupFlow) {
- const {adaptedState} = getAdaptedStateFromPath(ROUTES.ONBOARDING_ROOT, linkingConfig.config);
- return adaptedState;
- }
-
- // If there is no lastVisitedPath, we can do early return. We won't modify the default behavior.
- if (!lastVisitedPath) {
- return undefined;
- }
-
- const path = initialUrl ? getPathFromURL(initialUrl) : null;
+ const path = initialUrl ? getPathFromURL(initialUrl) : null;
- // If the user opens the root of app "/" it will be parsed to empty string "".
- // If the path is defined and different that empty string we don't want to modify the default behavior.
- if (path) {
- return;
- }
-
- // Otherwise we want to redirect the user to the last visited path.
- const {adaptedState} = getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config);
- return adaptedState;
+ // For non-nullable paths we don't want to set initial state
+ if (path) {
+ return;
+ }
- // The initialState value is relevant only on the first render.
+ const {adaptedState} = getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config);
+ return adaptedState;
+ },
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- }, []);
+ [],
+ );
// https://reactnavigation.org/docs/themes
const navigationTheme = useMemo(
diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
index 31b44a2681fd..0833d6c9f195 100755
--- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
@@ -132,8 +132,15 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
SCREENS.WORKSPACE.TAG_SETTINGS,
SCREENS.WORKSPACE.TAG_EDIT,
SCREENS.WORKSPACE.TAG_LIST_VIEW,
+ SCREENS.WORKSPACE.TAG_GL_CODE,
+ ],
+ [SCREENS.WORKSPACE.CATEGORIES]: [
+ SCREENS.WORKSPACE.CATEGORY_CREATE,
+ SCREENS.WORKSPACE.CATEGORY_SETTINGS,
+ SCREENS.WORKSPACE.CATEGORIES_SETTINGS,
+ SCREENS.WORKSPACE.CATEGORY_EDIT,
+ SCREENS.WORKSPACE.CATEGORY_GL_CODE,
],
- [SCREENS.WORKSPACE.CATEGORIES]: [SCREENS.WORKSPACE.CATEGORY_CREATE, SCREENS.WORKSPACE.CATEGORY_SETTINGS, SCREENS.WORKSPACE.CATEGORIES_SETTINGS, SCREENS.WORKSPACE.CATEGORY_EDIT],
[SCREENS.WORKSPACE.DISTANCE_RATES]: [
SCREENS.WORKSPACE.CREATE_DISTANCE_RATE,
SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS,
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 5262850d4e81..9d5f1355b74c 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -520,6 +520,12 @@ const config: LinkingOptions['config'] = {
categoryName: (categoryName: string) => decodeURIComponent(categoryName),
},
},
+ [SCREENS.WORKSPACE.CATEGORY_GL_CODE]: {
+ path: ROUTES.WORKSPACE_CATEGORY_GL_CODE.route,
+ parse: {
+ categoryName: (categoryName: string) => decodeURIComponent(categoryName),
+ },
+ },
[SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: {
path: ROUTES.WORKSPACE_CREATE_DISTANCE_RATE.route,
},
@@ -557,6 +563,13 @@ const config: LinkingOptions['config'] = {
tagName: (tagName: string) => decodeURIComponent(tagName),
},
},
+ [SCREENS.WORKSPACE.TAG_GL_CODE]: {
+ path: ROUTES.WORKSPACE_TAG_GL_CODE.route,
+ parse: {
+ orderWeight: Number,
+ tagName: (tagName: string) => decodeURIComponent(tagName),
+ },
+ },
[SCREENS.WORKSPACE.TAG_SETTINGS]: {
path: ROUTES.WORKSPACE_TAG_SETTINGS.route,
parse: {
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 00fd98dc51aa..a9bc53b01a2c 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -79,6 +79,12 @@ type CentralPaneScreensParamList = {
[SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: undefined;
};
+type SearchNavigatorParamList = {
+ [SCREENS.SEARCH.BOTTOM_TAB]: undefined;
+ [SCREENS.SEARCH.CENTRAL_PANE]: undefined;
+ [SCREENS.SEARCH.REPORT_RHP]: undefined;
+};
+
type SettingsNavigatorParamList = {
[SCREENS.SETTINGS.SHARE_CODE]: undefined;
[SCREENS.SETTINGS.PROFILE.ROOT]: undefined;
@@ -201,6 +207,10 @@ type SettingsNavigatorParamList = {
categoryName: string;
backTo?: Routes;
};
+ [SCREENS.WORKSPACE.CATEGORY_GL_CODE]: {
+ policyID: string;
+ categoryName: string;
+ };
[SCREENS.WORKSPACE.CATEGORY_SETTINGS]: {
policyID: string;
categoryName: string;
@@ -255,6 +265,11 @@ type SettingsNavigatorParamList = {
orderWeight: number;
tagName: string;
};
+ [SCREENS.WORKSPACE.TAG_GL_CODE]: {
+ policyID: string;
+ orderWeight: number;
+ tagName: string;
+ };
[SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: undefined;
[SCREENS.SETTINGS.SUBSCRIPTION.SIZE]: {
canChangeSize: 0 | 1;
@@ -1110,6 +1125,8 @@ type ExplanationModalNavigatorParamList = {
[SCREENS.EXPLANATION_MODAL.ROOT]: undefined;
};
+type BottomTabScreensParamList = {[SCREENS.HOME]: undefined; [SCREENS.REPORT]: undefined} & SearchNavigatorParamList & SettingsNavigatorParamList;
+
type BottomTabNavigatorParamList = {
[SCREENS.HOME]: {policyID?: string};
[SCREENS.SEARCH.BOTTOM_TAB]: {
@@ -1208,12 +1225,12 @@ type RootStackParamList = PublicScreensParamList & AuthScreensParamList & LeftMo
type BottomTabName = keyof BottomTabNavigatorParamList;
+type BottomTabScreenName = keyof BottomTabScreensParamList;
+
type FullScreenName = keyof FullScreenNavigatorParamList;
type CentralPaneName = keyof CentralPaneScreensParamList;
-type OnboardingFlowName = keyof OnboardingModalNavigatorParamList;
-
type SwitchPolicyIDParams = {
policyID?: string;
route?: Routes;
@@ -1227,6 +1244,8 @@ export type {
CentralPaneName,
BackToParams,
BottomTabName,
+ BottomTabScreenName,
+ BottomTabScreensParamList,
BottomTabNavigatorParamList,
DetailsNavigatorParamList,
EditRequestNavigatorParamList,
@@ -1244,7 +1263,6 @@ export type {
NewChatNavigatorParamList,
NewTaskNavigatorParamList,
OnboardingModalNavigatorParamList,
- OnboardingFlowName,
ParticipantsNavigatorParamList,
PrivateNotesNavigatorParamList,
ProfileNavigatorParamList,
@@ -1258,6 +1276,7 @@ export type {
RoomInviteNavigatorParamList,
RoomMembersNavigatorParamList,
RootStackParamList,
+ SearchNavigatorParamList,
SettingsNavigatorParamList,
SignInNavigatorParamList,
FeatureTrainingNavigatorParamList,
diff --git a/src/libs/NavigationUtils.ts b/src/libs/NavigationUtils.ts
index aa26268977a2..34e9df954688 100644
--- a/src/libs/NavigationUtils.ts
+++ b/src/libs/NavigationUtils.ts
@@ -1,7 +1,9 @@
import cloneDeep from 'lodash/cloneDeep';
+import type {TupleToUnion} from 'type-fest';
+import {flattenObject} from '@src/languages/translations';
import SCREENS from '@src/SCREENS';
import getTopmostBottomTabRoute from './Navigation/getTopmostBottomTabRoute';
-import type {CentralPaneName, OnboardingFlowName, RootStackParamList, State} from './Navigation/types';
+import type {CentralPaneName, RootStackParamList, State} from './Navigation/types';
const CENTRAL_PANE_SCREEN_NAMES = new Set([
SCREENS.SETTINGS.WORKSPACES,
@@ -17,8 +19,6 @@ const CENTRAL_PANE_SCREEN_NAMES = new Set([
SCREENS.REPORT,
]);
-const ONBOARDING_SCREEN_NAMES = new Set([SCREENS.ONBOARDING.PERSONAL_DETAILS, SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK, SCREENS.ONBOARDING_MODAL.ONBOARDING]);
-
function isCentralPaneName(screen: string | undefined): screen is CentralPaneName {
if (!screen) {
return false;
@@ -27,14 +27,6 @@ function isCentralPaneName(screen: string | undefined): screen is CentralPaneNam
return CENTRAL_PANE_SCREEN_NAMES.has(screen as CentralPaneName);
}
-function isOnboardingFlowName(screen: string | undefined): screen is OnboardingFlowName {
- if (!screen) {
- return false;
- }
-
- return ONBOARDING_SCREEN_NAMES.has(screen as OnboardingFlowName);
-}
-
const removePolicyIDParamFromState = (state: State) => {
const stateCopy = cloneDeep(state);
const bottomTabRoute = getTopmostBottomTabRoute(stateCopy);
@@ -44,4 +36,43 @@ const removePolicyIDParamFromState = (state: State) => {
return stateCopy;
};
-export {isCentralPaneName, removePolicyIDParamFromState, isOnboardingFlowName};
+const SETTINGS_SCREENS = Object.values(flattenObject(SCREENS.SETTINGS));
+const SEARCH_SCREENS = Object.values(flattenObject(SCREENS.SEARCH));
+const HOME_SCREENS = [SCREENS.HOME, SCREENS.REPORT];
+const BOTTOM_TAB_SCREEN_NAMES = new Set([...SETTINGS_SCREENS, ...SEARCH_SCREENS, ...HOME_SCREENS]);
+
+const SETTINGS_TAB_SCREEN_NAMES = new Set(SETTINGS_SCREENS);
+
+const SEARCH_TAB_SCREEN_NAMES = new Set(SEARCH_SCREENS);
+
+const HOME_SCREEN_NAMES = new Set(HOME_SCREENS);
+
+function isBottomTabName(screen: TupleToUnion | undefined) {
+ if (!screen) {
+ return false;
+ }
+ return BOTTOM_TAB_SCREEN_NAMES.has(screen);
+}
+
+function isSettingTabName(screen: TupleToUnion | undefined) {
+ if (!screen) {
+ return false;
+ }
+ return SETTINGS_TAB_SCREEN_NAMES.has(screen);
+}
+
+function isSearchTabName(screen: TupleToUnion | undefined) {
+ if (!screen) {
+ return false;
+ }
+ return SEARCH_TAB_SCREEN_NAMES.has(screen);
+}
+
+function isHomeTabName(screen: TupleToUnion | undefined) {
+ if (!screen) {
+ return false;
+ }
+ return HOME_SCREEN_NAMES.has(screen);
+}
+
+export {isCentralPaneName, isBottomTabName, isSearchTabName, isSettingTabName, isHomeTabName, removePolicyIDParamFromState};
diff --git a/src/libs/NetworkConnection.ts b/src/libs/NetworkConnection.ts
index acdb982c729a..bc1b82b21bc4 100644
--- a/src/libs/NetworkConnection.ts
+++ b/src/libs/NetworkConnection.ts
@@ -11,7 +11,6 @@ import AppStateMonitor from './AppStateMonitor';
import Log from './Log';
let isOffline = false;
-let hasPendingNetworkCheck = false;
type NetworkStatus = ValueOf;
type ResponseJSON = {
@@ -191,13 +190,8 @@ function clearReconnectionCallbacks() {
* Refresh NetInfo state.
*/
function recheckNetworkConnection() {
- if (hasPendingNetworkCheck) {
- return;
- }
-
Log.info('[NetworkConnection] recheck NetInfo');
- hasPendingNetworkCheck = true;
- NetInfo.refresh().finally(() => (hasPendingNetworkCheck = false));
+ NetInfo.refresh();
}
export default {
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 73b04742878a..97c9a0965f1d 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -725,6 +725,8 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails
lastMessageTextFromReport = ReportUtils.getIOUSubmittedMessage(reportID);
} else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.APPROVED) {
lastMessageTextFromReport = ReportUtils.getIOUApprovedMessage(reportID);
+ } else if (ReportActionUtils.isActionableAddPaymentCard(lastReportAction)) {
+ lastMessageTextFromReport = ReportActionUtils.getReportActionMessageText(lastReportAction);
}
return lastMessageTextFromReport || (report?.lastMessageText ?? '');
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index 140916349c53..cccf477d5550 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -318,6 +318,10 @@ function isPaidGroupPolicy(policy: OnyxEntry): boolean {
return policy?.type === CONST.POLICY.TYPE.TEAM || policy?.type === CONST.POLICY.TYPE.CORPORATE;
}
+function isControlPolicy(policy: OnyxEntry): boolean {
+ return policy?.type === CONST.POLICY.TYPE.CORPORATE;
+}
+
function isTaxTrackingEnabled(isPolicyExpenseChat: boolean, policy: OnyxEntry, isDistanceRequest: boolean): boolean {
const distanceUnit = getCustomUnit(policy);
const customUnitID = distanceUnit?.customUnitID ?? 0;
@@ -806,6 +810,7 @@ export {
getIntegrationLastSuccessfulDate,
getCurrentConnectionName,
getCustomersOrJobsLabelNetSuite,
+ isControlPolicy,
isNetSuiteCustomSegmentRecord,
getNameFromNetSuiteCustomField,
isNetSuiteCustomFieldPropertyEditable,
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index 85850c15e534..01ca1c46b1fa 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -1408,6 +1408,14 @@ function getTrackExpenseActionableWhisper(transactionID: string, chatReportID: s
return Object.values(chatReportActions).find((action: ReportAction) => isActionableTrackExpense(action) && getOriginalMessage(action)?.transactionID === transactionID);
}
+/**
+ * Checks if a given report action corresponds to a add payment card action.
+ * @param reportAction
+ */
+function isActionableAddPaymentCard(reportAction: OnyxEntry): reportAction is ReportAction {
+ return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_ADD_PAYMENT_CARD;
+}
+
export {
extractLinksFromMessageHtml,
getDismissedViolationMessageText,
@@ -1494,6 +1502,7 @@ export {
isTripPreview,
getIOUActionForReportID,
getFilteredForOneTransactionView,
+ isActionableAddPaymentCard,
};
export type {LastVisibleMessage};
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index afe384b87531..9fe81b7de5b1 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -40,6 +40,7 @@ import type {
UserWallet,
} from '@src/types/onyx';
import type {Participant} from '@src/types/onyx/IOU';
+import type Onboarding from '@src/types/onyx/Onboarding';
import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon';
import type {OriginalMessageChangeLog, PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import type {Status} from '@src/types/onyx/PersonalDetails';
@@ -52,6 +53,7 @@ import AccountUtils from './AccountUtils';
import * as IOU from './actions/IOU';
import * as PolicyActions from './actions/Policy/Policy';
import * as store from './actions/ReimbursementAccount/store';
+import * as SessionUtils from './actions/Session';
import * as CurrencyUtils from './CurrencyUtils';
import DateUtils from './DateUtils';
import {hasValidDraftComment} from './DraftCommentUtils';
@@ -437,6 +439,7 @@ type OptionData = {
shouldShowAmountInput?: boolean;
amountInputProps?: MoneyRequestAmountInputProps;
tabIndex?: 0 | -1;
+ isConciergeChat?: boolean;
} & Report;
type OnyxDataTaskAssigneeChat = {
@@ -571,6 +574,12 @@ Onyx.connect({
},
});
+let onboarding: OnyxEntry;
+Onyx.connect({
+ key: ONYXKEYS.NVP_ONBOARDING,
+ callback: (value) => (onboarding = value),
+});
+
function getCurrentUserAvatar(): AvatarSource | undefined {
return currentUserPersonalDetails?.avatar;
}
@@ -607,6 +616,13 @@ function isDraftReport(reportID: string | undefined): boolean {
return !!draftReport;
}
+/**
+ * Returns the report
+ */
+function getReport(reportID: string): OnyxEntry {
+ return ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
+}
+
/**
* Returns the parentReport if the given report is a thread
*/
@@ -1471,10 +1487,10 @@ function isOneTransactionReport(reportID: string): boolean {
/**
* Checks if a report is a transaction thread associated with a report that has only one transaction
*/
-function isOneTransactionThread(reportID: string, parentReportID: string): boolean {
+function isOneTransactionThread(reportID: string, parentReportID: string, threadParentReportAction: OnyxEntry): boolean {
const parentReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`] ?? ([] as ReportAction[]);
const transactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(parentReportID, parentReportActions);
- return reportID === transactionThreadReportID;
+ return reportID === transactionThreadReportID && !ReportActionsUtils.isSentMoneyReportAction(threadParentReportAction);
}
/**
@@ -2829,7 +2845,9 @@ function canHoldUnholdReportAction(reportAction: OnyxInputOrEntry)
const canHoldOrUnholdRequest = !isRequestSettled && !isApproved && !isDeletedParentAction;
const canHoldRequest = canHoldOrUnholdRequest && !isOnHold && (isRequestHoldCreator || (!isRequestIOU && canModifyStatus)) && !isScanning && !!transaction?.reimbursable;
- const canUnholdRequest = !!(canHoldOrUnholdRequest && isOnHold && (isRequestHoldCreator || (!isRequestIOU && canModifyStatus))) && !!transaction?.reimbursable;
+ const canUnholdRequest =
+ !!(canHoldOrUnholdRequest && isOnHold && !TransactionUtils.isDuplicate(transaction.transactionID, true) && (isRequestHoldCreator || (!isRequestIOU && canModifyStatus))) &&
+ !!transaction?.reimbursable;
return {canHoldRequest, canUnholdRequest};
}
@@ -5425,6 +5443,8 @@ function shouldReportBeInOptionList({
// This can also happen for anyone accessing a public room or archived room for which they don't have access to the underlying policy.
// Optionally exclude reports that do not belong to currently active workspace
+ const parentReportAction = ReportActionsUtils.getParentReportAction(report);
+
if (
!report?.reportID ||
!report?.type ||
@@ -5455,7 +5475,7 @@ function shouldReportBeInOptionList({
}
// If this is a transaction thread associated with a report that only has one transaction, omit it
- if (isOneTransactionThread(report.reportID, report.parentReportID ?? '-1')) {
+ if (isOneTransactionThread(report.reportID, report.parentReportID ?? '-1', parentReportAction)) {
return false;
}
@@ -5528,8 +5548,6 @@ function shouldReportBeInOptionList({
return false;
}
- const parentReportAction = ReportActionsUtils.getParentReportAction(report);
-
// Hide chat threads where the parent message is pending removal
if (
!isEmptyObject(parentReportAction) &&
@@ -6934,6 +6952,10 @@ function canJoinChat(report: OnyxInputOrEntry, parentReportAction: OnyxI
* Whether the user can leave a report
*/
function canLeaveChat(report: OnyxEntry, policy: OnyxEntry): boolean {
+ if (isPublicRoom(report) && SessionUtils.isAnonymousUser()) {
+ return false;
+ }
+
if (report?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) {
return false;
}
@@ -7072,10 +7094,18 @@ function shouldShowMerchantColumn(transactions: Transaction[]) {
}
/**
- * Whether the report is a system chat or concierge chat, depending on the user's account ID (used for A/B testing purposes).
+ * Whether the report is a system chat or concierge chat, depending on the onboarding report ID or fallbacking
+ * to the user's account ID (used for A/B testing purposes).
*/
-function isChatUsedForOnboarding(report: OnyxEntry): boolean {
- return AccountUtils.isAccountIDOddNumber(currentUserAccountID ?? -1) ? isSystemChat(report) : isConciergeChatReport(report);
+function isChatUsedForOnboarding(optionOrReport: OnyxEntry | OptionData): boolean {
+ // onboarding can be an array for old accounts and accounts created from olddot
+ if (!Array.isArray(onboarding) && onboarding?.chatReportID === optionOrReport?.reportID) {
+ return true;
+ }
+
+ return AccountUtils.isAccountIDOddNumber(currentUserAccountID ?? -1)
+ ? isSystemChat(optionOrReport)
+ : (optionOrReport as OptionData).isConciergeChat ?? isConciergeChatReport(optionOrReport);
}
/**
@@ -7370,6 +7400,7 @@ export {
findPolicyExpenseChatByPolicyID,
hasOnlyNonReimbursableTransactions,
getMostRecentlyVisitedReport,
+ getReport,
};
export type {
diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js
new file mode 100644
index 000000000000..28143edb40f7
--- /dev/null
+++ b/src/libs/SearchParser/searchParser.js
@@ -0,0 +1,1161 @@
+// @generated by Peggy 4.0.3.
+//
+// https://peggyjs.org/
+
+
+function peg$subclass(child, parent) {
+ function C() { this.constructor = child; }
+ C.prototype = parent.prototype;
+ child.prototype = new C();
+}
+
+function peg$SyntaxError(message, expected, found, location) {
+ var self = Error.call(this, message);
+ // istanbul ignore next Check is a necessary evil to support older environments
+ if (Object.setPrototypeOf) {
+ Object.setPrototypeOf(self, peg$SyntaxError.prototype);
+ }
+ self.expected = expected;
+ self.found = found;
+ self.location = location;
+ self.name = "SyntaxError";
+ return self;
+}
+
+peg$subclass(peg$SyntaxError, Error);
+
+function peg$padEnd(str, targetLength, padString) {
+ padString = padString || " ";
+ if (str.length > targetLength) { return str; }
+ targetLength -= str.length;
+ padString += padString.repeat(targetLength);
+ return str + padString.slice(0, targetLength);
+}
+
+peg$SyntaxError.prototype.format = function(sources) {
+ var str = "Error: " + this.message;
+ if (this.location) {
+ var src = null;
+ var k;
+ for (k = 0; k < sources.length; k++) {
+ if (sources[k].source === this.location.source) {
+ src = sources[k].text.split(/\r\n|\n|\r/g);
+ break;
+ }
+ }
+ var s = this.location.start;
+ var offset_s = (this.location.source && (typeof this.location.source.offset === "function"))
+ ? this.location.source.offset(s)
+ : s;
+ var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column;
+ if (src) {
+ var e = this.location.end;
+ var filler = peg$padEnd("", offset_s.line.toString().length, ' ');
+ var line = src[s.line - 1];
+ var last = s.line === e.line ? e.column : line.length + 1;
+ var hatLen = (last - s.column) || 1;
+ str += "\n --> " + loc + "\n"
+ + filler + " |\n"
+ + offset_s.line + " | " + line + "\n"
+ + filler + " | " + peg$padEnd("", s.column - 1, ' ')
+ + peg$padEnd("", hatLen, "^");
+ } else {
+ str += "\n at " + loc;
+ }
+ }
+ return str;
+};
+
+peg$SyntaxError.buildMessage = function(expected, found) {
+ var DESCRIBE_EXPECTATION_FNS = {
+ literal: function(expectation) {
+ return "\"" + literalEscape(expectation.text) + "\"";
+ },
+
+ class: function(expectation) {
+ var escapedParts = expectation.parts.map(function(part) {
+ return Array.isArray(part)
+ ? classEscape(part[0]) + "-" + classEscape(part[1])
+ : classEscape(part);
+ });
+
+ return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]";
+ },
+
+ any: function() {
+ return "any character";
+ },
+
+ end: function() {
+ return "end of input";
+ },
+
+ other: function(expectation) {
+ return expectation.description;
+ }
+ };
+
+ function hex(ch) {
+ return ch.charCodeAt(0).toString(16).toUpperCase();
+ }
+
+ function literalEscape(s) {
+ return s
+ .replace(/\\/g, "\\\\")
+ .replace(/"/g, "\\\"")
+ .replace(/\0/g, "\\0")
+ .replace(/\t/g, "\\t")
+ .replace(/\n/g, "\\n")
+ .replace(/\r/g, "\\r")
+ .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); })
+ .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); });
+ }
+
+ function classEscape(s) {
+ return s
+ .replace(/\\/g, "\\\\")
+ .replace(/\]/g, "\\]")
+ .replace(/\^/g, "\\^")
+ .replace(/-/g, "\\-")
+ .replace(/\0/g, "\\0")
+ .replace(/\t/g, "\\t")
+ .replace(/\n/g, "\\n")
+ .replace(/\r/g, "\\r")
+ .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); })
+ .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); });
+ }
+
+ function describeExpectation(expectation) {
+ return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation);
+ }
+
+ function describeExpected(expected) {
+ var descriptions = expected.map(describeExpectation);
+ var i, j;
+
+ descriptions.sort();
+
+ if (descriptions.length > 0) {
+ for (i = 1, j = 1; i < descriptions.length; i++) {
+ if (descriptions[i - 1] !== descriptions[i]) {
+ descriptions[j] = descriptions[i];
+ j++;
+ }
+ }
+ descriptions.length = j;
+ }
+
+ switch (descriptions.length) {
+ case 1:
+ return descriptions[0];
+
+ case 2:
+ return descriptions[0] + " or " + descriptions[1];
+
+ default:
+ return descriptions.slice(0, -1).join(", ")
+ + ", or "
+ + descriptions[descriptions.length - 1];
+ }
+ }
+
+ function describeFound(found) {
+ return found ? "\"" + literalEscape(found) + "\"" : "end of input";
+ }
+
+ return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found.";
+};
+
+function peg$parse(input, options) {
+ options = options !== undefined ? options : {};
+
+ var peg$FAILED = {};
+ var peg$source = options.grammarSource;
+
+ var peg$startRuleFunctions = { query: peg$parsequery };
+ var peg$startRuleFunction = peg$parsequery;
+
+ var peg$c0 = "!=";
+ var peg$c1 = ">";
+ var peg$c2 = ">=";
+ var peg$c3 = "<";
+ var peg$c4 = "<=";
+ var peg$c5 = "type";
+ var peg$c6 = "status";
+ var peg$c7 = "date";
+ var peg$c8 = "amount";
+ var peg$c9 = "expenseType";
+ var peg$c10 = "in";
+ var peg$c11 = "currency";
+ var peg$c12 = "merchant";
+ var peg$c13 = "description";
+ var peg$c14 = "from";
+ var peg$c15 = "to";
+ var peg$c16 = "category";
+ var peg$c17 = "tag";
+ var peg$c18 = "taxRate";
+ var peg$c19 = "card";
+ var peg$c20 = "reportID";
+ var peg$c21 = "keyword";
+ var peg$c22 = "sortBy";
+ var peg$c23 = "sortOrder";
+ var peg$c24 = "offset";
+ var peg$c25 = "\"";
+
+ var peg$r0 = /^[:=]/;
+ var peg$r1 = /^[^"\r\n]/;
+ var peg$r2 = /^[A-Za-z0-9_@.\/#&+\-\\',]/;
+ var peg$r3 = /^[ \t\r\n]/;
+
+ var peg$e0 = peg$classExpectation([":", "="], false, false);
+ var peg$e1 = peg$literalExpectation("!=", false);
+ var peg$e2 = peg$literalExpectation(">", false);
+ var peg$e3 = peg$literalExpectation(">=", false);
+ var peg$e4 = peg$literalExpectation("<", false);
+ var peg$e5 = peg$literalExpectation("<=", false);
+ var peg$e6 = peg$literalExpectation("type", false);
+ var peg$e7 = peg$literalExpectation("status", false);
+ var peg$e8 = peg$literalExpectation("date", false);
+ var peg$e9 = peg$literalExpectation("amount", false);
+ var peg$e10 = peg$literalExpectation("expenseType", false);
+ var peg$e11 = peg$literalExpectation("in", false);
+ var peg$e12 = peg$literalExpectation("currency", false);
+ var peg$e13 = peg$literalExpectation("merchant", false);
+ var peg$e14 = peg$literalExpectation("description", false);
+ var peg$e15 = peg$literalExpectation("from", false);
+ var peg$e16 = peg$literalExpectation("to", false);
+ var peg$e17 = peg$literalExpectation("category", false);
+ var peg$e18 = peg$literalExpectation("tag", false);
+ var peg$e19 = peg$literalExpectation("taxRate", false);
+ var peg$e20 = peg$literalExpectation("card", false);
+ var peg$e21 = peg$literalExpectation("reportID", false);
+ var peg$e22 = peg$literalExpectation("keyword", false);
+ var peg$e23 = peg$literalExpectation("sortBy", false);
+ var peg$e24 = peg$literalExpectation("sortOrder", false);
+ var peg$e25 = peg$literalExpectation("offset", false);
+ var peg$e26 = peg$literalExpectation("\"", false);
+ var peg$e27 = peg$classExpectation(["\"", "\r", "\n"], true, false);
+ var peg$e28 = peg$classExpectation([["A", "Z"], ["a", "z"], ["0", "9"], "_", "@", ".", "/", "#", "&", "+", "-", "\\", "'", ","], false, false);
+ var peg$e29 = peg$otherExpectation("whitespace");
+ var peg$e30 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false);
+
+ var peg$f0 = function(filters) { return applyDefaults(filters); };
+ var peg$f1 = function(head, tail) {
+ const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter(filter => filter !== null);
+ if (!allFilters.length) {
+ return null;
+ }
+ return allFilters.reduce((result, filter) => buildFilter("and", result, filter));
+ };
+ var peg$f2 = function(field, op, value) {
+ if (isDefaultField(field)) {
+ updateDefaultValues(field, value.trim());
+ return null;
+ }
+
+ if (!field && !op) {
+ return buildFilter('eq', 'keyword', value.trim());
+ }
+
+ const values = value.split(',');
+ const operatorValue = op ?? 'eq';
+
+ return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter(operatorValue, field, val.trim())), buildFilter(operatorValue, field, values[0]));
+ };
+ var peg$f3 = function() { return "eq"; };
+ var peg$f4 = function() { return "neq"; };
+ var peg$f5 = function() { return "gt"; };
+ var peg$f6 = function() { return "gte"; };
+ var peg$f7 = function() { return "lt"; };
+ var peg$f8 = function() { return "lte"; };
+ var peg$f9 = function() { return "type"; };
+ var peg$f10 = function() { return "status"; };
+ var peg$f11 = function() { return "date"; };
+ var peg$f12 = function() { return "amount"; };
+ var peg$f13 = function() { return "expenseType"; };
+ var peg$f14 = function() { return "in"; };
+ var peg$f15 = function() { return "currency"; };
+ var peg$f16 = function() { return "merchant"; };
+ var peg$f17 = function() { return "description"; };
+ var peg$f18 = function() { return "from"; };
+ var peg$f19 = function() { return "to"; };
+ var peg$f20 = function() { return "category"; };
+ var peg$f21 = function() { return "tag"; };
+ var peg$f22 = function() { return "taxRate"; };
+ var peg$f23 = function() { return "card"; };
+ var peg$f24 = function() { return "reportID"; };
+ var peg$f25 = function() { return "keyword"; };
+ var peg$f26 = function() { return "sortBy"; };
+ var peg$f27 = function() { return "sortOrder"; };
+ var peg$f28 = function() { return "offset"; };
+ var peg$f29 = function(parts) { return parts.join(''); };
+ var peg$f30 = function(chars) { return chars.join(''); };
+ var peg$f31 = function(chars) { return chars.join(''); };
+ var peg$f32 = function() { return "and"; };
+ var peg$currPos = options.peg$currPos | 0;
+ var peg$savedPos = peg$currPos;
+ var peg$posDetailsCache = [{ line: 1, column: 1 }];
+ var peg$maxFailPos = peg$currPos;
+ var peg$maxFailExpected = options.peg$maxFailExpected || [];
+ var peg$silentFails = options.peg$silentFails | 0;
+
+ var peg$result;
+
+ if (options.startRule) {
+ if (!(options.startRule in peg$startRuleFunctions)) {
+ throw new Error("Can't start parsing from rule \"" + options.startRule + "\".");
+ }
+
+ peg$startRuleFunction = peg$startRuleFunctions[options.startRule];
+ }
+
+ function text() {
+ return input.substring(peg$savedPos, peg$currPos);
+ }
+
+ function offset() {
+ return peg$savedPos;
+ }
+
+ function range() {
+ return {
+ source: peg$source,
+ start: peg$savedPos,
+ end: peg$currPos
+ };
+ }
+
+ function location() {
+ return peg$computeLocation(peg$savedPos, peg$currPos);
+ }
+
+ function expected(description, location) {
+ location = location !== undefined
+ ? location
+ : peg$computeLocation(peg$savedPos, peg$currPos);
+
+ throw peg$buildStructuredError(
+ [peg$otherExpectation(description)],
+ input.substring(peg$savedPos, peg$currPos),
+ location
+ );
+ }
+
+ function error(message, location) {
+ location = location !== undefined
+ ? location
+ : peg$computeLocation(peg$savedPos, peg$currPos);
+
+ throw peg$buildSimpleError(message, location);
+ }
+
+ function peg$literalExpectation(text, ignoreCase) {
+ return { type: "literal", text: text, ignoreCase: ignoreCase };
+ }
+
+ function peg$classExpectation(parts, inverted, ignoreCase) {
+ return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase };
+ }
+
+ function peg$anyExpectation() {
+ return { type: "any" };
+ }
+
+ function peg$endExpectation() {
+ return { type: "end" };
+ }
+
+ function peg$otherExpectation(description) {
+ return { type: "other", description: description };
+ }
+
+ function peg$computePosDetails(pos) {
+ var details = peg$posDetailsCache[pos];
+ var p;
+
+ if (details) {
+ return details;
+ } else {
+ if (pos >= peg$posDetailsCache.length) {
+ p = peg$posDetailsCache.length - 1;
+ } else {
+ p = pos;
+ while (!peg$posDetailsCache[--p]) {}
+ }
+
+ details = peg$posDetailsCache[p];
+ details = {
+ line: details.line,
+ column: details.column
+ };
+
+ while (p < pos) {
+ if (input.charCodeAt(p) === 10) {
+ details.line++;
+ details.column = 1;
+ } else {
+ details.column++;
+ }
+
+ p++;
+ }
+
+ peg$posDetailsCache[pos] = details;
+
+ return details;
+ }
+ }
+
+ function peg$computeLocation(startPos, endPos, offset) {
+ var startPosDetails = peg$computePosDetails(startPos);
+ var endPosDetails = peg$computePosDetails(endPos);
+
+ var res = {
+ source: peg$source,
+ start: {
+ offset: startPos,
+ line: startPosDetails.line,
+ column: startPosDetails.column
+ },
+ end: {
+ offset: endPos,
+ line: endPosDetails.line,
+ column: endPosDetails.column
+ }
+ };
+ if (offset && peg$source && (typeof peg$source.offset === "function")) {
+ res.start = peg$source.offset(res.start);
+ res.end = peg$source.offset(res.end);
+ }
+ return res;
+ }
+
+ function peg$fail(expected) {
+ if (peg$currPos < peg$maxFailPos) { return; }
+
+ if (peg$currPos > peg$maxFailPos) {
+ peg$maxFailPos = peg$currPos;
+ peg$maxFailExpected = [];
+ }
+
+ peg$maxFailExpected.push(expected);
+ }
+
+ function peg$buildSimpleError(message, location) {
+ return new peg$SyntaxError(message, null, null, location);
+ }
+
+ function peg$buildStructuredError(expected, found, location) {
+ return new peg$SyntaxError(
+ peg$SyntaxError.buildMessage(expected, found),
+ expected,
+ found,
+ location
+ );
+ }
+
+ function peg$parsequery() {
+ var s0, s1, s2, s3;
+
+ s0 = peg$currPos;
+ s1 = peg$parse_();
+ s2 = peg$parsefilterList();
+ if (s2 === peg$FAILED) {
+ s2 = null;
+ }
+ s3 = peg$parse_();
+ peg$savedPos = s0;
+ s0 = peg$f0(s2);
+
+ return s0;
+ }
+
+ function peg$parsefilterList() {
+ var s0, s1, s2, s3, s4, s5;
+
+ s0 = peg$currPos;
+ s1 = peg$parsefilter();
+ if (s1 !== peg$FAILED) {
+ s2 = [];
+ s3 = peg$currPos;
+ s4 = peg$parselogicalAnd();
+ s5 = peg$parsefilter();
+ if (s5 !== peg$FAILED) {
+ s4 = [s4, s5];
+ s3 = s4;
+ } else {
+ peg$currPos = s3;
+ s3 = peg$FAILED;
+ }
+ while (s3 !== peg$FAILED) {
+ s2.push(s3);
+ s3 = peg$currPos;
+ s4 = peg$parselogicalAnd();
+ s5 = peg$parsefilter();
+ if (s5 !== peg$FAILED) {
+ s4 = [s4, s5];
+ s3 = s4;
+ } else {
+ peg$currPos = s3;
+ s3 = peg$FAILED;
+ }
+ }
+ peg$savedPos = s0;
+ s0 = peg$f1(s1, s2);
+ } else {
+ peg$currPos = s0;
+ s0 = peg$FAILED;
+ }
+
+ return s0;
+ }
+
+ function peg$parsefilter() {
+ var s0, s1, s2, s3, s4, s5, s6;
+
+ s0 = peg$currPos;
+ s1 = peg$parse_();
+ s2 = peg$parsekey();
+ if (s2 === peg$FAILED) {
+ s2 = null;
+ }
+ s3 = peg$parse_();
+ s4 = peg$parseoperator();
+ if (s4 === peg$FAILED) {
+ s4 = null;
+ }
+ s5 = peg$parse_();
+ s6 = peg$parseidentifier();
+ if (s6 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s0 = peg$f2(s2, s4, s6);
+ } else {
+ peg$currPos = s0;
+ s0 = peg$FAILED;
+ }
+
+ return s0;
+ }
+
+ function peg$parseoperator() {
+ var s0, s1;
+
+ s0 = peg$currPos;
+ s1 = input.charAt(peg$currPos);
+ if (peg$r0.test(s1)) {
+ peg$currPos++;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e0); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f3();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 2) === peg$c0) {
+ s1 = peg$c0;
+ peg$currPos += 2;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e1); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f4();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.charCodeAt(peg$currPos) === 62) {
+ s1 = peg$c1;
+ peg$currPos++;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e2); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f5();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 2) === peg$c2) {
+ s1 = peg$c2;
+ peg$currPos += 2;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e3); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f6();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.charCodeAt(peg$currPos) === 60) {
+ s1 = peg$c3;
+ peg$currPos++;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e4); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f7();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 2) === peg$c4) {
+ s1 = peg$c4;
+ peg$currPos += 2;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e5); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f8();
+ }
+ s0 = s1;
+ }
+ }
+ }
+ }
+ }
+
+ return s0;
+ }
+
+ function peg$parsekey() {
+ var s0, s1;
+
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 4) === peg$c5) {
+ s1 = peg$c5;
+ peg$currPos += 4;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e6); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f9();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 6) === peg$c6) {
+ s1 = peg$c6;
+ peg$currPos += 6;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e7); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f10();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 4) === peg$c7) {
+ s1 = peg$c7;
+ peg$currPos += 4;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e8); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f11();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 6) === peg$c8) {
+ s1 = peg$c8;
+ peg$currPos += 6;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e9); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f12();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 11) === peg$c9) {
+ s1 = peg$c9;
+ peg$currPos += 11;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e10); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f13();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 2) === peg$c10) {
+ s1 = peg$c10;
+ peg$currPos += 2;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e11); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f14();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 8) === peg$c11) {
+ s1 = peg$c11;
+ peg$currPos += 8;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e12); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f15();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 8) === peg$c12) {
+ s1 = peg$c12;
+ peg$currPos += 8;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e13); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f16();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 11) === peg$c13) {
+ s1 = peg$c13;
+ peg$currPos += 11;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e14); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f17();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 4) === peg$c14) {
+ s1 = peg$c14;
+ peg$currPos += 4;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e15); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f18();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 2) === peg$c15) {
+ s1 = peg$c15;
+ peg$currPos += 2;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e16); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f19();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 8) === peg$c16) {
+ s1 = peg$c16;
+ peg$currPos += 8;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e17); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f20();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 3) === peg$c17) {
+ s1 = peg$c17;
+ peg$currPos += 3;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e18); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f21();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 7) === peg$c18) {
+ s1 = peg$c18;
+ peg$currPos += 7;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e19); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f22();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 4) === peg$c19) {
+ s1 = peg$c19;
+ peg$currPos += 4;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e20); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f23();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 8) === peg$c20) {
+ s1 = peg$c20;
+ peg$currPos += 8;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e21); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f24();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 7) === peg$c21) {
+ s1 = peg$c21;
+ peg$currPos += 7;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e22); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f25();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 6) === peg$c22) {
+ s1 = peg$c22;
+ peg$currPos += 6;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e23); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f26();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 9) === peg$c23) {
+ s1 = peg$c23;
+ peg$currPos += 9;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e24); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f27();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 6) === peg$c24) {
+ s1 = peg$c24;
+ peg$currPos += 6;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e25); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f28();
+ }
+ s0 = s1;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return s0;
+ }
+
+ function peg$parseidentifier() {
+ var s0, s1, s2;
+
+ s0 = peg$currPos;
+ s1 = [];
+ s2 = peg$parsequotedString();
+ if (s2 === peg$FAILED) {
+ s2 = peg$parsealphanumeric();
+ }
+ if (s2 !== peg$FAILED) {
+ while (s2 !== peg$FAILED) {
+ s1.push(s2);
+ s2 = peg$parsequotedString();
+ if (s2 === peg$FAILED) {
+ s2 = peg$parsealphanumeric();
+ }
+ }
+ } else {
+ s1 = peg$FAILED;
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f29(s1);
+ }
+ s0 = s1;
+
+ return s0;
+ }
+
+ function peg$parsequotedString() {
+ var s0, s1, s2, s3;
+
+ s0 = peg$currPos;
+ if (input.charCodeAt(peg$currPos) === 34) {
+ s1 = peg$c25;
+ peg$currPos++;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e26); }
+ }
+ if (s1 !== peg$FAILED) {
+ s2 = [];
+ s3 = input.charAt(peg$currPos);
+ if (peg$r1.test(s3)) {
+ peg$currPos++;
+ } else {
+ s3 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e27); }
+ }
+ while (s3 !== peg$FAILED) {
+ s2.push(s3);
+ s3 = input.charAt(peg$currPos);
+ if (peg$r1.test(s3)) {
+ peg$currPos++;
+ } else {
+ s3 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e27); }
+ }
+ }
+ if (input.charCodeAt(peg$currPos) === 34) {
+ s3 = peg$c25;
+ peg$currPos++;
+ } else {
+ s3 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e26); }
+ }
+ if (s3 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s0 = peg$f30(s2);
+ } else {
+ peg$currPos = s0;
+ s0 = peg$FAILED;
+ }
+ } else {
+ peg$currPos = s0;
+ s0 = peg$FAILED;
+ }
+
+ return s0;
+ }
+
+ function peg$parsealphanumeric() {
+ var s0, s1, s2;
+
+ s0 = peg$currPos;
+ s1 = [];
+ s2 = input.charAt(peg$currPos);
+ if (peg$r2.test(s2)) {
+ peg$currPos++;
+ } else {
+ s2 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e28); }
+ }
+ if (s2 !== peg$FAILED) {
+ while (s2 !== peg$FAILED) {
+ s1.push(s2);
+ s2 = input.charAt(peg$currPos);
+ if (peg$r2.test(s2)) {
+ peg$currPos++;
+ } else {
+ s2 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e28); }
+ }
+ }
+ } else {
+ s1 = peg$FAILED;
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f31(s1);
+ }
+ s0 = s1;
+
+ return s0;
+ }
+
+ function peg$parselogicalAnd() {
+ var s0, s1;
+
+ s0 = peg$currPos;
+ s1 = peg$parse_();
+ peg$savedPos = s0;
+ s1 = peg$f32();
+ s0 = s1;
+
+ return s0;
+ }
+
+ function peg$parse_() {
+ var s0, s1;
+
+ peg$silentFails++;
+ s0 = [];
+ s1 = input.charAt(peg$currPos);
+ if (peg$r3.test(s1)) {
+ peg$currPos++;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e30); }
+ }
+ while (s1 !== peg$FAILED) {
+ s0.push(s1);
+ s1 = input.charAt(peg$currPos);
+ if (peg$r3.test(s1)) {
+ peg$currPos++;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e30); }
+ }
+ }
+ peg$silentFails--;
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e29); }
+
+ return s0;
+ }
+
+
+ const defaultValues = {
+ "type": "expense",
+ "status": "all",
+ "sortBy": "date",
+ "sortOrder": "desc",
+ "offset": 0
+ };
+
+ function buildFilter(operator, left, right) {
+ return { operator, left, right };
+ }
+
+ function applyDefaults(filters) {
+ return {
+ ...defaultValues,
+ filters
+ };
+ }
+
+ function updateDefaultValues(field, value) {
+ defaultValues[field] = value;
+ }
+
+ function isDefaultField(field) {
+ return defaultValues.hasOwnProperty(field);
+ }
+
+ peg$result = peg$startRuleFunction();
+
+ if (options.peg$library) {
+ return /** @type {any} */ ({
+ peg$result,
+ peg$currPos,
+ peg$FAILED,
+ peg$maxFailExpected,
+ peg$maxFailPos
+ });
+ }
+ if (peg$result !== peg$FAILED && peg$currPos === input.length) {
+ return peg$result;
+ } else {
+ if (peg$result !== peg$FAILED && peg$currPos < input.length) {
+ peg$fail(peg$endExpectation());
+ }
+
+ throw peg$buildStructuredError(
+ peg$maxFailExpected,
+ peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null,
+ peg$maxFailPos < input.length
+ ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1)
+ : peg$computeLocation(peg$maxFailPos, peg$maxFailPos)
+ );
+ }
+}
+
+const peg$allowedStartRules = [
+ "query"
+];
+
+export {
+ peg$allowedStartRules as StartRules,
+ peg$SyntaxError as SyntaxError,
+ peg$parse as parse
+};
diff --git a/src/libs/SearchParser/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy
new file mode 100644
index 000000000000..0957239b6acb
--- /dev/null
+++ b/src/libs/SearchParser/searchParser.peggy
@@ -0,0 +1,122 @@
+// This files defines the grammar that's used by [Peggy](https://peggyjs.org/) to generate the searchParser.js file.
+// The searchParser is setup to parse our custom search syntax and output an AST with the filters.
+//
+// Here's a general grammar structure:
+//
+// start: entry point for the parser. It calls the query rule and return its value.
+// query: rule to process the values returned by the filterList rule. Takes filters as an argument and returns the final AST output.
+// filterList: rule to process the array of filters returned by the filter rule. It takes head and tail as arguments, filters it for null values and builds the AST.
+// filter: rule to build the filter object. It takes field, operator and value as input and returns {operator, left: field, right: value} or null if the left value is a defaultValues
+// operator: rule to match pre-defined search syntax operators, e.g. !=, >, etc
+// key: rule to match pre-defined search syntax fields, e.g. amount, merchant, etc
+// identifier: composite rule to match patterns defined by the quotedString and alphanumeric rules
+// quotedString: rule to match a quoted string pattern, e.g. "this is a quoted string"
+// alphanumeric: rule to match unquoted alphanumeric characters, e.g. a-z, 0-9, _, @, etc
+// logicalAnd: rule to match whitespace and return it as a logical 'and' operator
+// whitespace: rule to match whitespaces
+
+{
+ const defaultValues = {
+ "type": "expense",
+ "status": "all",
+ "sortBy": "date",
+ "sortOrder": "desc",
+ "offset": 0
+ };
+
+ function buildFilter(operator, left, right) {
+ return { operator, left, right };
+ }
+
+ function applyDefaults(filters) {
+ return {
+ ...defaultValues,
+ filters
+ };
+ }
+
+ function updateDefaultValues(field, value) {
+ defaultValues[field] = value;
+ }
+
+ function isDefaultField(field) {
+ return defaultValues.hasOwnProperty(field);
+ }
+}
+
+query
+ = _ filters:filterList? _ { return applyDefaults(filters); }
+
+filterList
+ = head:filter tail:(logicalAnd filter)* {
+ const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter(filter => filter !== null);
+ if (!allFilters.length) {
+ return null;
+ }
+ return allFilters.reduce((result, filter) => buildFilter("and", result, filter));
+ }
+
+filter
+ = _ field:key? _ op:operator? _ value:identifier {
+ if (isDefaultField(field)) {
+ updateDefaultValues(field, value.trim());
+ return null;
+ }
+
+ if (!field && !op) {
+ return buildFilter('eq', 'keyword', value.trim());
+ }
+
+ const values = value.split(',');
+ const operatorValue = op ?? 'eq';
+
+ return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter(operatorValue, field, val.trim())), buildFilter(operatorValue, field, values[0]));
+ }
+
+operator
+ = (":" / "=") { return "eq"; }
+ / "!=" { return "neq"; }
+ / ">" { return "gt"; }
+ / ">=" { return "gte"; }
+ / "<" { return "lt"; }
+ / "<=" { return "lte"; }
+
+key
+ = "type" { return "type"; }
+ / "status" { return "status"; }
+ / "date" { return "date"; }
+ / "amount" { return "amount"; }
+ / "expenseType" { return "expenseType"; }
+ / "in" { return "in"; }
+ / "currency" { return "currency"; }
+ / "merchant" { return "merchant"; }
+ / "description" { return "description"; }
+ / "from" { return "from"; }
+ / "to" { return "to"; }
+ / "category" { return "category"; }
+ / "tag" { return "tag"; }
+ / "taxRate" { return "taxRate"; }
+ / "card" { return "card"; }
+ / "reportID" { return "reportID"; }
+ / "keyword" { return "keyword"; }
+ / "sortBy" { return "sortBy"; }
+ / "sortOrder" { return "sortOrder"; }
+ / "offset" { return "offset"; }
+
+identifier
+ = parts:(quotedString / alphanumeric)+ { return parts.join(''); }
+
+quotedString
+ = '"' chars:[^"\r\n]* '"' { return chars.join(''); }
+
+alphanumeric
+ = chars:[A-Za-z0-9_@./#&+\-\\',]+ { return chars.join(''); }
+
+logicalAnd
+ = _ { return "and"; }
+
+_ "whitespace"
+ = [ \t\r\n]*
+
+start
+ = query
diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts
index 91d742f44e62..3225a21c2661 100644
--- a/src/libs/SearchUtils.ts
+++ b/src/libs/SearchUtils.ts
@@ -11,6 +11,7 @@ import DateUtils from './DateUtils';
import getTopmostCentralPaneRoute from './Navigation/getTopmostCentralPaneRoute';
import navigationRef from './Navigation/navigationRef';
import type {AuthScreensParamList, RootStackParamList, State} from './Navigation/types';
+import * as searchParser from './SearchParser/searchParser';
import * as TransactionUtils from './TransactionUtils';
import * as UserUtils from './UserUtils';
@@ -301,7 +302,29 @@ function isSearchResultsEmpty(searchResults: SearchResults) {
return !Object.keys(searchResults?.data).some((key) => key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION));
}
+function getQueryHashFromString(query: string): number {
+ return UserUtils.hashText(query, 2 ** 32);
+}
+
+type JSONQuery = {
+ input: string;
+ hash: number;
+};
+
+function buildJSONQuery(query: string) {
+ try {
+ // Add the full input and hash to the results
+ const result = searchParser.parse(query) as JSONQuery;
+ result.input = query;
+ result.hash = getQueryHashFromString(query);
+ return result;
+ } catch (e) {
+ console.error(e);
+ }
+}
+
export {
+ buildJSONQuery,
getListItem,
getQueryHash,
getSections,
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index 9efd1e584052..3151f522d800 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -18,11 +18,18 @@ import localeCompare from './LocaleCompare';
import * as LocalePhoneNumber from './LocalePhoneNumber';
import * as Localize from './Localize';
import * as OptionsListUtils from './OptionsListUtils';
+import Permissions from './Permissions';
import * as PolicyUtils from './PolicyUtils';
import * as ReportActionsUtils from './ReportActionsUtils';
import * as ReportUtils from './ReportUtils';
import * as TaskUtils from './TaskUtils';
+let allBetas: OnyxEntry;
+Onyx.connect({
+ key: ONYXKEYS.BETAS,
+ callback: (value) => (allBetas = value),
+});
+
const visibleReportActionItems: ReportActions = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
@@ -89,13 +96,23 @@ function getOrderedReportIDs(
return;
}
const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? {};
+ const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '-1', report?.parentReportActionID ?? '-1');
const doesReportHaveViolations = OptionsListUtils.shouldShowViolations(report, betas ?? [], transactionViolations);
const isHidden = report.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
const isFocused = report.reportID === currentReportId;
const allReportErrors = OptionsListUtils.getAllReportErrors(report, reportActions) ?? {};
+ const transactionReportActions = ReportActionsUtils.getAllReportActions(report.reportID);
+ const oneTransactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, transactionReportActions, undefined);
+ let doesTransactionThreadReportHasViolations = false;
+ if (oneTransactionThreadReportID) {
+ const transactionReport = ReportUtils.getReport(oneTransactionThreadReportID);
+ doesTransactionThreadReportHasViolations = !!transactionReport && OptionsListUtils.shouldShowViolations(transactionReport, betas ?? [], transactionViolations);
+ }
const hasErrorsOtherThanFailedReceipt =
- doesReportHaveViolations || Object.values(allReportErrors).some((error) => error?.[0] !== Localize.translateLocal('iou.error.genericSmartscanFailureMessage'));
- if (ReportUtils.isOneTransactionThread(report.reportID, report.parentReportID ?? '0')) {
+ doesTransactionThreadReportHasViolations ||
+ doesReportHaveViolations ||
+ Object.values(allReportErrors).some((error) => error?.[0] !== Localize.translateLocal('iou.error.genericSmartscanFailureMessage'));
+ if (ReportUtils.isOneTransactionThread(report.reportID, report.parentReportID ?? '0', parentReportAction)) {
return;
}
if (hasErrorsOtherThanFailedReceipt) {
@@ -213,6 +230,7 @@ function getOptionData({
policy,
parentReportAction,
hasViolations,
+ transactionViolations,
}: {
report: OnyxEntry;
reportActions: OnyxEntry;
@@ -221,6 +239,7 @@ function getOptionData({
policy: OnyxEntry | undefined;
parentReportAction: OnyxEntry | undefined;
hasViolations: boolean;
+ transactionViolations?: OnyxCollection;
}): ReportUtils.OptionData | undefined {
// When a user signs out, Onyx is cleared. Due to the lazy rendering with a virtual list, it's possible for
// this method to be called after the Onyx data has been cleared out. In that case, it's fine to do
@@ -259,6 +278,7 @@ function getOptionData({
isWaitingOnBankAccount: false,
isAllowedToComment: true,
isDeletedParentAction: false,
+ isConciergeChat: false,
};
const participantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report);
@@ -280,6 +300,21 @@ function getOptionData({
result.shouldShowSubscript = ReportUtils.shouldReportShowSubscript(report);
result.pendingAction = report.pendingFields?.addWorkspaceRoom ?? report.pendingFields?.createChat;
result.brickRoadIndicator = hasErrors || hasViolations ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '';
+ const oneTransactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, ReportActionsUtils.getAllReportActions(report.reportID));
+ if (oneTransactionThreadReportID) {
+ const oneTransactionThreadReport = ReportUtils.getReport(oneTransactionThreadReportID);
+
+ if (
+ Permissions.canUseViolations(allBetas) &&
+ ReportUtils.shouldDisplayTransactionThreadViolations(
+ oneTransactionThreadReport,
+ transactionViolations,
+ ReportActionsUtils.getAllReportActions(report.reportID)[oneTransactionThreadReport?.parentReportActionID ?? '-1'],
+ )
+ ) {
+ result.brickRoadIndicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
+ }
+ }
result.ownerAccountID = report.ownerAccountID;
result.managerID = report.managerID;
result.reportID = report.reportID;
@@ -304,6 +339,7 @@ function getOptionData({
result.tooltipText = ReportUtils.getReportParticipantsTitle(visibleParticipantAccountIDs);
result.hasOutstandingChildTask = report.hasOutstandingChildTask;
result.hasParentAccess = report.hasParentAccess;
+ result.isConciergeChat = ReportUtils.isConciergeChatReport(report);
const hasMultipleParticipants = participantPersonalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat || ReportUtils.isExpenseReport(report);
const subtitle = ReportUtils.getChatRoomSubtitle(report);
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index b9a8a05ba046..d72e7d5c83ba 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -322,14 +322,31 @@ function getReportPreviewAction(chatReportID: string, iouReportID: string): Onyx
* @param policy
* @param isFromGlobalCreate
* @param iouRequestType one of manual/scan/distance
- * @param skipConfirmation if true, skip confirmation step
*/
function initMoneyRequest(reportID: string, policy: OnyxEntry, isFromGlobalCreate: boolean, iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL) {
// Generate a brand new transactionID
const newTransactionID = CONST.IOU.OPTIMISTIC_TRANSACTION_ID;
+ const currency = policy?.outputCurrency ?? currentUserPersonalDetails?.localCurrencyCode ?? CONST.CURRENCY.USD;
// Disabling this line since currentDate can be an empty string
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const created = currentDate || format(new Date(), 'yyyy-MM-dd');
+
+ const currentTransaction = allTransactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${newTransactionID}`];
+
+ // in case we have to re-init money request, but the IOU request type is the same with the old draft transaction,
+ // we should keep most of the existing data by using the ONYX MERGE operation
+ if (currentTransaction?.iouRequestType === iouRequestType) {
+ // so, we just need to update the reportID, isFromGlobalCreate, created, currency
+ Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${newTransactionID}`, {
+ reportID,
+ isFromGlobalCreate,
+ created,
+ currency,
+ transactionID: newTransactionID,
+ });
+ return;
+ }
+
const comment: Comment = {};
// Add initial empty waypoints when starting a distance expense
@@ -350,7 +367,7 @@ function initMoneyRequest(reportID: string, policy: OnyxEntry,
amount: 0,
comment,
created,
- currency: policy?.outputCurrency ?? currentUserPersonalDetails?.localCurrencyCode ?? CONST.CURRENCY.USD,
+ currency,
iouRequestType,
reportID,
transactionID: newTransactionID,
diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts
index 4f32eb0b81ba..3af4749569c9 100644
--- a/src/libs/actions/Policy/Category.ts
+++ b/src/libs/actions/Policy/Category.ts
@@ -2,7 +2,7 @@ import lodashUnion from 'lodash/union';
import type {NullishDeep, OnyxCollection, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import * as API from '@libs/API';
-import type {EnablePolicyCategoriesParams, OpenPolicyCategoriesPageParams, SetPolicyDistanceRatesDefaultCategoryParams} from '@libs/API/parameters';
+import type {EnablePolicyCategoriesParams, OpenPolicyCategoriesPageParams, SetPolicyDistanceRatesDefaultCategoryParams, UpdatePolicyCategoryGLCodeParams} from '@libs/API/parameters';
import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
import * as ErrorUtils from '@libs/ErrorUtils';
import getIsNarrowLayout from '@libs/getIsNarrowLayout';
@@ -331,6 +331,74 @@ function renamePolicyCategory(policyID: string, policyCategory: {oldName: string
API.write(WRITE_COMMANDS.RENAME_WORKSPACE_CATEGORY, parameters, onyxData);
}
+function updatePolicyCategoryGLCode(policyID: string, categoryName: string, glCode: string) {
+ const policyCategoryToUpdate = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName] ?? {};
+
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`,
+ value: {
+ [categoryName]: {
+ ...policyCategoryToUpdate,
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ pendingFields: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'GL Code': CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'GL Code': glCode,
+ },
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`,
+ value: {
+ [categoryName]: {
+ ...policyCategoryToUpdate,
+ pendingAction: null,
+ pendingFields: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'GL Code': null,
+ },
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'GL Code': glCode,
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`,
+ value: {
+ [categoryName]: {
+ ...policyCategoryToUpdate,
+ errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.categories.updateGLCodeFailureMessage'),
+ pendingAction: null,
+ pendingFields: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'GL Code': null,
+ },
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters: UpdatePolicyCategoryGLCodeParams = {
+ policyID,
+ categoryName,
+ glCode,
+ };
+
+ API.write(WRITE_COMMANDS.UPDATE_POLICY_CATEGORY_GL_CODE, parameters, onyxData);
+}
+
function setWorkspaceRequiresCategory(policyID: string, requiresCategory: boolean) {
const onyxData: OnyxData = {
optimisticData: [
@@ -618,6 +686,7 @@ export {
setWorkspaceRequiresCategory,
createPolicyCategory,
renamePolicyCategory,
+ updatePolicyCategoryGLCode,
clearCategoryErrors,
enablePolicyCategories,
setPolicyDistanceRatesDefaultCategory,
diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts
index 9dc0f96c5886..daefaed07f68 100644
--- a/src/libs/actions/Policy/Tag.ts
+++ b/src/libs/actions/Policy/Tag.ts
@@ -1,7 +1,15 @@
import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import * as API from '@libs/API';
-import type {EnablePolicyTagsParams, OpenPolicyTagsPageParams, RenamePolicyTaglistParams, RenamePolicyTagsParams, SetPolicyTagsEnabled, SetPolicyTagsRequired} from '@libs/API/parameters';
+import type {
+ EnablePolicyTagsParams,
+ OpenPolicyTagsPageParams,
+ RenamePolicyTaglistParams,
+ RenamePolicyTagsParams,
+ SetPolicyTagsEnabled,
+ SetPolicyTagsRequired,
+ UpdatePolicyTagGLCodeParams,
+} from '@libs/API/parameters';
import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
import * as ErrorUtils from '@libs/ErrorUtils';
import getIsNarrowLayout from '@libs/getIsNarrowLayout';
@@ -756,6 +764,81 @@ function setPolicyTagsRequired(policyID: string, requiresTag: boolean, tagListIn
API.write(WRITE_COMMANDS.SET_POLICY_TAGS_REQUIRED, parameters, onyxData);
}
+function setPolicyTagGLCode(policyID: string, tagName: string, tagListIndex: number, glCode: string) {
+ const tagListName = Object.keys(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})[tagListIndex];
+ const policyTagToUpdate = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]?.[tagListName]?.tags?.[tagName] ?? {};
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
+ value: {
+ [tagListName]: {
+ tags: {
+ [tagName]: {
+ ...policyTagToUpdate,
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ pendingFields: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'GL Code': CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'GL Code': glCode,
+ },
+ },
+ },
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
+ value: {
+ [tagListName]: {
+ tags: {
+ [tagName]: {
+ errors: null,
+ pendingAction: null,
+ pendingFields: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'GL Code': null,
+ },
+ },
+ },
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
+ value: {
+ [tagListName]: {
+ tags: {
+ [tagName]: {
+ ...policyTagToUpdate,
+ errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.tags.updateGLCodeFailureMessage'),
+ },
+ },
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters: UpdatePolicyTagGLCodeParams = {
+ policyID,
+ tagName,
+ tagListName,
+ tagListIndex,
+ glCode,
+ };
+
+ API.write(WRITE_COMMANDS.UPDATE_POLICY_TAG_GL_CODE, parameters, onyxData);
+}
+
export {
buildOptimisticPolicyRecentlyUsedTags,
setPolicyRequiresTag,
@@ -770,6 +853,7 @@ export {
renamePolicyTag,
renamePolicyTaglist,
setWorkspaceTagEnabled,
+ setPolicyTagGLCode,
};
export type {NewCustomUnit};
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index 3060f53f12c3..026ce45146d3 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -1,4 +1,3 @@
-import {findFocusedRoute} from '@react-navigation/native';
import {format as timezoneFormat, utcToZonedTime} from 'date-fns-tz';
import {Str} from 'expensify-common';
import isEmpty from 'lodash/isEmpty';
@@ -56,13 +55,11 @@ import {prepareDraftComment} from '@libs/DraftCommentUtils';
import * as EmojiUtils from '@libs/EmojiUtils';
import * as Environment from '@libs/Environment/Environment';
import * as ErrorUtils from '@libs/ErrorUtils';
-import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector';
import isPublicScreenRoute from '@libs/isPublicScreenRoute';
import * as Localize from '@libs/Localize';
import Log from '@libs/Log';
import {registerPaginationConfig} from '@libs/Middleware/Pagination';
-import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
-import {isOnboardingFlowName} from '@libs/NavigationUtils';
+import Navigation from '@libs/Navigation/Navigation';
import type {NetworkStatus} from '@libs/NetworkConnection';
import LocalNotification from '@libs/Notification/LocalNotification';
import Parser from '@libs/Parser';
@@ -2544,47 +2541,28 @@ function openReportFromDeepLink(url: string) {
// Navigate to the report after sign-in/sign-up.
InteractionManager.runAfterInteractions(() => {
Session.waitForUserSignIn().then(() => {
- Onyx.connect({
- key: ONYXKEYS.NVP_ONBOARDING,
- callback: (onboarding) => {
- Navigation.waitForProtectedRoutes().then(() => {
- if (route && Session.isAnonymousUser() && !Session.canAnonymousUserAccessRoute(route)) {
- Session.signOutAndRedirectToSignIn(true);
- return;
- }
-
- // We don't want to navigate to the exitTo route when creating a new workspace from a deep link,
- // because we already handle creating the optimistic policy and navigating to it in App.setUpPoliciesAndNavigate,
- // which is already called when AuthScreens mounts.
- if (new URL(url).searchParams.get('exitTo') === ROUTES.WORKSPACE_NEW) {
- return;
- }
-
- if (shouldSkipDeepLinkNavigation(route)) {
- return;
- }
-
- const state = navigationRef.getRootState();
- const currentFocusedRoute = findFocusedRoute(state);
- const hasCompletedGuidedSetupFlow = hasCompletedGuidedSetupFlowSelector(onboarding);
-
- // We need skip deeplinking if the user hasn't completed the guided setup flow.
- if (!hasCompletedGuidedSetupFlow) {
- return;
- }
-
- if (isOnboardingFlowName(currentFocusedRoute?.name)) {
- Welcome.setOnboardingErrorMessage(Localize.translateLocal('onboarding.purpose.errorBackButton'));
- return;
- }
-
- if (isAuthenticated) {
- return;
- }
-
- Navigation.navigate(route as Route, CONST.NAVIGATION.ACTION_TYPE.PUSH);
- });
- },
+ Navigation.waitForProtectedRoutes().then(() => {
+ if (route && Session.isAnonymousUser() && !Session.canAnonymousUserAccessRoute(route)) {
+ Session.signOutAndRedirectToSignIn(true);
+ return;
+ }
+
+ // We don't want to navigate to the exitTo route when creating a new workspace from a deep link,
+ // because we already handle creating the optimistic policy and navigating to it in App.setUpPoliciesAndNavigate,
+ // which is already called when AuthScreens mounts.
+ if (new URL(url).searchParams.get('exitTo') === ROUTES.WORKSPACE_NEW) {
+ return;
+ }
+
+ if (shouldSkipDeepLinkNavigation(route)) {
+ return;
+ }
+
+ if (isAuthenticated) {
+ return;
+ }
+
+ Navigation.navigate(route as Route, CONST.NAVIGATION.ACTION_TYPE.PUSH);
});
});
});
diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts
index 0a7244bde1e5..964f5be0129b 100644
--- a/src/libs/actions/Task.ts
+++ b/src/libs/actions/Task.ts
@@ -126,7 +126,7 @@ function createTaskAndNavigate(
const currentTime = DateUtils.getDBTimeWithSkew();
const lastCommentText = ReportUtils.formatReportLastMessageText(ReportActionsUtils.getReportActionText(optimisticAddCommentReport.reportAction));
- const parentReport = getReport(parentReportID);
+ const parentReport = ReportUtils.getReport(parentReportID);
const optimisticParentReport = {
lastVisibleActionCreated: optimisticAddCommentReport.reportAction.created,
lastMessageText: lastCommentText,
@@ -906,13 +906,6 @@ function getParentReport(report: OnyxEntry): OnyxEntry {
- return ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
-}
-
/**
* Cancels a task by setting the report state to SUBMITTED and status to CLOSED
*/
diff --git a/src/libs/actions/Welcome.ts b/src/libs/actions/Welcome.ts
index b592424cfcdf..a90c386d02b6 100644
--- a/src/libs/actions/Welcome.ts
+++ b/src/libs/actions/Welcome.ts
@@ -11,8 +11,7 @@ import ROUTES from '@src/ROUTES';
import type Onboarding from '@src/types/onyx/Onboarding';
import type TryNewDot from '@src/types/onyx/TryNewDot';
-type OnboardingData = Onboarding | [] | undefined;
-
+let onboarding: Onboarding | [] | undefined;
let isLoadingReportData = true;
let tryNewDotData: TryNewDot | undefined;
@@ -31,8 +30,8 @@ let isServerDataReadyPromise = new Promise((resolve) => {
resolveIsReadyPromise = resolve;
});
-let resolveOnboardingFlowStatus: (value?: OnboardingData) => void;
-let isOnboardingFlowStatusKnownPromise = new Promise((resolve) => {
+let resolveOnboardingFlowStatus: (value?: Promise) => void | undefined;
+let isOnboardingFlowStatusKnownPromise = new Promise((resolve) => {
resolveOnboardingFlowStatus = resolve;
});
@@ -46,7 +45,7 @@ function onServerDataReady(): Promise {
}
function isOnboardingFlowCompleted({onCompleted, onNotCompleted}: HasCompletedOnboardingFlowProps) {
- isOnboardingFlowStatusKnownPromise.then((onboarding) => {
+ isOnboardingFlowStatusKnownPromise.then(() => {
if (Array.isArray(onboarding) || onboarding?.hasCompletedGuidedSetupFlow === undefined) {
return;
}
@@ -103,7 +102,23 @@ function handleHybridAppOnboarding() {
}
/**
- * Check if report data are loaded
+ * Check that a few requests have completed so that the welcome action can proceed:
+ *
+ * - Whether we are a first time new expensify user
+ * - Whether we have loaded all policies the server knows about
+ * - Whether we have loaded all reports the server knows about
+ * Check if onboarding data is ready in order to check if the user has completed onboarding or not
+ */
+function checkOnboardingDataReady() {
+ if (onboarding === undefined) {
+ return;
+ }
+
+ resolveOnboardingFlowStatus?.();
+}
+
+/**
+ * Check if user dismissed modal and if report data are loaded
*/
function checkServerDataReady() {
if (isLoadingReportData) {
@@ -128,10 +143,6 @@ function setOnboardingPurposeSelected(value: OnboardingPurposeType) {
Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, value ?? null);
}
-function setOnboardingErrorMessage(value: string) {
- Onyx.set(ONYXKEYS.ONBOARDING_ERROR_MESSAGE, value ?? null);
-}
-
function setOnboardingAdminsChatReportID(adminsChatReportID?: string) {
Onyx.set(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID, adminsChatReportID ?? null);
}
@@ -175,7 +186,9 @@ Onyx.connect({
return;
}
- resolveOnboardingFlowStatus(value);
+ onboarding = value;
+
+ checkOnboardingDataReady();
},
});
@@ -200,9 +213,10 @@ function resetAllChecks() {
isServerDataReadyPromise = new Promise((resolve) => {
resolveIsReadyPromise = resolve;
});
- isOnboardingFlowStatusKnownPromise = new Promise((resolve) => {
+ isOnboardingFlowStatusKnownPromise = new Promise((resolve) => {
resolveOnboardingFlowStatus = resolve;
});
+ onboarding = undefined;
isLoadingReportData = true;
}
@@ -215,5 +229,4 @@ export {
setOnboardingPolicyID,
completeHybridAppOnboarding,
handleHybridAppOnboarding,
- setOnboardingErrorMessage,
};
diff --git a/src/libs/focusComposerWithDelay/types.ts b/src/libs/focusComposerWithDelay/types.ts
index 4cd2f785f2bc..97a1298e8c7a 100644
--- a/src/libs/focusComposerWithDelay/types.ts
+++ b/src/libs/focusComposerWithDelay/types.ts
@@ -3,6 +3,8 @@ import type {TextInput} from 'react-native';
type Selection = {
start: number;
end: number;
+ positionX?: number;
+ positionY?: number;
};
type FocusComposerWithDelay = (shouldDelay?: boolean, forcedSelectionRange?: Selection) => void;
diff --git a/src/libs/hasCompletedGuidedSetupFlowSelector.ts b/src/libs/hasCompletedGuidedSetupFlowSelector.ts
deleted file mode 100644
index 83cde0a0be8c..000000000000
--- a/src/libs/hasCompletedGuidedSetupFlowSelector.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import type {OnyxValue} from 'react-native-onyx';
-import type ONYXKEYS from '@src/ONYXKEYS';
-
-function hasCompletedGuidedSetupFlowSelector(onboarding: OnyxValue): boolean {
- // onboarding is an array for old accounts and accounts created from olddot
- if (Array.isArray(onboarding)) {
- return true;
- }
- return onboarding?.hasCompletedGuidedSetupFlow ?? false;
-}
-
-export default hasCompletedGuidedSetupFlowSelector;
diff --git a/src/libs/shouldRenderAppPaymentCard/index.native.ts b/src/libs/shouldRenderAppPaymentCard/index.native.ts
new file mode 100644
index 000000000000..74137a6f7cdc
--- /dev/null
+++ b/src/libs/shouldRenderAppPaymentCard/index.native.ts
@@ -0,0 +1,5 @@
+import type ShouldRenderAddPaymentCard from './types';
+
+const shouldRenderAddPaymentCard: ShouldRenderAddPaymentCard = () => false;
+
+export default shouldRenderAddPaymentCard;
diff --git a/src/libs/shouldRenderAppPaymentCard/index.ts b/src/libs/shouldRenderAppPaymentCard/index.ts
new file mode 100644
index 000000000000..9b2ee6082e02
--- /dev/null
+++ b/src/libs/shouldRenderAppPaymentCard/index.ts
@@ -0,0 +1,5 @@
+import type ShouldRenderAddPaymentCard from './types';
+
+const shouldRenderAddPaymentCard: ShouldRenderAddPaymentCard = () => true;
+
+export default shouldRenderAddPaymentCard;
diff --git a/src/libs/shouldRenderAppPaymentCard/types.ts b/src/libs/shouldRenderAppPaymentCard/types.ts
new file mode 100644
index 000000000000..80e934badab2
--- /dev/null
+++ b/src/libs/shouldRenderAppPaymentCard/types.ts
@@ -0,0 +1,3 @@
+type ShouldRenderAddPaymentCard = () => boolean;
+
+export default ShouldRenderAddPaymentCard;
diff --git a/src/libs/shouldShowSubscriptionsMenu/index.native.ts b/src/libs/shouldShowSubscriptionsMenu/index.native.ts
deleted file mode 100644
index c98302e9a87d..000000000000
--- a/src/libs/shouldShowSubscriptionsMenu/index.native.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import type ShouldShowSubscriptionsMenu from './types';
-
-/**
- * Indicates whether the subscription menu should show in the all settings screen
- */
-const shouldShowSubscriptionsMenu: ShouldShowSubscriptionsMenu = false;
-
-export default shouldShowSubscriptionsMenu;
diff --git a/src/libs/shouldShowSubscriptionsMenu/index.ts b/src/libs/shouldShowSubscriptionsMenu/index.ts
deleted file mode 100644
index 2f2b7f17c2c5..000000000000
--- a/src/libs/shouldShowSubscriptionsMenu/index.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import type ShouldShowSubscriptionsMenu from './types';
-
-/**
- * Indicates whether the subscription menu should show in the all settings screen
- */
-const shouldShowSubscriptionsMenu: ShouldShowSubscriptionsMenu = true;
-
-export default shouldShowSubscriptionsMenu;
diff --git a/src/libs/shouldShowSubscriptionsMenu/types.tsx b/src/libs/shouldShowSubscriptionsMenu/types.tsx
deleted file mode 100644
index e72b55234639..000000000000
--- a/src/libs/shouldShowSubscriptionsMenu/types.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-type ShouldShowSubscriptionsMenu = boolean;
-
-export default ShouldShowSubscriptionsMenu;
diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx
index 52e2d817e6db..f5bd14ed7aa1 100644
--- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx
+++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useEffect, useState} from 'react';
+import React, {useCallback, useState} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import FormProvider from '@components/Form/FormProvider';
@@ -12,6 +12,7 @@ import Text from '@components/Text';
import TextInput from '@components/TextInput';
import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
+import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape';
import useLocalize from '@hooks/useLocalize';
import useOnboardingLayout from '@hooks/useOnboardingLayout';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -45,9 +46,7 @@ function BaseOnboardingPersonalDetails({
const [shouldValidateOnChange, setShouldValidateOnChange] = useState(false);
const {accountID} = useSession();
- useEffect(() => {
- Welcome.setOnboardingErrorMessage('');
- }, []);
+ useDisableModalDismissOnEscape();
const completeEngagement = useCallback(
(values: FormOnyxValues<'onboardingPersonalDetailsForm'>) => {
diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx
index 7304c1822ae9..03a4b790bc5f 100644
--- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx
+++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx
@@ -2,7 +2,7 @@ import {useIsFocused} from '@react-navigation/native';
import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import {ScrollView} from 'react-native-gesture-handler';
-import {useOnyx} from 'react-native-onyx';
+import {withOnyx} from 'react-native-onyx';
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import Icon from '@components/Icon';
@@ -13,6 +13,7 @@ import MenuItemList from '@components/MenuItemList';
import OfflineIndicator from '@components/OfflineIndicator';
import SafeAreaConsumer from '@components/SafeAreaConsumer';
import Text from '@components/Text';
+import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape';
import useLocalize from '@hooks/useLocalize';
import useOnboardingLayout from '@hooks/useOnboardingLayout';
import useTheme from '@hooks/useTheme';
@@ -27,8 +28,7 @@ import type {OnboardingPurposeType} from '@src/CONST';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
-import type {BaseOnboardingPurposeProps} from './types';
+import type {BaseOnboardingPurposeOnyxProps, BaseOnboardingPurposeProps} from './types';
const menuIcons = {
[CONST.ONBOARDING_CHOICES.EMPLOYER]: Illustrations.ReceiptUpload,
@@ -38,15 +38,15 @@ const menuIcons = {
[CONST.ONBOARDING_CHOICES.LOOKING_AROUND]: Illustrations.Binoculars,
};
-function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight}: BaseOnboardingPurposeProps) {
+function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, onboardingPurposeSelected}: BaseOnboardingPurposeProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {shouldUseNarrowLayout} = useOnboardingLayout();
const [selectedPurpose, setSelectedPurpose] = useState(undefined);
const {isSmallScreenWidth, windowHeight} = useWindowDimensions();
const theme = useTheme();
- const [onboardingPurposeSelected, onboardingPurposeSelectedResult] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED);
- const [onboardingErrorMessage, onboardingErrorMessageResult] = useOnyx(ONYXKEYS.ONBOARDING_ERROR_MESSAGE);
+
+ useDisableModalDismissOnEscape();
const PurposeFooterInstance = ;
@@ -83,6 +83,8 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight}: B
Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS);
}, [selectedPurpose]);
+ const [errorMessage, setErrorMessage] = useState('');
+
const menuItems: MenuItemProps[] = Object.values(CONST.ONBOARDING_CHOICES).map((choice) => {
const translationKey = `onboarding.purpose.${choice}` as const;
const isSelected = selectedPurpose === choice;
@@ -101,7 +103,7 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight}: B
numberOfLinesTitle: 0,
onPress: () => {
Welcome.setOnboardingPurposeSelected(choice);
- Welcome.setOnboardingErrorMessage('');
+ setErrorMessage('');
},
};
});
@@ -109,18 +111,15 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight}: B
const handleOuterClick = useCallback(() => {
if (!selectedPurpose) {
- Welcome.setOnboardingErrorMessage(translate('onboarding.purpose.errorSelection'));
+ setErrorMessage(translate('onboarding.purpose.errorSelection'));
} else {
- Welcome.setOnboardingErrorMessage(translate('onboarding.purpose.errorContinue'));
+ setErrorMessage(translate('onboarding.purpose.errorContinue'));
}
- }, [selectedPurpose, translate]);
+ }, [selectedPurpose, setErrorMessage, translate]);
const onboardingLocalRef = useRef(null);
useImperativeHandle(isFocused ? OnboardingRefManager.ref : onboardingLocalRef, () => ({handleOuterClick}), [handleOuterClick]);
- if (isLoadingOnyxValue(onboardingPurposeSelectedResult, onboardingErrorMessageResult)) {
- return null;
- }
return (
{({safeAreaPaddingBottomStyle}) => (
@@ -149,14 +148,14 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight}: B
buttonText={translate('common.continue')}
onSubmit={() => {
if (!selectedPurpose) {
- Welcome.setOnboardingErrorMessage(translate('onboarding.purpose.errorSelection'));
+ setErrorMessage(translate('onboarding.purpose.errorSelection'));
return;
}
- Welcome.setOnboardingErrorMessage('');
+ setErrorMessage('');
saveAndNavigate();
}}
- message={onboardingErrorMessage}
- isAlertVisible={!!onboardingErrorMessage}
+ message={errorMessage}
+ isAlertVisible={!!errorMessage}
containerStyles={[styles.w100, styles.mb5, styles.mh0, paddingHorizontal]}
/>
@@ -167,6 +166,10 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight}: B
BaseOnboardingPurpose.displayName = 'BaseOnboardingPurpose';
-export default BaseOnboardingPurpose;
+export default withOnyx({
+ onboardingPurposeSelected: {
+ key: ONYXKEYS.ONBOARDING_PURPOSE_SELECTED,
+ },
+})(BaseOnboardingPurpose);
export type {BaseOnboardingPurposeProps};
diff --git a/src/pages/OnboardingPurpose/types.ts b/src/pages/OnboardingPurpose/types.ts
index 17970dbab9a6..8c8f11503f1a 100644
--- a/src/pages/OnboardingPurpose/types.ts
+++ b/src/pages/OnboardingPurpose/types.ts
@@ -1,11 +1,20 @@
-type OnboardingPurposeProps = Record;
+import type {OnyxEntry} from 'react-native-onyx';
+import type {OnboardingPurposeType} from '@src/CONST';
-type BaseOnboardingPurposeProps = OnboardingPurposeProps & {
- /* Whether to use native styles tailored for native devices */
- shouldUseNativeStyles: boolean;
+type OnboardingPurposeProps = Record;
- /** Whether to use the maxHeight (true) or use the 100% of the height (false) */
- shouldEnableMaxHeight: boolean;
+type BaseOnboardingPurposeOnyxProps = {
+ /** Saved onboarding purpose selected by the user */
+ onboardingPurposeSelected: OnyxEntry;
};
-export type {BaseOnboardingPurposeProps, OnboardingPurposeProps};
+type BaseOnboardingPurposeProps = OnboardingPurposeProps &
+ BaseOnboardingPurposeOnyxProps & {
+ /* Whether to use native styles tailored for native devices */
+ shouldUseNativeStyles: boolean;
+
+ /** Whether to use the maxHeight (true) or use the 100% of the height (false) */
+ shouldEnableMaxHeight: boolean;
+ };
+
+export type {BaseOnboardingPurposeOnyxProps, BaseOnboardingPurposeProps, OnboardingPurposeProps};
diff --git a/src/pages/OnboardingWork/BaseOnboardingWork.tsx b/src/pages/OnboardingWork/BaseOnboardingWork.tsx
index 14f9223f6c67..9b8824300d30 100644
--- a/src/pages/OnboardingWork/BaseOnboardingWork.tsx
+++ b/src/pages/OnboardingWork/BaseOnboardingWork.tsx
@@ -9,6 +9,7 @@ import KeyboardAvoidingView from '@components/KeyboardAvoidingView';
import OfflineIndicator from '@components/OfflineIndicator';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
+import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape';
import useLocalize from '@hooks/useLocalize';
import useOnboardingLayout from '@hooks/useOnboardingLayout';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -32,6 +33,8 @@ function BaseOnboardingWork({shouldUseNativeStyles, onboardingPurposeSelected, o
const {isSmallScreenWidth} = useWindowDimensions();
const {shouldUseNarrowLayout} = useOnboardingLayout();
+ useDisableModalDismissOnEscape();
+
const completeEngagement = useCallback(
(values: FormOnyxValues<'onboardingWorkForm'>) => {
if (!onboardingPurposeSelected) {
diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx
index a585f9c94d67..ea9746aaeb77 100644
--- a/src/pages/ReportDetailsPage.tsx
+++ b/src/pages/ReportDetailsPage.tsx
@@ -24,11 +24,11 @@ import ScrollView from '@components/ScrollView';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
+import usePaginatedReportActions from '@hooks/usePaginatedReportActions';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import type {ReportDetailsNavigatorParamList} from '@libs/Navigation/types';
import * as OptionsListUtils from '@libs/OptionsListUtils';
-import PaginationUtils from '@libs/PaginationUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
@@ -86,18 +86,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
// The app would crash due to subscribing to the entire report collection if parentReportID is an empty string. So we should have a fallback ID here.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID || '-1'}`);
- const [sortedAllReportActions = []] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID || '-1'}`, {
- canEvict: false,
- selector: (allReportActions: OnyxEntry) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true),
- });
- const [reportActionPages = []] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${report.reportID || '-1'}`);
-
- const reportActions = useMemo(() => {
- if (!sortedAllReportActions.length) {
- return [];
- }
- return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages, (reportAction) => reportAction.reportActionID);
- }, [sortedAllReportActions, reportActionPages]);
+ const {reportActions} = usePaginatedReportActions(report.reportID || '-1');
const transactionThreadReportID = useMemo(
() => ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, reportActions ?? [], isOffline),
diff --git a/src/pages/Search/SearchPageBottomTab.tsx b/src/pages/Search/SearchPageBottomTab.tsx
index eb662fd49046..9ae6423a6cc1 100644
--- a/src/pages/Search/SearchPageBottomTab.tsx
+++ b/src/pages/Search/SearchPageBottomTab.tsx
@@ -1,9 +1,10 @@
import type {StackScreenProps} from '@react-navigation/stack';
-import React, {useMemo} from 'react';
+import React, {useMemo, useState} from 'react';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import Search from '@components/Search';
-import useActiveRoute from '@hooks/useActiveRoute';
+import useActiveBottomTabRoute from '@hooks/useActiveBottomTabRoute';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
@@ -27,8 +28,9 @@ const defaultSearchProps = {
function SearchPageBottomTab() {
const {translate} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
- const activeRoute = useActiveRoute();
+ const activeBottomTabRoute = useActiveBottomTabRoute();
const styles = useThemeStyles();
+ const [isMobileSelectionModeActive, setIsMobileSelectionModeActive] = useState(false);
const {
query: rawQuery,
@@ -36,11 +38,11 @@ function SearchPageBottomTab() {
sortBy,
sortOrder,
} = useMemo(() => {
- if (activeRoute?.name !== SCREENS.SEARCH.CENTRAL_PANE || !activeRoute.params) {
+ if (activeBottomTabRoute?.name !== SCREENS.SEARCH.CENTRAL_PANE || !activeBottomTabRoute.params) {
return defaultSearchProps;
}
- return {...defaultSearchProps, ...activeRoute.params} as SearchPageProps['route']['params'];
- }, [activeRoute]);
+ return {...defaultSearchProps, ...activeBottomTabRoute.params} as SearchPageProps['route']['params'];
+ }, [activeBottomTabRoute]);
const query = rawQuery as SearchQuery;
@@ -59,18 +61,29 @@ function SearchPageBottomTab() {
onBackButtonPress={handleOnBackButtonPress}
shouldShowLink={false}
>
-
-
+ {!isMobileSelectionModeActive ? (
+ <>
+
+
+ >
+ ) : (
+ setIsMobileSelectionModeActive(false)}
+ />
+ )}
{isSmallScreenWidth && (
)}
diff --git a/src/pages/Search/SearchSelectedNarrow.tsx b/src/pages/Search/SearchSelectedNarrow.tsx
new file mode 100644
index 000000000000..b90142ff5873
--- /dev/null
+++ b/src/pages/Search/SearchSelectedNarrow.tsx
@@ -0,0 +1,57 @@
+import React, {useRef, useState} from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
+import MenuItem from '@components/MenuItem';
+import Modal from '@components/Modal';
+import type {SearchHeaderOptionValue} from '@components/Search/SearchPageHeader';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as Expensicons from '@src/components/Icon/Expensicons';
+import CONST from '@src/CONST';
+
+type SearchSelectedNarrowProps = {options: Array>; itemsLength: number};
+
+function SearchSelectedNarrow({options, itemsLength}: SearchSelectedNarrowProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const [isModalVisible, setIsModalVisible] = useState(false);
+ const buttonRef = useRef(null);
+
+ const openMenu = () => setIsModalVisible(true);
+ const closeMenu = () => setIsModalVisible(false);
+
+ return (
+
+
+
+
+ {options.map((option) => (
+
+ ))}
+
+
+ );
+}
+
+SearchSelectedNarrow.displayName = 'SearchSelectedNarrow';
+
+export default SearchSelectedNarrow;
diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx
index 6491245469a1..f4b814ffc7fe 100644
--- a/src/pages/home/ReportScreen.tsx
+++ b/src/pages/home/ReportScreen.tsx
@@ -26,17 +26,16 @@ import useDeepCompareRef from '@hooks/useDeepCompareRef';
import useIsReportOpenInRHP from '@hooks/useIsReportOpenInRHP';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
+import usePaginatedReportActions from '@hooks/usePaginatedReportActions';
import usePermissions from '@hooks/usePermissions';
import usePrevious from '@hooks/usePrevious';
import useThemeStyles from '@hooks/useThemeStyles';
import useViewportOffsetTop from '@hooks/useViewportOffsetTop';
import useWindowDimensions from '@hooks/useWindowDimensions';
-import {getCurrentUserAccountID} from '@libs/actions/Report';
import Timing from '@libs/actions/Timing';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import clearReportNotifications from '@libs/Notification/clearReportNotifications';
-import PaginationUtils from '@libs/PaginationUtils';
import Performance from '@libs/Performance';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
@@ -70,12 +69,6 @@ type ReportScreenOnyxProps = {
/** The policies which the user has access to */
policies: OnyxCollection;
- /** An array containing all report actions related to this report, sorted based on a date criterion */
- sortedAllReportActions: OnyxTypes.ReportAction[];
-
- /** Pagination data for sortedAllReportActions */
- reportActionPages: OnyxEntry;
-
/** Additional report details */
reportNameValuePairs: OnyxEntry;
@@ -124,8 +117,6 @@ function ReportScreen({
betas = [],
route,
reportNameValuePairs,
- sortedAllReportActions,
- reportActionPages,
reportMetadata = {
isLoadingInitialReportActions: true,
isLoadingOlderReportActions: false,
@@ -290,12 +281,9 @@ function ReportScreen({
const prevReport = usePrevious(report);
const prevUserLeavingStatus = usePrevious(userLeavingStatus);
const [isLinkingToMessage, setIsLinkingToMessage] = useState(!!reportActionIDFromRoute);
- const reportActions = useMemo(() => {
- if (!sortedAllReportActions.length) {
- return [];
- }
- return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, reportActionIDFromRoute);
- }, [reportActionIDFromRoute, sortedAllReportActions, reportActionPages]);
+
+ const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: (value) => value?.accountID});
+ const {reportActions, linkedAction} = usePaginatedReportActions(report.reportID, reportActionIDFromRoute);
// Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger.
// If we have cached reportActions, they will be shown immediately.
@@ -320,10 +308,6 @@ function ReportScreen({
const screenWrapperStyle: ViewStyle[] = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}];
const isEmptyChat = useMemo(() => ReportUtils.isEmptyReport(report), [report]);
const isOptimisticDelete = report.statusNum === CONST.REPORT.STATUS_NUM.CLOSED;
- const isLinkedMessageAvailable = useMemo(
- (): boolean => sortedAllReportActions.findIndex((obj) => String(obj.reportActionID) === String(reportActionIDFromRoute)) > -1,
- [sortedAllReportActions, reportActionIDFromRoute],
- );
// If there's a non-404 error for the report we should show it instead of blocking the screen
const hasHelpfulErrors = Object.keys(report?.errorFields ?? {}).some((key) => key !== 'notFound');
@@ -421,7 +405,7 @@ function ReportScreen({
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const isLoading = isLoadingApp || !reportIDFromRoute || (!isSidebarLoaded && !isReportOpenInRHP) || PersonalDetailsUtils.isPersonalDetailsEmpty();
const shouldShowSkeleton =
- !isLinkedMessageAvailable &&
+ !linkedAction &&
(isLinkingToMessage ||
!isCurrentReportLoadedFromOnyx ||
(reportActions.length === 0 && !!reportMetadata?.isLoadingInitialReportActions) ||
@@ -691,28 +675,22 @@ function ReportScreen({
fetchReport();
}, [fetchReport]);
- const {isLinkedReportActionDeleted, isInaccessibleWhisper} = useMemo(() => {
- const currentUserAccountID = getCurrentUserAccountID();
- if (!reportActionIDFromRoute || !sortedAllReportActions) {
- return {isLinkedReportActionDeleted: false, isInaccessibleWhisper: false};
- }
- const action = sortedAllReportActions.find((item) => item.reportActionID === reportActionIDFromRoute);
- return {
- isLinkedReportActionDeleted: action && !ReportActionsUtils.shouldReportActionBeVisible(action, action.reportActionID),
- isInaccessibleWhisper: action && ReportActionsUtils.isWhisperAction(action) && !(action?.whisperedToAccountIDs ?? []).includes(currentUserAccountID),
- };
- }, [reportActionIDFromRoute, sortedAllReportActions]);
+ const isLinkedActionDeleted = useMemo(() => !!linkedAction && !ReportActionsUtils.shouldReportActionBeVisible(linkedAction, linkedAction.reportActionID), [linkedAction]);
+ const isLinkedActionInaccessibleWhisper = useMemo(
+ () => !!linkedAction && ReportActionsUtils.isWhisperAction(linkedAction) && !(linkedAction?.whisperedToAccountIDs ?? []).includes(currentUserAccountID),
+ [currentUserAccountID, linkedAction],
+ );
// If user redirects to an inaccessible whisper via a deeplink, on a report they have access to,
// then we set reportActionID as empty string, so we display them the report and not the "Not found page".
useEffect(() => {
- if (!isInaccessibleWhisper) {
+ if (!isLinkedActionInaccessibleWhisper) {
return;
}
Navigation.isNavigationReady().then(() => {
Navigation.setParams({reportActionID: ''});
});
- }, [isInaccessibleWhisper]);
+ }, [isLinkedActionInaccessibleWhisper]);
useEffect(() => {
if (!!report.lastReadTime || !ReportUtils.isTaskReport(report)) {
@@ -722,7 +700,7 @@ function ReportScreen({
Report.readNewestAction(report.reportID);
}, [report]);
- if ((!isInaccessibleWhisper && isLinkedReportActionDeleted) ?? (!shouldShowSkeleton && reportActionIDFromRoute && reportActions?.length === 0 && !isLinkingToMessage)) {
+ if ((!isLinkedActionInaccessibleWhisper && isLinkedActionDeleted) ?? (!shouldShowSkeleton && reportActionIDFromRoute && reportActions?.length === 0 && !isLinkingToMessage)) {
return (
`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getReportID(route)}`,
- canEvict: false,
- selector: (allReportActions: OnyxEntry) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true),
- },
- reportActionPages: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${getReportID(route)}`,
- },
reportNameValuePairs: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${getReportID(route)}`,
allowStaleData: true,
@@ -873,7 +843,6 @@ export default withCurrentReportID(
ReportScreen,
(prevProps, nextProps) =>
prevProps.isSidebarLoaded === nextProps.isSidebarLoaded &&
- lodashIsEqual(prevProps.sortedAllReportActions, nextProps.sortedAllReportActions) &&
lodashIsEqual(prevProps.reportMetadata, nextProps.reportMetadata) &&
lodashIsEqual(prevProps.betas, nextProps.betas) &&
lodashIsEqual(prevProps.policies, nextProps.policies) &&
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
index 1df45303694a..72d387b07f52 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
@@ -724,6 +724,8 @@ function ComposerWithSuggestions(
}, []);
useEffect(() => {
+ // We use the tag to store the native ID of the text input. Later, we use it in onSelectionChange to pick up the proper text input data.
+
tag.value = findNodeHandle(textInputRef.current) ?? -1;
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, []);
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx
index 682d3e8605b9..beebccd131b3 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx
@@ -18,6 +18,12 @@ function IncrementRenderCount() {
}
function ComposerWithSuggestionsE2e(props: ComposerWithSuggestionsProps, ref: ForwardedRef) {
+ 'use no memo';
+
+ // we rely on waterfall rendering in react, so we intentionally disable compiler
+ // for this component. This file is only used for e2e tests, so it's okay to
+ // disable compiler for this file.
+
// Eventually Auto focus on e2e tests
useEffect(() => {
const testConfig = E2EClient.getCurrentActiveTestConfig();
diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
index bf82a28dd48a..9fede8068e64 100644
--- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
+++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
@@ -60,6 +60,7 @@ type SuggestionsRef = {
updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void;
setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void;
getSuggestions: () => Mention[] | Emoji[];
+ getIsSuggestionsMenuVisible: () => boolean;
};
type ReportActionComposeOnyxProps = {
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx
index 9b04fd7df4dc..8d5a544afd42 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx
+++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx
@@ -150,7 +150,7 @@ function SuggestionEmoji(
*/
const calculateEmojiSuggestion = useCallback(
(selectionStart?: number, selectionEnd?: number) => {
- if (selectionStart !== selectionEnd || !selectionEnd || shouldBlockCalc.current || !value) {
+ if (selectionStart !== selectionEnd || !selectionEnd || shouldBlockCalc.current || !value || (selectionStart === 0 && selectionEnd === 0)) {
shouldBlockCalc.current = false;
resetSuggestions();
return;
@@ -181,6 +181,7 @@ function SuggestionEmoji(
if (!isComposerFocused) {
return;
}
+
calculateEmojiSuggestion(selection.start, selection.end);
}, [selection, calculateEmojiSuggestion, isComposerFocused]);
@@ -193,6 +194,8 @@ function SuggestionEmoji(
const getSuggestions = useCallback(() => suggestionValues.suggestedEmojis, [suggestionValues]);
+ const getIsSuggestionsMenuVisible = useCallback(() => isEmojiSuggestionsMenuVisible, [isEmojiSuggestionsMenuVisible]);
+
useImperativeHandle(
ref,
() => ({
@@ -201,8 +204,9 @@ function SuggestionEmoji(
setShouldBlockSuggestionCalc,
updateShouldShowSuggestionMenuToFalse,
getSuggestions,
+ getIsSuggestionsMenuVisible,
}),
- [resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions],
+ [resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions, getIsSuggestionsMenuVisible],
);
if (!isEmojiSuggestionsMenuVisible) {
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
index 129c8c822d74..86a05bad1994 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
+++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
@@ -408,6 +408,7 @@ function SuggestionMention(
);
const getSuggestions = useCallback(() => suggestionValues.suggestedMentions, [suggestionValues]);
+ const getIsSuggestionsMenuVisible = useCallback(() => isMentionSuggestionsMenuVisible, [isMentionSuggestionsMenuVisible]);
useImperativeHandle(
ref,
@@ -417,8 +418,9 @@ function SuggestionMention(
setShouldBlockSuggestionCalc,
updateShouldShowSuggestionMenuToFalse,
getSuggestions,
+ getIsSuggestionsMenuVisible,
}),
- [resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions],
+ [resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions, getIsSuggestionsMenuVisible],
);
if (!isMentionSuggestionsMenuVisible) {
diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.tsx b/src/pages/home/report/ReportActionCompose/Suggestions.tsx
index f82b38c3e154..158c60b0e89a 100644
--- a/src/pages/home/report/ReportActionCompose/Suggestions.tsx
+++ b/src/pages/home/report/ReportActionCompose/Suggestions.tsx
@@ -124,6 +124,11 @@ function Suggestions(
suggestionEmojiRef.current?.setShouldBlockSuggestionCalc(shouldBlock);
suggestionMentionRef.current?.setShouldBlockSuggestionCalc(shouldBlock);
}, []);
+ const getIsSuggestionsMenuVisible = useCallback((): boolean => {
+ const isEmojiVisible = suggestionEmojiRef.current?.getIsSuggestionsMenuVisible() ?? false;
+ const isSuggestionVisible = suggestionMentionRef.current?.getIsSuggestionsMenuVisible() ?? false;
+ return isEmojiVisible || isSuggestionVisible;
+ }, []);
useImperativeHandle(
ref,
@@ -134,8 +139,9 @@ function Suggestions(
updateShouldShowSuggestionMenuToFalse,
setShouldBlockSuggestionCalc,
getSuggestions,
+ getIsSuggestionsMenuVisible,
}),
- [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions],
+ [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions, getIsSuggestionsMenuVisible],
);
useEffect(() => {
diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx
index 9135e494794e..31bd21adce4c 100644
--- a/src/pages/home/report/ReportActionItem.tsx
+++ b/src/pages/home/report/ReportActionItem.tsx
@@ -50,6 +50,7 @@ import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
import SelectionScraper from '@libs/SelectionScraper';
+import shouldRenderAddPaymentCard from '@libs/shouldRenderAppPaymentCard';
import {ReactionListContext} from '@pages/home/ReportScreenContext';
import * as BankAccounts from '@userActions/BankAccounts';
import * as EmojiPickerAction from '@userActions/EmojiPickerAction';
@@ -398,6 +399,20 @@ function ReportActionItem({
const attachmentContextValue = useMemo(() => ({reportID: report.reportID, type: CONST.ATTACHMENT_TYPE.REPORT}), [report.reportID]);
const actionableItemButtons: ActionableItem[] = useMemo(() => {
+ if (ReportActionsUtils.isActionableAddPaymentCard(action) && shouldRenderAddPaymentCard()) {
+ return [
+ {
+ text: 'subscription.cardSection.addCardButton',
+ key: `${action.reportActionID}-actionableAddPaymentCard-submit`,
+ onPress: () => {
+ Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION);
+ },
+ isMediumSized: true,
+ isPrimary: true,
+ },
+ ];
+ }
+
if (!isActionableWhisper && (!ReportActionsUtils.isActionableJoinRequest(action) || ReportActionsUtils.getOriginalMessage(action)?.choice !== ('' as JoinWorkspaceResolution))) {
return [];
}
diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx
index cb68410131ce..b34cd68730f0 100644
--- a/src/pages/home/report/ReportActionItemMessageEdit.tsx
+++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx
@@ -1,11 +1,15 @@
import lodashDebounce from 'lodash/debounce';
import type {ForwardedRef} from 'react';
import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react';
-import type {NativeSyntheticEvent, TextInput, TextInputFocusEventData, TextInputKeyPressEventData} from 'react-native';
-import {InteractionManager, Keyboard, View} from 'react-native';
+import {findNodeHandle, InteractionManager, Keyboard, View} from 'react-native';
+import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInput, TextInputFocusEventData, TextInputKeyPressEventData, TextInputScrollEventData} from 'react-native';
+import {useFocusedInputHandler} from 'react-native-keyboard-controller';
import {useOnyx} from 'react-native-onyx';
+import {useSharedValue} from 'react-native-reanimated';
import type {Emoji} from '@assets/emojis/types';
+import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types';
import Composer from '@components/Composer';
+import type {TextSelection} from '@components/Composer/types';
import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton';
import ExceededCommentLength from '@components/ExceededCommentLength';
import Icon from '@components/Icon';
@@ -41,6 +45,10 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type * as OnyxTypes from '@src/types/onyx';
import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu';
+import getCursorPosition from './ReportActionCompose/getCursorPosition';
+import getScrollPosition from './ReportActionCompose/getScrollPosition';
+import type {SuggestionsRef} from './ReportActionCompose/ReportActionCompose';
+import Suggestions from './ReportActionCompose/Suggestions';
import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection';
type ReportActionItemMessageEditProps = {
@@ -80,12 +88,17 @@ function ReportActionItemMessageEdit(
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
+ const containerRef = useRef(null);
const reportScrollManager = useReportScrollManager();
const {translate, preferredLocale} = useLocalize();
const {isKeyboardShown} = useKeyboardState();
const {isSmallScreenWidth} = useWindowDimensions();
const prevDraftMessage = usePrevious(draftMessage);
-
+ const suggestionsRef = useRef(null);
+ const mobileInputScrollPosition = useRef(0);
+ const cursorPositionValue = useSharedValue({x: 0, y: 0});
+ const tag = useSharedValue(-1);
+ const isInitialMount = useRef(true);
const emojisPresentBefore = useRef([]);
const [draft, setDraft] = useState(() => {
if (draftMessage) {
@@ -93,7 +106,7 @@ function ReportActionItemMessageEdit(
}
return draftMessage;
});
- const [selection, setSelection] = useState({start: draft.length, end: draft.length});
+ const [selection, setSelection] = useState({start: draft.length, end: draft.length, positionX: 0, positionY: 0});
const [isFocused, setIsFocused] = useState(false);
const {hasExceededMaxCommentLength, validateCommentMaxLength} = useHandleExceedMaxCommentLength();
const [modal, setModal] = useState({
@@ -121,11 +134,6 @@ function ReportActionItemMessageEdit(
setDraft(draftMessage);
}, [draftMessage, action, prevDraftMessage]);
- useEffect(() => {
- // required for keeping last state of isFocused variable
- isFocusedRef.current = isFocused;
- }, [isFocused]);
-
useEffect(() => {
InputFocus.composerFocusKeepFocusOn(textInputRef.current as HTMLElement, isFocused, modal, onyxFocused);
}, [isFocused, modal, onyxFocused]);
@@ -163,25 +171,32 @@ function ReportActionItemMessageEdit(
);
useEffect(
- () => () => {
- InputFocus.callback(() => setIsFocused(false));
- InputFocus.inputFocusChange(false);
-
- // Skip if the current report action is not active
- if (!isActive()) {
+ () => {
+ if (isInitialMount.current) {
+ isInitialMount.current = false;
return;
}
- if (EmojiPickerAction.isActive(action.reportActionID)) {
- EmojiPickerAction.clearActive();
- }
- if (ReportActionContextMenu.isActiveReportAction(action.reportActionID)) {
- ReportActionContextMenu.clearActiveReportAction();
- }
+ return () => {
+ InputFocus.callback(() => setIsFocused(false));
+ InputFocus.inputFocusChange(false);
- // Show the main composer when the focused message is deleted from another client
- // to prevent the main composer stays hidden until we swtich to another chat.
- setShouldShowComposeInputKeyboardAware(true);
+ // Skip if the current report action is not active
+ if (!isActive()) {
+ return;
+ }
+
+ if (EmojiPickerAction.isActive(action.reportActionID)) {
+ EmojiPickerAction.clearActive();
+ }
+ if (ReportActionContextMenu.isActiveReportAction(action.reportActionID)) {
+ ReportActionContextMenu.clearActiveReportAction();
+ }
+
+ // Show the main composer when the focused message is deleted from another client
+ // to prevent the main composer stays hidden until we swtich to another chat.
+ setShouldShowComposeInputKeyboardAware(true);
+ };
},
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- this cleanup needs to be called only on unmount
[action.reportActionID],
@@ -226,10 +241,12 @@ function ReportActionItemMessageEdit(
setDraft(newDraft);
if (newDraftInput !== newDraft) {
- const position = Math.max(selection.end + (newDraft.length - draftRef.current.length), cursorPosition ?? 0);
+ const position = Math.max((selection?.end ?? 0) + (newDraft.length - draftRef.current.length), cursorPosition ?? 0);
setSelection({
start: position,
end: position,
+ positionX: 0,
+ positionY: 0,
});
}
@@ -296,6 +313,8 @@ function ReportActionItemMessageEdit(
const newSelection = {
start: selection.start + emoji.length + CONST.SPACE_LENGTH,
end: selection.start + emoji.length + CONST.SPACE_LENGTH,
+ positionX: 0,
+ positionY: 0,
};
setSelection(newSelection);
@@ -307,6 +326,21 @@ function ReportActionItemMessageEdit(
updateDraft(ComposerUtils.insertText(draft, selection, `${emoji} `));
};
+ const hideSuggestionMenu = useCallback(() => {
+ if (!suggestionsRef.current) {
+ return;
+ }
+ suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false);
+ }, [suggestionsRef]);
+ const onSaveScrollAndHideSuggestionMenu = useCallback(
+ (e: NativeSyntheticEvent) => {
+ mobileInputScrollPosition.current = e?.nativeEvent?.contentOffset?.y ?? 0;
+
+ hideSuggestionMenu();
+ },
+ [hideSuggestionMenu],
+ );
+
/**
* Key event handlers that short cut to saving/canceling.
*
@@ -318,6 +352,17 @@ function ReportActionItemMessageEdit(
return;
}
const keyEvent = e as KeyboardEvent;
+ const isSuggestionsMenuVisible = suggestionsRef.current?.getIsSuggestionsMenuVisible();
+
+ if (isSuggestionsMenuVisible && keyEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) {
+ suggestionsRef.current?.triggerHotkeyActions(keyEvent);
+ return;
+ }
+ if (keyEvent.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey && isSuggestionsMenuVisible) {
+ e.preventDefault();
+ hideSuggestionMenu();
+ return;
+ }
if (keyEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !keyEvent.shiftKey) {
e.preventDefault();
publishDraft();
@@ -326,7 +371,59 @@ function ReportActionItemMessageEdit(
deleteDraft();
}
},
- [deleteDraft, isKeyboardShown, isSmallScreenWidth, publishDraft],
+ [deleteDraft, hideSuggestionMenu, isKeyboardShown, isSmallScreenWidth, publishDraft],
+ );
+
+ const measureContainer = useCallback(
+ (callback: MeasureInWindowOnSuccessCallback) => {
+ if (!containerRef.current) {
+ return;
+ }
+ containerRef.current.measureInWindow(callback);
+ },
+ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
+ [isFocused],
+ );
+
+ const measureParentContainerAndReportCursor = useCallback(
+ (callback: MeasureParentContainerAndCursorCallback) => {
+ const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef});
+ const {x: xPosition, y: yPosition} = getCursorPosition({positionOnMobile: cursorPositionValue.value, positionOnWeb: selection});
+ measureContainer((x, y, width, height) => {
+ callback({
+ x,
+ y,
+ width,
+ height,
+ scrollValue,
+ cursorCoordinates: {x: xPosition, y: yPosition},
+ });
+ });
+ },
+ [cursorPositionValue.value, measureContainer, selection],
+ );
+
+ useEffect(() => {
+ // We use the tag to store the native ID of the text input. Later, we use it in onSelectionChange to pick up the proper text input data.
+
+ // eslint-disable-next-line react-compiler/react-compiler
+ tag.value = findNodeHandle(textInputRef.current) ?? -1;
+ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
+ }, []);
+ useFocusedInputHandler(
+ {
+ onSelectionChange: (event) => {
+ 'worklet';
+
+ if (event.target === tag.value) {
+ cursorPositionValue.value = {
+ x: event.selection.end.x,
+ y: event.selection.end.y,
+ };
+ }
+ },
+ },
+ [],
);
/**
@@ -338,9 +435,21 @@ function ReportActionItemMessageEdit(
validateCommentMaxLength(draft, {reportID});
}, [draft, reportID, validateCommentMaxLength]);
+ useEffect(() => {
+ // required for keeping last state of isFocused variable
+ isFocusedRef.current = isFocused;
+
+ if (!isFocused) {
+ hideSuggestionMenu();
+ }
+ }, [isFocused, hideSuggestionMenu]);
+
return (
<>
-
+
setSelection(e.nativeEvent.selection)}
isGroupPolicyReport={isGroupPolicyReport}
+ shouldCalculateCaretPosition
+ onScroll={onSaveScrollAndHideSuggestionMenu}
/>
+
+
+
{
Link.openOldDotLink(CONST.OLDDOT_URLS.ADMIN_POLICIES_URL);
@@ -89,7 +90,7 @@ function AllSettingsScreen({policies}: AllSettingsScreenProps) {
hoverAndPressStyle: styles.hoveredComponentBG,
brickRoadIndicator: item.brickRoadIndicator,
}));
- }, [isSmallScreenWidth, styles.hoveredComponentBG, styles.sectionMenuItem, translate, waitForNavigate, policies]);
+ }, [isSmallScreenWidth, policies, privateSubscription, waitForNavigate, translate, styles]);
return (
{
+ if (transaction?.iouRequestType === newIOUType) {
+ return;
+ }
IOU.initMoneyRequest(reportID, policy, isFromGlobalCreate, newIOUType);
},
- [policy, reportID, isFromGlobalCreate],
+ [policy, reportID, isFromGlobalCreate, transaction],
);
if (!transaction?.transactionID) {
diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
index 5fef11fa4b15..af808f4a3040 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
@@ -2,8 +2,10 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
+import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
+import LocationPermissionModal from '@components/LocationPermissionModal';
import MoneyRequestConfirmationList from '@components/MoneyRequestConfirmationList';
import {usePersonalDetails} from '@components/OnyxProvider';
import ScreenWrapper from '@components/ScreenWrapper';
@@ -20,6 +22,7 @@ import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as ReportUtils from '@libs/ReportUtils';
+import playSound, {SOUNDS} from '@libs/Sound';
import * as TransactionUtils from '@libs/TransactionUtils';
import * as IOU from '@userActions/IOU';
import {openDraftWorkspaceRequest} from '@userActions/Policy/Policy';
@@ -81,6 +84,9 @@ function IOURequestStepConfirmation({
const {translate} = useLocalize();
const {windowWidth} = useWindowDimensions();
const {isOffline} = useNetwork();
+ const [startLocationPermissionFlow, setStartLocationPermissionFlow] = useState(false);
+ const [selectedParticipantList, setSelectedParticipantList] = useState([]);
+
const [receiptFile, setReceiptFile] = useState>();
const requestType = TransactionUtils.getRequestType(transaction);
const isDistanceRequest = requestType === CONST.IOU.REQUEST_TYPE.DISTANCE;
@@ -112,6 +118,8 @@ function IOURequestStepConfirmation({
};
}, [personalDetails, transaction?.participants, transaction?.splitPayerAccountIDs]);
+ const gpsRequired = transaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && receiptFile;
+
const headerTitle = useMemo(() => {
if (isCategorizingTrackExpense) {
return translate('iou.categorize');
@@ -187,7 +195,8 @@ function IOURequestStepConfirmation({
// If there is not a report attached to the IOU with a reportID, then the participants were manually selected and the user needs taken
// back to the participants step
if (!transaction?.participantsAutoAssigned) {
- Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID, undefined, action));
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, transaction?.reportID || reportID, undefined, action));
return;
}
IOUUtils.navigateToStartMoneyRequestStep(requestType, iouType, transactionID, reportID, action);
@@ -318,7 +327,7 @@ function IOURequestStepConfirmation({
);
const createTransaction = useCallback(
- (selectedParticipants: Participant[]) => {
+ (selectedParticipants: Participant[], locationPermissionGranted = false) => {
let splitParticipants = selectedParticipants;
// Filter out participants with an amount equal to O
@@ -339,6 +348,8 @@ function IOURequestStepConfirmation({
formHasBeenSubmitted.current = true;
+ playSound(SOUNDS.DONE);
+
// If we have a receipt let's start the split expense by creating only the action, the transaction, and the group DM if needed
if (iouType === CONST.IOU.TYPE.SPLIT && receiptFile) {
if (currentUserPersonalDetails.login && !!transaction) {
@@ -420,7 +431,7 @@ function IOURequestStepConfirmation({
if (iouType === CONST.IOU.TYPE.TRACK || isCategorizingTrackExpense || isSharingTrackExpense) {
if (receiptFile && transaction) {
// If the transaction amount is zero, then the money is being requested through the "Scan" flow and the GPS coordinates need to be included.
- if (transaction.amount === 0 && !isSharingTrackExpense && !isCategorizingTrackExpense) {
+ if (transaction.amount === 0 && !isSharingTrackExpense && !isCategorizingTrackExpense && locationPermissionGranted) {
getCurrentPosition(
(successData) => {
trackExpense(selectedParticipants, trimmedComment, receiptFile, {
@@ -451,7 +462,7 @@ function IOURequestStepConfirmation({
if (receiptFile && !!transaction) {
// If the transaction amount is zero, then the money is being requested through the "Scan" flow and the GPS coordinates need to be included.
- if (transaction.amount === 0 && !isSharingTrackExpense && !isCategorizingTrackExpense) {
+ if (transaction.amount === 0 && !isSharingTrackExpense && !isCategorizingTrackExpense && locationPermissionGranted) {
getCurrentPosition(
(successData) => {
requestMoney(selectedParticipants, trimmedComment, receiptFile, {
@@ -540,6 +551,20 @@ function IOURequestStepConfirmation({
[transactionID],
);
+ // This loading indicator is shown because the transaction originalCurrency is being updated later than the component mounts.
+ // To prevent the component from rendering with the wrong currency, we show a loading indicator until the correct currency is set.
+ const isLoading = !!transaction?.originalCurrency;
+
+ const onConfirm = (listOfParticipants: Participant[]) => {
+ setSelectedParticipantList(listOfParticipants);
+ if (gpsRequired) {
+ setStartLocationPermissionFlow(true);
+ return;
+ }
+
+ createTransaction(listOfParticipants);
+ };
+
return (
+ {isLoading && }
+ {gpsRequired && (
+ setStartLocationPermissionFlow(false)}
+ onGrant={() => createTransaction(selectedParticipantList, true)}
+ onDeny={() => createTransaction(selectedParticipantList, false)}
+ />
+ )}
)}
diff --git a/src/pages/iou/request/step/IOURequestStepScan/LocationPermission/index.android.ts b/src/pages/iou/request/step/IOURequestStepScan/LocationPermission/index.android.ts
new file mode 100644
index 000000000000..bd2db49befbe
--- /dev/null
+++ b/src/pages/iou/request/step/IOURequestStepScan/LocationPermission/index.android.ts
@@ -0,0 +1,12 @@
+import {check, PERMISSIONS, request} from 'react-native-permissions';
+
+function requestLocationPermission() {
+ return request(PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION);
+}
+
+// Android will never return blocked after a check, you have to request the permission to get the info.
+function getLocationPermission() {
+ return check(PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION);
+}
+
+export {requestLocationPermission, getLocationPermission};
diff --git a/src/pages/iou/request/step/IOURequestStepScan/LocationPermission/index.ios.ts b/src/pages/iou/request/step/IOURequestStepScan/LocationPermission/index.ios.ts
new file mode 100644
index 000000000000..6d8dc6543bb2
--- /dev/null
+++ b/src/pages/iou/request/step/IOURequestStepScan/LocationPermission/index.ios.ts
@@ -0,0 +1,11 @@
+import {check, PERMISSIONS, request} from 'react-native-permissions';
+
+function requestLocationPermission() {
+ return request(PERMISSIONS.IOS.LOCATION_WHEN_IN_USE);
+}
+
+function getLocationPermission() {
+ return check(PERMISSIONS.IOS.LOCATION_WHEN_IN_USE);
+}
+
+export {requestLocationPermission, getLocationPermission};
diff --git a/src/pages/iou/request/step/IOURequestStepScan/LocationPermission/index.ts b/src/pages/iou/request/step/IOURequestStepScan/LocationPermission/index.ts
new file mode 100644
index 000000000000..6f6a87dbfef7
--- /dev/null
+++ b/src/pages/iou/request/step/IOURequestStepScan/LocationPermission/index.ts
@@ -0,0 +1,38 @@
+import {RESULTS} from 'react-native-permissions';
+import type {PermissionStatus} from 'react-native-permissions';
+import CONST from '@src/CONST';
+
+function requestLocationPermission(): Promise {
+ return new Promise((resolve) => {
+ if (navigator.geolocation) {
+ navigator.geolocation.getCurrentPosition(
+ () => resolve(RESULTS.GRANTED),
+ (error) => resolve(error.TIMEOUT || error.POSITION_UNAVAILABLE ? RESULTS.BLOCKED : RESULTS.DENIED),
+ {
+ timeout: CONST.GPS.TIMEOUT,
+ enableHighAccuracy: true,
+ },
+ );
+ } else {
+ resolve(RESULTS.UNAVAILABLE);
+ }
+ });
+}
+
+function getLocationPermission(): Promise {
+ return new Promise((resolve) => {
+ if (navigator.geolocation) {
+ navigator.permissions.query({name: 'geolocation'}).then((result) => {
+ if (result.state === 'prompt') {
+ resolve(RESULTS.DENIED);
+ return;
+ }
+ resolve(result.state === 'granted' ? RESULTS.GRANTED : RESULTS.BLOCKED);
+ });
+ } else {
+ resolve(RESULTS.UNAVAILABLE);
+ }
+ });
+}
+
+export {requestLocationPermission, getLocationPermission};
diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx
index 65acd3660f7b..d8051a5f7ed5 100755
--- a/src/pages/settings/InitialSettingsPage.tsx
+++ b/src/pages/settings/InitialSettingsPage.tsx
@@ -4,7 +4,7 @@ import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, use
import type {GestureResponderEvent, ScrollView as RNScrollView, ScrollViewProps, StyleProp, ViewStyle} from 'react-native';
import {NativeModules, View} from 'react-native';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx, withOnyx} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import AvatarWithImagePicker from '@components/AvatarWithImagePicker';
import ConfirmModal from '@components/ConfirmModal';
@@ -20,7 +20,7 @@ import Text from '@components/Text';
import Tooltip from '@components/Tooltip';
import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
-import useActiveRoute from '@hooks/useActiveRoute';
+import useActiveBottomTabRoute from '@hooks/useActiveBottomTabRoute';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useSingleExecution from '@hooks/useSingleExecution';
@@ -29,7 +29,6 @@ import useThemeStyles from '@hooks/useThemeStyles';
import useWaitForNavigation from '@hooks/useWaitForNavigation';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import Navigation from '@libs/Navigation/Navigation';
-import shouldShowSubscriptionsMenu from '@libs/shouldShowSubscriptionsMenu';
import * as SubscriptionUtils from '@libs/SubscriptionUtils';
import * as UserUtils from '@libs/UserUtils';
import {hasGlobalWorkspaceSettingsRBR} from '@libs/WorkspacesSettingsUtils';
@@ -105,9 +104,11 @@ function InitialSettingsPage({session, userWallet, bankAccountList, fundList, wa
const waitForNavigate = useWaitForNavigation();
const popoverAnchor = useRef(null);
const {translate, formatPhoneNumber} = useLocalize();
- const activeRoute = useActiveRoute();
+ const activeBottomTabRoute = useActiveBottomTabRoute();
const emojiCode = currentUserPersonalDetails?.status?.emojiCode ?? '';
+ const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION);
+
const [shouldShowSignoutConfirmModal, setShouldShowSignoutConfirmModal] = useState(false);
useEffect(() => {
@@ -202,16 +203,12 @@ function InitialSettingsPage({session, userWallet, bankAccountList, fundList, wa
},
];
- if (shouldShowSubscriptionsMenu) {
+ if (privateSubscription) {
items.splice(1, 0, {
- translationKey: 'allSettingsScreen.subscriptions',
- icon: Expensicons.MoneyBag,
- action: () => {
- Link.openOldDotLink(CONST.OLDDOT_URLS.ADMIN_POLICIES_URL);
- },
- shouldShowRightIcon: true,
- iconRight: Expensicons.NewWindow,
- link: () => Link.buildOldDotURL(CONST.OLDDOT_URLS.ADMIN_POLICIES_URL),
+ translationKey: 'allSettingsScreen.subscription',
+ icon: Expensicons.CreditCard,
+ routeName: ROUTES.SETTINGS_SUBSCRIPTION,
+ brickRoadIndicator: !!privateSubscription?.errors || SubscriptionUtils.hasSubscriptionRedDotError() ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined,
badgeText: SubscriptionUtils.isUserOnFreeTrial() ? translate('subscription.badge.freeTrial', {numOfDays: SubscriptionUtils.calculateRemainingFreeTrialDays()}) : undefined,
badgeStyle: SubscriptionUtils.isUserOnFreeTrial() ? styles.badgeSuccess : undefined,
});
@@ -222,7 +219,7 @@ function InitialSettingsPage({session, userWallet, bankAccountList, fundList, wa
sectionTranslationKey: 'common.workspaces',
items,
};
- }, [policies, styles.badgeSuccess, styles.workspaceSettingsSectionContainer, translate]);
+ }, [policies, privateSubscription, styles.badgeSuccess, styles.workspaceSettingsSectionContainer, translate]);
/**
* Retuns a list of menu items data for general section
@@ -332,7 +329,11 @@ function InitialSettingsPage({session, userWallet, bankAccountList, fundList, wa
hoverAndPressStyle={styles.hoveredComponentBG}
shouldBlockSelection={!!item.link}
onSecondaryInteraction={item.link ? (event) => openPopover(item.link, event) : undefined}
- focused={!!activeRoute && !!item.routeName && !!(activeRoute.name.toLowerCase().replaceAll('_', '') === item.routeName.toLowerCase().replaceAll('/', ''))}
+ focused={
+ !!activeBottomTabRoute &&
+ !!item.routeName &&
+ !!(activeBottomTabRoute.name.toLowerCase().replaceAll('_', '') === item.routeName.toLowerCase().replaceAll('/', ''))
+ }
isPaneMenu
iconRight={item.iconRight}
shouldShowRightIcon={item.shouldShowRightIcon}
@@ -352,7 +353,7 @@ function InitialSettingsPage({session, userWallet, bankAccountList, fundList, wa
userWallet?.currentBalance,
isExecuting,
singleExecution,
- activeRoute,
+ activeBottomTabRoute,
waitForNavigate,
],
);
diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/TrialEndedBillingBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/TrialEndedBillingBanner.tsx
new file mode 100644
index 000000000000..ef321bf72e88
--- /dev/null
+++ b/src/pages/settings/Subscription/CardSection/BillingBanner/TrialEndedBillingBanner.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import * as Illustrations from '@components/Icon/Illustrations';
+import useLocalize from '@hooks/useLocalize';
+import BillingBanner from './BillingBanner';
+
+function TrialEndedBillingBanner() {
+ const {translate} = useLocalize();
+
+ return (
+
+ );
+}
+
+TrialEndedBillingBanner.displayName = 'TrialEndedBillingBanner';
+
+export default TrialEndedBillingBanner;
diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx
index f3b78b3f2b95..4cc160fc13b2 100644
--- a/src/pages/settings/Subscription/CardSection/CardSection.tsx
+++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx
@@ -24,6 +24,7 @@ import ROUTES from '@src/ROUTES';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import PreTrialBillingBanner from './BillingBanner/PreTrialBillingBanner';
import SubscriptionBillingBanner from './BillingBanner/SubscriptionBillingBanner';
+import TrialEndedBillingBanner from './BillingBanner/TrialEndedBillingBanner';
import TrialStartedBillingBanner from './BillingBanner/TrialStartedBillingBanner';
import CardSectionActions from './CardSectionActions';
import CardSectionDataEmpty from './CardSectionDataEmpty';
@@ -76,6 +77,8 @@ function CardSection() {
BillingBanner = ;
} else if (SubscriptionUtils.isUserOnFreeTrial()) {
BillingBanner = ;
+ } else if (SubscriptionUtils.hasUserFreeTrialEnded()) {
+ BillingBanner = ;
} else if (billingStatus) {
BillingBanner = (
diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx
index f0c575007103..9a3584de7779 100755
--- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx
+++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx
@@ -4,6 +4,7 @@ import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import Button from '@components/Button';
+import SafariFormWrapper from '@components/Form/SafariFormWrapper';
import FormHelpMessage from '@components/FormHelpMessage';
import type {MagicCodeInputHandle} from '@components/MagicCodeInput';
import MagicCodeInput from '@components/MagicCodeInput';
@@ -291,7 +292,7 @@ function BaseValidateCodeForm({account, credentials, session, autoComplete, isUs
}, [account, credentials, twoFactorAuthCode, validateCode, isUsingRecoveryCode, recoveryCode]);
return (
- <>
+
{/* At this point, if we know the account requires 2FA we already successfully authenticated */}
{account?.requiresTwoFactorAuth ? (
@@ -402,7 +403,7 @@ function BaseValidateCodeForm({account, credentials, session, autoComplete, isUs
- >
+
);
}
diff --git a/src/pages/workspace/AccessOrNotFoundWrapper.tsx b/src/pages/workspace/AccessOrNotFoundWrapper.tsx
index c7f89559fdda..770358335680 100644
--- a/src/pages/workspace/AccessOrNotFoundWrapper.tsx
+++ b/src/pages/workspace/AccessOrNotFoundWrapper.tsx
@@ -24,6 +24,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject';
const ACCESS_VARIANTS = {
[CONST.POLICY.ACCESS_VARIANTS.PAID]: (policy: OnyxEntry) => PolicyUtils.isPaidGroupPolicy(policy),
[CONST.POLICY.ACCESS_VARIANTS.ADMIN]: (policy: OnyxEntry, login: string) => PolicyUtils.isPolicyAdmin(policy, login),
+ [CONST.POLICY.ACCESS_VARIANTS.CONTROL]: (policy: OnyxEntry) => PolicyUtils.isControlPolicy(policy),
[CONST.IOU.ACCESS_VARIANTS.CREATE]: (
policy: OnyxEntry,
login: string,
diff --git a/src/pages/workspace/categories/CategoryGLCodePage.tsx b/src/pages/workspace/categories/CategoryGLCodePage.tsx
new file mode 100644
index 000000000000..050ced2e497c
--- /dev/null
+++ b/src/pages/workspace/categories/CategoryGLCodePage.tsx
@@ -0,0 +1,87 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useCallback} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import type {FormOnyxValues} from '@components/Form/types';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import TextInput from '@components/TextInput';
+import useAutoFocusInput from '@hooks/useAutoFocusInput';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@navigation/types';
+import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
+import * as Category from '@userActions/Policy/Category';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import INPUT_IDS from '@src/types/form/WorkspaceCategoryForm';
+
+type EditCategoryPageProps = StackScreenProps;
+
+function CategoryGLCodePage({route}: EditCategoryPageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const policyId = route.params.policyID ?? '-1';
+ const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyId}`);
+
+ const categoryName = route.params.categoryName;
+ const glCode = policyCategories?.[categoryName]?.['GL Code'];
+ const {inputCallbackRef} = useAutoFocusInput();
+
+ const editGLCode = useCallback(
+ (values: FormOnyxValues) => {
+ const newGLCode = values.glCode.trim();
+ if (newGLCode !== glCode) {
+ Category.updatePolicyCategoryGLCode(route.params.policyID, categoryName, newGLCode);
+ }
+ Navigation.goBack();
+ },
+ [categoryName, glCode, route.params.policyID],
+ );
+
+ return (
+
+
+ Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(route.params.policyID, route.params.categoryName))}
+ />
+
+
+
+
+
+ );
+}
+
+CategoryGLCodePage.displayName = 'CategoryGLCodePage';
+
+export default CategoryGLCodePage;
diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx
index f5e6d3824a5f..54450a1117cd 100644
--- a/src/pages/workspace/categories/CategorySettingsPage.tsx
+++ b/src/pages/workspace/categories/CategorySettingsPage.tsx
@@ -134,6 +134,14 @@ function CategorySettingsPage({route, policyCategories, navigation}: CategorySet
shouldShowRightIcon
/>
+
+ Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_GL_CODE.getRoute(route.params.policyID, policyCategory.name))}
+ shouldShowRightIcon
+ />
+
{!isThereAnyAccountingConnection && (
;
+};
+
+type EditTagGLCodePageProps = WorkspaceEditTagGLCodePageOnyxProps & StackScreenProps;
+
+function TagGLCodePage({route, policyTags}: EditTagGLCodePageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {inputCallbackRef} = useAutoFocusInput();
+
+ const tagName = route.params.tagName;
+ const orderWeight = route.params.orderWeight;
+ const {tags} = PolicyUtils.getTagList(policyTags, orderWeight);
+ const glCode = tags?.[route.params.tagName]?.['GL Code'];
+
+ const editGLCode = useCallback(
+ (values: FormOnyxValues) => {
+ const newGLCode = values.glCode.trim();
+ if (newGLCode !== glCode) {
+ Tag.setPolicyTagGLCode(route.params.policyID, tagName, orderWeight, newGLCode);
+ }
+ Navigation.goBack();
+ },
+ [glCode, route.params.policyID, tagName, orderWeight],
+ );
+
+ return (
+
+
+ Navigation.goBack()}
+ />
+
+
+
+
+
+ );
+}
+
+TagGLCodePage.displayName = 'TagGLCodePage';
+
+export default withOnyx({
+ policyTags: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${route?.params?.policyID}`,
+ },
+})(TagGLCodePage);
diff --git a/src/pages/workspace/tags/TagSettingsPage.tsx b/src/pages/workspace/tags/TagSettingsPage.tsx
index 1e73267fec97..a059fc4848c7 100644
--- a/src/pages/workspace/tags/TagSettingsPage.tsx
+++ b/src/pages/workspace/tags/TagSettingsPage.tsx
@@ -70,6 +70,10 @@ function TagSettingsPage({route, policyTags, navigation}: TagSettingsPageProps)
Navigation.navigate(ROUTES.WORKSPACE_TAG_EDIT.getRoute(route.params.policyID, route.params.orderWeight, currentPolicyTag.name));
};
+ const navigateToEditGlCode = () => {
+ Navigation.navigate(ROUTES.WORKSPACE_TAG_GL_CODE.getRoute(route.params.policyID, route.params.orderWeight, currentPolicyTag.name));
+ };
+
const isThereAnyAccountingConnection = Object.keys(policy?.connections ?? {}).length !== 0;
const isMultiLevelTags = PolicyUtils.isMultiLevelTags(policyTags);
@@ -127,6 +131,14 @@ function TagSettingsPage({route, policyTags, navigation}: TagSettingsPageProps)
shouldShowRightIcon
/>
+
+
+
{shouldShowDeleteMenuItem && (
flex: 1,
},
+ lhnSuccessText: {
+ color: theme.success,
+ fontWeight: FontUtils.fontWeight.bold,
+ },
+
signInPageHeroCenter: {
position: 'absolute',
top: 0,
@@ -2876,7 +2881,7 @@ const styles = (theme: ThemeColors) =>
subscriptionAddedCardIcon: {
padding: 10,
- backgroundColor: theme.icon,
+ backgroundColor: theme.buttonDefaultBG,
borderRadius: variables.componentBorderRadius,
height: variables.iconSizeExtraLarge,
width: variables.iconSizeExtraLarge,
diff --git a/src/types/form/WorkspaceCategoryForm.ts b/src/types/form/WorkspaceCategoryForm.ts
index 4f5f9282373c..56c4bd709fcc 100644
--- a/src/types/form/WorkspaceCategoryForm.ts
+++ b/src/types/form/WorkspaceCategoryForm.ts
@@ -3,6 +3,7 @@ import type Form from './Form';
const INPUT_IDS = {
CATEGORY_NAME: 'categoryName',
+ GL_CODE: 'glCode',
} as const;
type InputID = ValueOf;
@@ -11,6 +12,7 @@ type WorkspaceCategoryForm = Form<
InputID,
{
[INPUT_IDS.CATEGORY_NAME]: string;
+ [INPUT_IDS.GL_CODE]: string;
}
>;
diff --git a/src/types/form/WorkspaceTagForm.ts b/src/types/form/WorkspaceTagForm.ts
index a6cc4c6c37cd..b049e1f5cc97 100644
--- a/src/types/form/WorkspaceTagForm.ts
+++ b/src/types/form/WorkspaceTagForm.ts
@@ -3,6 +3,7 @@ import type Form from './Form';
const INPUT_IDS = {
TAG_NAME: 'tagName',
+ TAG_GL_CODE: 'glCode',
} as const;
type InputID = ValueOf;
@@ -11,6 +12,7 @@ type WorkspaceTagForm = Form<
InputID,
{
[INPUT_IDS.TAG_NAME]: string;
+ [INPUT_IDS.TAG_GL_CODE]: string;
}
>;
diff --git a/src/types/onyx/Onboarding.ts b/src/types/onyx/Onboarding.ts
index 9860dd93f9ce..bb250d895c87 100644
--- a/src/types/onyx/Onboarding.ts
+++ b/src/types/onyx/Onboarding.ts
@@ -1,5 +1,8 @@
/** Model of onboarding */
type Onboarding = {
+ /** ID of the report used to display the onboarding checklist message */
+ chatReportID?: string;
+
/** A Boolean that informs whether the user has completed the guided setup onboarding flow */
hasCompletedGuidedSetupFlow: boolean;
};
diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts
index 3d864523e418..a24a3b3af502 100644
--- a/src/types/onyx/OriginalMessage.ts
+++ b/src/types/onyx/OriginalMessage.ts
@@ -414,8 +414,15 @@ type OriginalMessageUnapproved = {
expenseReportID: string;
};
+/**
+ * Model of `Add payment card` report action
+ */
+type OriginalMessageAddPaymentCard = Record;
+
/** The map type of original message */
type OriginalMessageMap = {
+ /** */
+ [CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_ADD_PAYMENT_CARD]: OriginalMessageAddPaymentCard;
/** */
[CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_JOIN_REQUEST]: OriginalMessageJoinPolicyChangeLog;
/** */
diff --git a/tests/e2e/config.ts b/tests/e2e/config.ts
index 9241463f6a0c..8963e07c31c8 100644
--- a/tests/e2e/config.ts
+++ b/tests/e2e/config.ts
@@ -8,7 +8,6 @@ const TEST_NAMES = {
ReportTyping: 'Report typing',
ChatOpening: 'Chat opening',
Linking: 'Linking',
- PreloadedLinking: 'Preloaded linking',
};
/**
@@ -90,7 +89,6 @@ export default {
// #announce Chat with many messages
reportID: '5421294415618529',
},
- // linking from chat A to a specific message in chat B
[TEST_NAMES.Linking]: {
name: TEST_NAMES.Linking,
reportScreen: {
@@ -101,16 +99,6 @@ export default {
linkedReportID: '5421294415618529',
linkedReportActionID: '2845024374735019929',
},
- // linking from chat A to a specific message in the same chat A
- [TEST_NAMES.PreloadedLinking]: {
- name: TEST_NAMES.PreloadedLinking,
- reportScreen: {
- autoFocus: true,
- },
- // Crowded Policy (Do Not Delete) Report, has a input bar available:
- reportID: '5421294415618529',
- linkedReportActionID: '8984197495983183608', // Message 4897
- },
},
};
diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx
index ca30eb10b065..b10cae2e7736 100644
--- a/tests/ui/UnreadIndicatorsTest.tsx
+++ b/tests/ui/UnreadIndicatorsTest.tsx
@@ -39,10 +39,6 @@ jest.mock('../../src/components/ConfirmedRoute.tsx');
TestHelper.setupApp();
TestHelper.setupGlobalFetchMock();
-beforeEach(() => {
- Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: true});
-});
-
function scrollUpToRevealNewMessagesBadge() {
const hintText = Localize.translateLocal('sidebarScreen.listOfChatMessages');
fireEvent.scroll(screen.getByLabelText(hintText), {
diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts
index aa1a2a60628c..34a9a923bd61 100644
--- a/tests/unit/ReportUtilsTest.ts
+++ b/tests/unit/ReportUtilsTest.ts
@@ -949,5 +949,25 @@ describe('ReportUtils', () => {
expect(ReportUtils.isChatUsedForOnboarding(report)).toBeTruthy();
});
+
+ it("should use the report id from the onboarding NVP if it's set", async () => {
+ const reportID = '8010';
+
+ await Onyx.multiSet({
+ [ONYXKEYS.NVP_ONBOARDING]: {chatReportID: reportID, hasCompletedGuidedSetupFlow: true},
+ });
+
+ const report1: Report = {
+ ...LHNTestUtils.getFakeReport(),
+ reportID,
+ };
+ expect(ReportUtils.isChatUsedForOnboarding(report1)).toBeTruthy();
+
+ const report2: Report = {
+ ...LHNTestUtils.getFakeReport(),
+ reportID: '8011',
+ };
+ expect(ReportUtils.isChatUsedForOnboarding(report2)).toBeFalsy();
+ });
});
});