diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml
index c7b8a1bb7e01..380b874c6c95 100644
--- a/.github/workflows/e2ePerformanceTests.yml
+++ b/.github/workflows/e2ePerformanceTests.yml
@@ -113,6 +113,8 @@ jobs:
- name: Setup Node
uses: ./.github/actions/composite/setupNode
+ with:
+ IS_DESKTOP_BUILD: 'true'
- name: Make zip directory for everything to send to AWS Device Farm
run: mkdir zip
diff --git a/Mobile-Expensify b/Mobile-Expensify
index 75361a078c87..1f1c233f53e0 160000
--- a/Mobile-Expensify
+++ b/Mobile-Expensify
@@ -1 +1 @@
-Subproject commit 75361a078c8792ae3a4f7b145c77d32357297ed2
+Subproject commit 1f1c233f53e03d601039a5ed41ca84ee9ae8a1e8
diff --git a/android/app/build.gradle b/android/app/build.gradle
index ddbc3ceb9033..7e8b398f1268 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -114,8 +114,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1009011207
- versionName "9.1.12-7"
+ versionCode 1009011300
+ versionName "9.1.13-0"
// 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/android/app/src/main/java/com/expensify/chat/navbar/NavBarManagerModule.kt b/android/app/src/main/java/com/expensify/chat/navbar/NavBarManagerModule.kt
index 5c566df606eb..dd02d8aba1bd 100644
--- a/android/app/src/main/java/com/expensify/chat/navbar/NavBarManagerModule.kt
+++ b/android/app/src/main/java/com/expensify/chat/navbar/NavBarManagerModule.kt
@@ -1,10 +1,12 @@
package com.expensify.chat.navbar
+import android.content.res.Resources
import androidx.core.view.WindowInsetsControllerCompat
+import com.expensify.chat.R
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
-import com.facebook.react.bridge.UiThreadUtil;
+import com.facebook.react.bridge.UiThreadUtil
class NavBarManagerModule(
private val mReactContext: ReactApplicationContext,
@@ -24,4 +26,18 @@ class NavBarManagerModule(
}
}
}
+
+ @ReactMethod
+ fun getType(): String {
+ val resources = mReactContext.resources
+ val resourceId = resources.getIdentifier("config_navBarInteractionMode", "integer", "android");
+ if (resourceId > 0) {
+ val navBarInteractionMode = resources.getInteger(resourceId)
+ when (navBarInteractionMode) {
+ 0, 1 -> return "soft-keys"
+ 2 -> return "gesture-bar"
+ }
+ }
+ return "soft-keys";
+ }
}
diff --git a/assets/images/product-illustrations/emptystate__receiptfairy.svg b/assets/images/product-illustrations/emptystate__receiptfairy.svg
new file mode 100644
index 000000000000..ccdeda5926f8
--- /dev/null
+++ b/assets/images/product-illustrations/emptystate__receiptfairy.svg
@@ -0,0 +1,154 @@
+
diff --git a/contributingGuides/FORMS.md b/contributingGuides/FORMS.md
index 469281d3e86b..be769faf568e 100644
--- a/contributingGuides/FORMS.md
+++ b/contributingGuides/FORMS.md
@@ -68,7 +68,7 @@ Browsers use the name prop to autofill information into the input. Here's a [ref
```jsx
```
@@ -118,7 +118,7 @@ Once a user has “touched” an input, i.e. blurred the input, we will also sta
All form fields will additionally be validated when the form is submitted. Although we are validating on blur this additional step is necessary to cover edge cases where forms are auto-filled or when a form is submitted by pressing enter (i.e. there will be only a ‘submit’ event and no ‘blur’ event to hook into).
-The Form component takes care of validation internally and the only requirement is that we pass a validate callback prop. The validate callback takes in the input values as argument and should return an object with shape `{[inputID]: errorMessage}`.
+The Form component takes care of validation internally and the only requirement is that we pass a validate callback prop. The validate callback takes in the input values as argument and should return an object with shape `{[inputID]: errorMessage}`.
Here's an example for a form that has two inputs, `routingNumber` and `accountNumber`:
@@ -332,10 +332,10 @@ An example of this can be seen in the [ACHContractStep](https://github.com/Expen
### Safe Area Padding
Any `FormProvider.tsx` that has a button at the bottom. If the `` is inside a ``, the bottom safe area inset is handled automatically (`includeSafeAreaPaddingBottom` needs to be set to `true`, but its the default).
-If you have custom requirements and can't use ``, you can use the `useStyledSafeAreaInsets()` hook:
+If you have custom requirements and can't use ``, you can use the `useSafeAreaPaddings()` hook:
```jsx
-const { paddingTop, paddingBottom, safeAreaPaddingBottomStyle } = useStyledSafeAreaInsets();
+const { paddingTop, paddingBottom, safeAreaPaddingBottomStyle } = useSafeAreaPaddings();
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments.md
deleted file mode 100644
index 7d318fd35143..000000000000
--- a/docs/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments.md
+++ /dev/null
@@ -1,29 +0,0 @@
----
-title: Third-Party Payments
-description: Reimburse reports and pay bills using PayPal or Venmo.
----
-Expensify integrates with PayPal and Venmo, which can be used to reimburse employees or pay bills. Some of the key benefits of using a third-party payment provider are:
-- Faster Reimbursements: Expedite the reimbursement process and reduce the time it takes for employees to receive their funds.
-- Secure Transactions: Benefit from the security features and protocols provided by trusted payment providers.
-- Centralized Expense Management: Consolidate all your expenses and payments within Expensify for a more efficient financial workflow.
-
-# Connect a third-party payment option
-
-To connect a third-party payment platform to Expensify:
-1. Log into your Expensify web account
-2. Head to **Settings > Account > Payments > Alternative Payment Accounts**
-3. Choose PayPal or Venmo
- - **PayPal**: Enter your username in the `paypal.me/` field
- - **Venmo**: Receive invoices via Venmo by adding your mobile phone number as a Secondary Login
-
-{% include faq-begin.md %}
-
-## Can I use multiple third-party payment providers with Expensify?
-
-Yes, you can link both your Venmo and PayPal accounts to Expensify if you'd like.
-
-## Is there a limit on the amount I can reimburse using third party payments?
-
-The payment limit is dependent on the settings configured within your Expensify account as well as the limits imposed by the third-party payment provider.
-
-{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements.md
index 02ee7b7ce04a..72ba03e600ee 100644
--- a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements.md
+++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements.md
@@ -1,81 +1,192 @@
---
title: Enable Global Reimbursements
description: Send international payments
+keywords: Expensify Classic, foreign bank validation, global reimbursement, Canada, Europe, Singapore, Australia, United Kingdom, international reimbursements
---
-
-Enabling global reimbursements allows you to send direct reimbursements to countries worldwide if your company’s bank account is in the US, UK, Canada, Europe, or Australia.
+If your company’s business bank account is in the US, Canada, the UK, Europe, Australia, or Singapore, you can send direct reimbursements to nearly any country worldwide!
-# For USD accounts
+The process to enable international reimbursements depends on the currency of your reimbursement bank account, so be sure to review the corresponding instructions below.
-{% include info.html %}
-Before you can complete this process, you must first connect a **verified** U.S. bank account, and your employees receiving payments from this account must also connect their **deposit-only** U.S. bank account.
-{% include end-info.html %}
+---
+
+# If the Reimbursement Account is in the U.S. (USD)
+
+Before you begin, ensure you have a verified U.S. business bank account connected. Once global reimbursement is enabled on your account, employees receiving payments will connect their **non-USD bank account**.
-## Step 1: Request global reimbursements
+## Step 1: Connect a U.S Business Bank Account
-Once your verified U.S. bank account has been added and verified, you can request that global reimbursements be enabled on your account.
+If you haven't already, follow the instructions to [Connect a US Business Bank Account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account).
-## Step 2: Re-verify the bank account
+## Step 2: Verify the Bank Account for Global Reimbursements
1. Hover over **Settings**, then click **Workspaces**.
-2. Select the workspace.
3. Click the **Reports** tab on the left.
4. Ensure that the workspace currency is set to **USD**.
5. Click the **Reimbursements** tab on the left.
-6. Ensure that the reimbursement method is set to **Direct** and that the right bank account is selected.
+6. Ensure that the reimbursement method is set to **Direct** and that the correct bank account is selected.
7. Click the **Payments** tab on the left.
8. Click **Enable Global Reimbursement** next to the bank account.
-9. Complete the International Reimbursement DocuSign form.
+9. Complete the Global Reimbursement DocuSign form.
+10. Once the form is complete, it is automatically sent to our Compliance Team for review.
-Once the form is complete, it is automatically sent to our Compliance Team for review. Our Support Team will contact you with more details if additional information is required, which may include:
+**Our Support Team will contact you with more details if additional information is required, which may include:**
- An authorization letter
- Proof of address and ID for the reimburser and/or company directors
- Independently certified documentation, such as a shareholder agreement from a lawyer, notary, or public accountant if an individual owns more than 25% of the company
-# For AUD, CAD, GBP, and EUR accounts
+## Step 3: Start Reimbursing Internationally
+
+After the bank account is verified for international payments, set the correct bank account as the reimbursement account:
+
+1. Under **Settings** > **Workspaces** > **Group** > **[Workspace Name]** > **Reimbursements**
+2. Select the reimbursement account as the default account.
+3. Ask your employees to add their deposit-only bank account.
+ - They can do this by logging into their Expensify accounts, heading to **Settings** > **Account** > **Payments**, and clicking **Add Deposit-Only Bank Account**.
+
+---
+
+# If the Bank Account is Outside of the U.S (AUD, CAD, GBP, EUR, and SGD Currencies)
+
+**Overview of the International Bank Account Setup:**
+1. Connect the reimbursement account to Expensify.
+2. Complete DocuSign Form.
+3. Expensify's Compliance Team Reviews the Application: Depending on the country, the team approves the request or requests additional documentation.
+4. Expensify sends test deposits to the provided bank account: Expensify asks the customer to confirm test deposit amounts.
+5. Admin Actions Required:
+ - Set the reimbursement account under **Settings > Workspaces > [Workspace Name] > Reimbursements**.
+ - Ensure employees add their deposit-only bank accounts under **Settings > Account > Payments > Add Deposit-Only Bank Account**.
-## Step 1: Add the bank account
+## Step 1: Connect the Bank Account
1. Hover over **Settings**, then click **Workspaces**.
-2. Select the workspace.
-3. Click the **Reports** tab on the left.
-4. Ensure that the selected workspace currency matches your reimbursement bank account currency.
-5. Click the **Reimbursements** tab on the left.
-6. Set the reimbursement method to **Direct**.
-7. Click **Add Business Bank Account**.
-8. If necessary, click **Switch Country** to select the correct country if not automatically selected.
-9. Enter the bank account details, then click **Save & Continue**.
-10. Complete the International Reimbursement DocuSign form.
+2. Click the **Reports** tab on the left.
+3. Ensure that the selected workspace currency matches your reimbursement bank account currency.
+4. Click the **Reimbursements** tab on the left.
+5. Set the reimbursement method to **Direct**.
+6. Click **Add Business Bank Account**.
+7. Click **Switch Country** and adjust the **Currency** so the information is correct.
+8. Click **Save & Continue**.
+9. Enter the details of your bank account and click **Save & Continue**.
-Once the form is complete, it is automatically sent to our Compliance Team for review. Our Support Team will contact you with more details if additional information is required, which may include:
-- An authorization letter
-- Proof of address and ID for the reimburser and/or company directors
-- Independently certified documentation, such as a shareholder agreement from a lawyer, notary, or public accountant if an individual owns more than 25% of the company
+## Step 2: Verify the Bank Account for Global Reimbursements
+
+1. Complete the Global Reimbursement DocuSign form.
+2. Once the form is complete, it is automatically sent to our Compliance Team for review.
+3. See the **Requirements for Global Reimbursement** section below to determine which additional information is required to set up international reimbursements for your entity.
+4. If prompted, upload one of the following:
+ - **Voided check** with business name and account details
+ - **Bank statement** showing the business name and account information
+ - **Bank letter** confirming the account details
+
+**Our Support Team will contact you via Concierge with more details if additional information is required.**
+
+
+## Step 3: Start Reimbursing Internationally
-{% include faq-begin.md %}
+After the bank account is verified for international payments, set the correct bank account as the reimbursement account:
-**Can multiple people send reimbursements internationally?**
+1. Under **Settings** > **Workspaces** > **Group** > **[Workspace Name]** > **Reimbursements**
+2. Select the reimbursement account as the default account.
+3. Ask your employees to add their deposit-only bank account.
+ - They can do this by logging into their Expensify accounts, heading to **Settings** > **Account** > **Payments**, and clicking **Add Deposit-Only Bank Account**.
+
+---
+
+# Requirements for International Reimbursement
+
+## U.S.
+**Requirements:**
+- ✅ Partner Application Form
+- ✅ Address Verification Document (e.g., utility bill, lease agreement)
+- ✅ Bank Statement for Direct Debit Setup
+
+**Direct Debit Timeline:**
+⏳ Takes 1 day to set up once onboarding is complete and the bank account is verified.
+
+## Canada
+**Requirements:**
+- ✅ Partner Application Form
+- ✅ Address Verification Document
+- ✅ Bank Statement for Direct Debit Setup
+- ✅ Proof of Beneficial Ownership
+
+**Direct Debit Timeline:**
+⏳ Takes 1 day to set up once onboarding is complete and the bank account is verified.
+
+## United Kingdom
+**Requirements:**
+- ✅ Partner Application Form
+- ✅ Address Verification Document
+- ✅ Bank Statement for Direct Debit Setup
+- ✅ Proof of Beneficial Ownership
+- ✅ Copy of ID and Proof of Address for Signatories
+
+**Direct Debit Timeline:**
+⏳ Can only be set up after onboarding is complete.
+
+## European Union
+**Requirements:**
+- ✅ Partner Application Form
+- ✅ Address Verification Document
+- ✅ Bank Statement for Direct Debit Setup
+- ✅ Proof of Beneficial Ownership
+- ✅ Certified Copies of ID & Address for UBOs (Only if the company is in Jersey)
+
+**Special Notes:**
+🇮🇹 Italy: Requires "Codice Fiscale" (Tax Identification Number).
+🇪🇸 Spain: Driver's license not accepted as ID.
+
+**Direct Debit Timeline:**
+⏳ Can only be set up after onboarding is complete.
+
+## Australia
+**Requirements:**
+- ✅ Partner Application Form
+- ✅ Address Verification Document
+- ✅ Bank Statement for Direct Debit Setup
+- ✅ Proof of Beneficial Ownership
+- ✅ Copy of ID & Proof of Address for all Signatories
+
+**Special Notes:**
+Expensify's Compliance Team must send a PDS FSG to company directors before onboarding.
+
+**Direct Debit Timeline:**
+⏳ Takes 1 day to set up once onboarding is complete and the bank account is verified.
+
+## Singapore
+**Requirements:**
+- ✅ Partner Application Form
+- ✅ Address Verification Document
+- ✅ Proof of Beneficial Ownership
+- ✅ Director/Board Resolution
+- ✅ Memorandum & Articles of Association
+- ✅ Certified Copies of ID & Address for UBOs
+
+**Direct Debit Timeline:**
+⏳ Takes 3 weeks to set up. Clients are encouraged to pre-fund the account while waiting.
+
+---
-Once your company is authorized to send global payments, the individual who verified the bank account can share it with additional admins on the workspace. This will enable them to be able to send global reimbursements.
+# FAQ
-**How long does it take to verify an account for global payments?**
+## Can multiple people send reimbursements internationally?
-The verification process can take anywhere from a few business days to several weeks depending on the information provided in the DocuSign form, if additional information is required for compliance.
+Yes, once your company is authorized to send international payments, the individual who verified the bank account can share it with additional admins on the workspace, enabling them to send global reimbursements.
-**My employee doesn’t have the option to add their non-USD bank account as a deposit account. What should they do?**
+## How long does it take to verify an account for global payments?
-Have the employee double-check that their [default workspace](https://help.expensify.com/articles/expensify-classic/workspaces/Navigate-multiple-workspaces) is set as the workspace that’s connected to the bank you’re using to send global payments.
+The verification process can take anywhere from a few business days to several weeks, depending on the information provided in the DocuSign form and if additional information is required for compliance.
-**Who is the “Authorized User” and the “User” on the International Reimbursement DocuSign form?**
+## Why can't my employee add a non-USD bank account?
-- **Authorized User**: The person who will process global reimbursements. The Authorized User should be the same person who manages the bank account connection in Expensify.
-- **User**: You can leave this section blank because the “User” is Expensify.
+Make sure your employee's default workspace is set to the one linked to the bank you're using for global payments. They can confirm their default workspace by following [these steps](https://help.expensify.com/articles/expensify-classic/workspaces/Navigate-multiple-workspaces).
-**Does Global Reimbursement support Sepa in the EU?**
+## Who are the “Authorized User” and the “User” on the International Reimbursement DocuSign form?
-Global Reimbursement uses Sepa B2B to facilitate payments from EU-based accounts. Sepa Core is not supported.
+- **Authorized User**: The person who will process international reimbursements. The Authorized User should be the same person who manages the bank account connection in Expensify.
+- **User**: Expensify (leave blank).
-{% include faq-end.md %}
+## Does Global Reimbursement in Expensify support SEPA (Single Euro Payments Area) in the EU?
-
+Global Reimbursement uses SEPA B2B to facilitate payments from EU-based accounts. SEPA Core is not supported.
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Get-reimbursed-faster-as-a-non-US-employee.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Get-reimbursed-faster-as-a-non-US-employee.md
deleted file mode 100644
index 30dea99bbfde..000000000000
--- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Get-reimbursed-faster-as-a-non-US-employee.md
+++ /dev/null
@@ -1,24 +0,0 @@
----
-title: Get Reimbursed Faster as a Non-US Employee
-description: How to use Wise to get paid faster
----
-
-If you are an overseas employee who works for a US-based company, you can use Wise to be reimbursed for expenses just as quickly as your US-based colleagues. Wise (formerly TransferWise) is an FCA-regulated global money transfer service.
-
-Here’s how it works:
-
-1. When you sign up for a Wise account, you are provided with a USD checking account number and a routing number to use as your Expensify bank account.
-2. Once you receive a reimbursement, it will be deposited directly into your Wise account.
-3. You can then convert your funds into 40+ different currencies and withdraw them to your local bank account. If you live in the UK or EU, you can also get a debit card to spend money directly from your Wise account.
-
-## Set up reimbursements through Wise
-
-1. Check with your company to see if you can submit your expenses in USD.
-2. Sign up for a Wise Borderless Account and get verified (verification can take up to 3 days).
-3. In Expensify, [add a deposit-only bank account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account) with your Wise USD account and ACH routing numbers (NOT the wire transfer routing number).
-
-{% include info.html %}
-Do not include spaces in the Wise account number, which should be 16 digits.
-{% include end-info.html %}
-
-If your expenses are not in USD, Expensify will automatically convert them to USD when they are added to your expense report. Once you submit your expenses to your company’s USD workspace and they are approved, you will receive the reimbursement for the approved report total in USD in your Wise account.
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
index 00fb236e1763..c036c316896d 100644
--- 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
@@ -1,36 +1,82 @@
---
-title: Receive payments
-description: Receive reimbursements from an employer
+title: Receive Payments
+description: Learn how to receive reimbursements through Expensify, including ACH, Wise, PayPal, and Venmo options.
+keywords: [reimbursement, ACH, bank account, Wise, PayPal, Venmo, Rapid Reimbursement]
---
-
-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.
+To receive a reimbursement after submitting an expense report, you must first [connect a personal bank account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account) to Expensify. Once your employer approves your report or invoice, the reimbursement is deposited directly into your bank 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.
+**Processing Times:**
+- U.S. and global payments generally take **4-5 business days**:
+ - **2 days** for the business account to process the debit
+ - **2-3 business days** for ACH or wire transfer to your account
+- Payments initiated **after 3 p.m. PST** or on **weekends/bank holidays** may be delayed.
+- If your payment qualifies for **Rapid Reimbursement**, you may receive it 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)
+Your company or client also has the option to pay you via **check, a third-party payment processor, or outside of Expensify**. Check with your **Workspace Admin** or payor to confirm the payment method.
-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.
+---
+
+# Rapid Reimbursement (U.S. Only)
+
+Expensify’s **Rapid Reimbursement** allows eligible ACH payments to process **within one business day** if they meet the following conditions:
+
+- **Deposit-only accounts:** Payment must not exceed **$100**.
+- **Verified business bank accounts:** Total disbursements must not exceed **$10,000** within 24 hours.
+
+If payments exceed these limits, they follow the standard **4-5 business day ACH timeframe**.
+
+---
+
+# Global Reimbursements via Wise
+
+For employees outside the U.S., Expensify partners with **Wise (formerly TransferWise)** to enable **faster global reimbursements**.
-If the payment amount exceeds the limit, funds will be deposited within the typical ACH time frame of four to five business days.
+## How It Works
+1. Sign up for a **Wise account** to receive a USD checking account number and ACH routing number.
+2. Add these details as a **deposit-only bank account** in Expensify.
+3. When your reimbursement is approved, it is deposited into your Wise account.
+4. Convert funds into **40+ currencies** or withdraw them to your local bank. **UK and EU users can also request a Wise debit card**.
-{% include faq-begin.md %}
+## Setting Up Wise for Expensify
+1. Confirm with your employer if **USD expenses** can be submitted.
+2. Open a **Wise Borderless Account** and complete **verification** (may take up to **3 days**).
+3. Add your **Wise USD account and ACH routing number** to Expensify.
-**Is there a way I can track my payment?**
+**Important:** Use the **ACH routing number**, *not* the wire transfer routing number.
+
+Expensify will **automatically convert non-USD expenses** to USD before submission.
+
+---
+
+# Third-Party Payment Options
+
+Expensify integrates with **PayPal** and **Venmo** to provide alternative reimbursement options.
+
+## Benefits of Third-Party Payments
+- **Faster reimbursements** compared to standard ACH transfers.
+- **Enhanced security** via trusted payment platforms.
+- **Streamlined expense management** within Expensify.
+
+## Connecting a Third-Party Payment Option
+1. Log into your **Expensify account**.
+2. Navigate to **Settings > Account > Payments > Alternative Payment Accounts**.
+3. Select **PayPal** or **Venmo**:
+ - **PayPal**: Enter your **PayPal.Me** username.
+ - **Venmo**: Enter your **mobile number** (must match your Venmo account).
+
+---
-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.
+# FAQ
-**For global payments, what currency is the payment provided in?**
+## Can I link multiple third-party payment providers?
+Yes, you can link both **PayPal and Venmo** to your Expensify account.
-Global payments are reimbursed in the recipient's currency.
+## Is there a limit on third-party reimbursements?
+Limits depend on **Expensify settings** and **third-party provider restrictions**.
-{% include faq-end.md %}
+## How can I track my reimbursement?
+For **ACH and global payments**, Expensify displays the **expected deposit date** at the top of your expense report and in the report comments.
-
+## What currency will my global reimbursement be in?
+Expensify will reimburse you in your local currency, based on the conversion at the time of payout.
diff --git a/docs/articles/expensify-classic/expensify-partner-program/Card-Revenue-Share.md b/docs/articles/expensify-classic/expensify-partner-program/Card-Revenue-Share.md
index 663a5e3cd9c8..96d961c479a9 100644
--- a/docs/articles/expensify-classic/expensify-partner-program/Card-Revenue-Share.md
+++ b/docs/articles/expensify-classic/expensify-partner-program/Card-Revenue-Share.md
@@ -1,27 +1,42 @@
---
-title: Expensify Card revenue share for ExpensifyApproved! partners
-description: Earn money when your clients adopt the Expensify Card
+title: Expensify-Card-Revenue-Share.md
+description: Learn how ExpensifyApproved! partner accountants can earn revenue by helping clients adopt the Expensify Card.
+keywords: [Expensify Card, revenue share, ExpensifyApproved! partner, accountant earnings]
---
-You can now earn additional income for your firm every time your client uses their Expensify Card. In short, your firm gets 0.5% of your clients’ total Expensify Card spend as cash back. The more your clients spend, the more cashback your firm receives!
-This program is currently only available to US-based ExpensifyApproved! partner accountants.
+ExpensifyApproved! Accounting Partners in the US can now earn additional income through the Expensify Card. Your firm will receive **0.5% of your clients’ total Expensify Card spend** as cashback -- More client spending means more revenue for your firm!
-# Become a Domain Admin
-To benefit from this program, you or a member of your firm must be a domain admin on the client’s domain in Expensify:
-1. Head to *Settings > Domains*
-2. Click the client's domain
- - If you can click on the domain and access the domain settings, you are a Domain Admin
- - If you’re not a Domain Admin, your client can add you as one by heading to **Settings > Domains > [Client's Domain] > Domain Admins > Add Admin**.
+**Note:** This program is currently available only to **US-based ExpensifyApproved! partner accountants**.
-_**Note:** You can view all domain admins under Settings > Domains > [Client's Domain] > Domain Admins._
+---
+
+# How to Earn Revenue
+
+## 1. Become a Domain Admin
+To qualify, you or a member of your firm must be a **Domain Admin** on the client’s domain in Expensify.
+
+Steps to becoming a Domain Admin:
+1. Go to **Settings > Domains**.
+2. Click on your client’s domain.
+ - If you can access the domain settings, you are a Domain Admin.
+ - If you are not a Domain Admin, ask your client to add you:
+ - Navigate to **Settings > Domains > [Client's Domain] > Domain Admins > Add Admin**.
+
+**Tip:** You can view all Domain Admins under **Settings > Domains > [Client's Domain] > Domain Admins**.
-# Connect a deposit-only business bank account
-[Follow these instructions](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account#connect-a-business-deposit-only-account) to connect a deposit-only business bank account.
+---
+
+## 2. Connect a Deposit-Only Business Bank Account
+To receive your revenue share, connect a **deposit-only business bank account**:
+
+[Follow these instructions](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account#connect-a-business-deposit-only-account) to complete the setup.
-Once that's complete, any revenue earned will be deposited directly into that bank account.
+Once connected, all earned revenue will be deposited directly into this account.
+
+---
-{% include faq-begin.md %}
+# FAQ
-## What if my firm is not permitted to accept revenue share from our clients?
+## What if my firm cannot accept revenue share from clients?
-We understand that different firms may have different policies. If your firm is unable to accept this revenue share, you can pass the revenue share back to your client to give them an additional 0.5% of cash back using your own internal payment tools.
+We understand that some firms have restrictions on receiving revenue share. If your firm cannot accept the cashback, you can choose to pass the 0.5% cashback directly to your client using your internal payment tools.
diff --git a/docs/articles/expensify-classic/expensify-partner-program/Referral-Program.md b/docs/articles/expensify-classic/expensify-partner-program/Referral-Program.md
deleted file mode 100644
index 3153e6e217fa..000000000000
--- a/docs/articles/expensify-classic/expensify-partner-program/Referral-Program.md
+++ /dev/null
@@ -1,31 +0,0 @@
----
-title: Refer a Friend to New Expensify!
-description: Share your invite link with a friend, start a chat with a coworker or request money from your boss
----
-
-
-# About
-
-New Expensify's referral program is currently paused. Please check back later.
-
-[New Expensify](https://new.expensify.com/) is growing thanks to members like you who love it so much that they tell their friends, family, colleagues, managers, and fellow business founders to use it, too.
-
-# How to refer a friend to New Expensify
-
-1. There are a bunch of different ways to refer someone to New Expensify:
- - Start a chat
- - Submit an expense to them
- - Split an expense with them
- - Pay someone (them)
- - Assign them a task
- - @ mention them
- - Invite them to a room
- - Add them to a workspace
-
-
-{% include faq-begin.md %}
-
-- **Where can I find my referral link?**
-
-In New Expensify, go to **Settings** > **Share code** > **Get $250** to retrieve your invite link.
-{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/expensify-partner-program/Your-Expensify-Partner-Manager.md b/docs/articles/expensify-classic/expensify-partner-program/Your-Expensify-Partner-Manager.md
index 8243833dcc23..a8c54842e13e 100644
--- a/docs/articles/expensify-classic/expensify-partner-program/Your-Expensify-Partner-Manager.md
+++ b/docs/articles/expensify-classic/expensify-partner-program/Your-Expensify-Partner-Manager.md
@@ -1,77 +1,82 @@
---
title: Expensify Partner Support
-description: Understanding support for our partners
+description: Learn about Expensify's Partner Support program, including training, dedicated partner managers, and real-time chat support.
+keywords: [ExpensifyApproved, partner support, training, partner manager, onboarding, concierge]
---
-# Overview
-As an ExpensifyApproved! Partner, your firm gains access to a specialized suite of support services. We're here to ensure your clients have the tools and resources they need, every step of the way.
-Our well-rounded support methodology is designed to provide comprehensive assistance, ensuring that both you and your clients have a seamless experience. Have questions or want to delve deeper? Don't hesitate to reach out to your dedicated Partner Manager to get started!
+As an **ExpensifyApproved! Partner**, your firm gains access to exclusive support services designed to enhance your experience and help your clients succeed. This guide outlines the key support resources available to you.
+# ExpensifyApproved! University
-## 1. ExpensifyApproved! University
-**Purpose:** Equip your team with a comprehensive understanding of Expensify.
+**Purpose:** Train and certify your team on Expensify.
**Benefits:**
-- Foundation-level knowledge about the platform.
-- 3 CPE credits upon successful completion (US-only).
-- Unlock exclusive partner perks, including tickets to ExpensiCon!
-- Visit university.Expensify.com to access our comprehensive training program.
+- Gain foundational knowledge of Expensify.
+- Earn **3 CPE credits** (US-only).
+- Unlock partner perks, including tickets to **ExpensiCon**.
+- Access training at [university.expensify.com](https://university.expensify.com).
-## 2. Partner Manager
-**Role:**
-A Partner Manager is a dedicated point of contact for your firm Partner Managers support our accounting partners by providing recommendations for client’s accounts, assisting with firm-wide training, and ensuring partners receive the full benefits of our partnership program. They will actively monitor open technical issues and be proactive with recommendations to increase efficiency.
+---
+
+# Partner Manager
+Your **Partner Manager** is your dedicated contact for strategic support.
-**Key Responsibilities:**
-- Handle any escalations promptly.
+## Key Responsibilities:
+- Handle escalations promptly.
- Organize firm-wide training sessions.
-- Assist with strategic planning and the introduction of new features.
-- Once you've completed the ExpensifyApproved! University, log in to your Expensify account. Click on the "Support" option to connect with your dedicated Partner Manager.
+- Provide recommendations to optimize your clients' accounts.
+- Assist with strategic planning and feature adoption.
-**How do I know if I have a Partner Manager?**
+## How to Connect with Your Partner Manager
+To be assigned a **Partner Manager**, you must complete the **ExpensifyApproved! University** training. Once completed, check your **Expensify account**:
+- Look for the **ExpensifyApproved!** logo in the bottom left-hand corner.
+- Navigate to `Support` to message your **Partner Manager**.
-For your firm to be assigned a Partner Manager, you must complete the ExpensifyApproved! University training course. Every external accountant or bookkeeper who completes the training is automatically enrolled in our program and receives all the benefits, including access to the Partner Manager. So everyone at your firm must complete the training to receive the maximum benefit.
+**Contact Options:**
+1. Log in to [new.expensify.com](https://new.expensify.com) and search for your **Partner Manager**.
+2. Reply to any email from your **Partner Manager**.
-You can check to see if you’ve completed the course and enrolled in the ExpensifyApproved! Accountants program simply by logging into your Expensify account. In the bottom left-hand corner of the website, you will see the ExpensifyApproved! logo.
+**Checking Availability:**
+- Your **Partner Manager's** status in **New Expensify** will show as **"Online"** or display their working hours.
-**How do I contact my Partner Manager?**
-1. Signing in to new.expensify.com and searching for your Partner Manager
-2. Replying to or clicking the chat link on any email you get from your Partner Manager
+**Scheduling a Call:**
+- You can request a call for discussions on client onboarding, training, and best practices.
+- For general support questions, use **Concierge chat** for immediate assistance.
-**How do I know if my Partner Manager is online?**
+---
-You will be able to see if they are online via their status in new.expensify.com, which will either say “online” or have their working hours.
+# Client Setup Specialist
-**Can I get on a call with my Partner Manager?**
+**Purpose:** Ensure seamless onboarding for your referred clients.
-Of course! You can ask your Partner Manager to schedule a call whenever you think one might be helpful. Partner Managers can discuss client onboarding strategies, firm-wide training, and client setups.
+**Duties:**
+- Assist with Expensify setup and **accounting integrations**.
+- Ensure clients have the necessary tools and knowledge.
+- To connect with a **Client Setup Specialist**, log in and navigate to `Support`.
-We recommend continuing to work with Concierge for general support questions, as this team is always online and available to help immediately.
+---
-## 3. Client Setup Specialist
-**Purpose:** Ensure smooth onboarding for every client you refer.
+# Client Account Manager
-**Duties:**
-- Comprehensive assistance with setting up Expensify.
-- Help with configuring accounting integrations.
-- Ensure clients have the tools and knowledge they need to thrive.
-- After logging into Expensify, click on the "Support" option in the left-hand navigation pane. This will connect you directly to your assigned Client Onboarding Manager.
-
-## 4. Client Account Manager
-**Role:** Dedicated support for ongoing client needs.
+**Role:** Provide ongoing support for client accounts.
**Responsibilities:**
-- Address day-to-day product inquiries.
-- Assist clients in navigating and optimizing their use of Expensify.
-- After logging into Expensify, click on the "Support" option in the left-hand navigation pane. This will connect you directly to your assigned Account Manager.
+- Address daily product-related inquiries.
+- Guide clients in optimizing their **Expensify experience**.
+- To contact your **Client Account Manager**, log in and go to `Support`.
+
+---
-## 5. Concierge chat support
-**Availability:** Real-time support for any urgent inquiries.
+# Concierge Chat Support
+
+**Availability:** Real-time support for immediate inquiries.
**Features:**
-- Immediate assistance with an average response time of under two minutes.
-- Available to both accountants and clients for all product-related questions.
-- For instant assistance, click on the chat bubble located at the bottom right-hand corner of your screen when logged in.
+- **Response time under 2 minutes**.
+- Available to **both accountants and clients**.
+- Click the **chat bubble** in the bottom right-hand corner of Expensify for instant help.
+---
-Our well-rounded support methodology is designed to provide comprehensive assistance, ensuring that both you and your clients have a seamless experience. Have questions or want to delve deeper? Don't hesitate to reach out to your dedicated Partner Manager. You can find your partner manager by logging into Expensify and clicking the "Support" button in the left hand navigator.
+Expensify's Partner Support ensures you and your clients receive elevated support in Expensify. If you need further guidance, reach out via `Support` in your Expensify account or connect directly with your Partner Manager!
diff --git a/docs/articles/new-expensify/getting-started/Using-Reports-in-New-Expensify.md b/docs/articles/new-expensify/getting-started/Using-Reports-in-New-Expensify.md
index 7c2016b4e212..bc4afbe957a7 100644
--- a/docs/articles/new-expensify/getting-started/Using-Reports-in-New-Expensify.md
+++ b/docs/articles/new-expensify/getting-started/Using-Reports-in-New-Expensify.md
@@ -24,6 +24,8 @@ Expensify's Reports feature introduces a powerful way to access and manage finan
- **Cross-platform consistency** - Enjoy a seamless experience across desktop and mobile platforms.
- **Saved reports** - Save and revisit frequently used report queries for recurring tasks.
+{:width="100%"}
+
---
# Report Filters
@@ -37,6 +39,8 @@ Expensify’s report filters help users narrow down results to find specific dat
- Trips: Drafts, Upcoming, In Progress, Past
- **Advanced filters** - Enable precise reports using query syntax (e.g., `type:expenses status:approved`).
+{:width="100%"}
+
---
# Search Format
diff --git a/docs/redirects.csv b/docs/redirects.csv
index 26d902954652..cf7fd558e5cf 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -663,3 +663,6 @@ https://help.expensify.com/articles/expensify-classic/reports/Add-comments-and-a
https://help.expensify.com/articles/expensify-classic/expensify-card/Statements,https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Statements
https://help.expensify.com/articles/expensify-classic/expensify-billing/Out-of-date-Billing,https://help.expensify.com/articles/expensify-classic/expensify-billing/Out-of-Date-Billing
https://help.expensify.com/articles/expensify-classic/settings/Set-time-zone,https://help.expensify.com/articles/expensify-classic/settings/Set-Time-Zone
+https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Get-reimbursed-faster-as-a-non-US-employee,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/Third-Party-Payments,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-Payments
+https://help.expensify.com/articles/expensify-classic/expensify-partner-program/Referral-Program,https://use.expensify.com/accountants-program
\ No newline at end of file
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 4a7b8f7a1e3a..5259b0cfc5ef 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -23,7 +23,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 9.1.12
+ 9.1.13CFBundleSignature????CFBundleURLTypes
@@ -44,7 +44,7 @@
CFBundleVersion
- 9.1.12.7
+ 9.1.13.0FullStoryOrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index d7e45b02f7d2..6c877542b6f2 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 9.1.12
+ 9.1.13CFBundleSignature????CFBundleVersion
- 9.1.12.7
+ 9.1.13.0
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 664e379b3d79..2c9338f0d407 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName$(PRODUCT_NAME)CFBundleShortVersionString
- 9.1.12
+ 9.1.13CFBundleVersion
- 9.1.12.7
+ 9.1.13.0NSExtensionNSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index 4d5098f0c62c..0b70c75704bc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.1.12-7",
+ "version": "9.1.13-0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.1.12-7",
+ "version": "9.1.13-0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index d51c0ad899d3..dbcb50f41f0e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.1.12-7",
+ "version": "9.1.13-0",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
diff --git a/src/App.tsx b/src/App.tsx
index 8dd2631a6b7d..3e6fc9ac2a27 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -17,6 +17,7 @@ import InitialURLContextProvider from './components/InitialURLContextProvider';
import {InputBlurContextProvider} from './components/InputBlurContext';
import KeyboardProvider from './components/KeyboardProvider';
import {LocaleContextProvider} from './components/LocaleContextProvider';
+import NavigationBar from './components/NavigationBar';
import OnyxProvider from './components/OnyxProvider';
import PopoverContextProvider from './components/PopoverProvider';
import {ProductTrainingContextProvider} from './components/ProductTrainingContext';
@@ -118,6 +119,7 @@ function App({url, hybridAppSettings, timestamp}: AppProps) {
+
diff --git a/src/CONST.ts b/src/CONST.ts
index 1d4a682fa262..a5542620fdf9 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -781,6 +781,7 @@ const CONST = {
NEWDOT_INTERNATIONAL_DEPOSIT_BANK_ACCOUNT: 'newDotInternationalDepositBankAccount',
NSQS: 'nsqs',
CUSTOM_RULES: 'customRules',
+ TABLE_REPORT_VIEW: 'tableReportView',
},
BUTTON_STATES: {
DEFAULT: 'default',
@@ -1026,7 +1027,7 @@ const CONST = {
GITHUB_RELEASE_URL: 'https://api.github.com/repos/expensify/app/releases/latest',
ADD_SECONDARY_LOGIN_URL: encodeURI('settings?param={"section":"account","openModal":"secondaryLogin"}'),
MANAGE_CARDS_URL: 'domain_companycards',
- FEES_URL: `${USE_EXPENSIFY_URL}/fees`,
+ FEES_URL: `${EXPENSIFY_URL}/fees`,
SAVE_WITH_EXPENSIFY_URL: `${USE_EXPENSIFY_URL}/savings-calculator`,
CFPB_PREPAID_URL: 'https://cfpb.gov/prepaid',
STAGING_NEW_EXPENSIFY_URL: 'https://staging.new.expensify.com',
@@ -1311,6 +1312,7 @@ const CONST = {
CORPORATE_UPGRADE: 'POLICYCHANGELOG_CORPORATE_UPGRADE',
TEAM_DOWNGRADE: 'POLICYCHANGELOG_TEAM_DOWNGRADE',
},
+ RESOLVED_DUPLICATES: 'RESOLVEDDUPLICATES',
ROOM_CHANGE_LOG: {
INVITE_TO_ROOM: 'INVITETOROOM',
REMOVE_FROM_ROOM: 'REMOVEFROMROOM',
@@ -1531,6 +1533,20 @@ const CONST = {
LIGHT: 'light',
DARK: 'dark',
},
+ NAVIGATION_BAR_TYPE: {
+ // We consider there to be no navigation bar in one of these cases:
+ // 1. The device has physical navigation buttons
+ // 2. The device uses gesture navigation without a gesture bar.
+ // 3. The device uses hidden (auto-hiding) soft keys.
+ NONE: 'none',
+ SOFT_KEYS: 'soft-keys',
+ GESTURE_BAR: 'gesture-bar',
+ },
+ // Currently, in Android there is no native API to detect the type of navigation bar (soft keys vs. gesture).
+ // The navigation bar on (standard) Android devices is around 30-50dpi tall. (Samsung: 40dpi, Huawei: ~34dpi)
+ // To leave room to detect soft-key navigation bars on non-standard Android devices,
+ // we set this height threshold to 30dpi, since gesture bars will never be taller than that. (Samsung & Huawei: ~14-15dpi)
+ NAVIGATION_BAR_ANDROID_SOFT_KEYS_MINIMUM_HEIGHT_THRESHOLD: 30,
TRANSACTION: {
DEFAULT_MERCHANT: 'Expense',
UNKNOWN_MERCHANT: 'Unknown Merchant',
@@ -1928,6 +1944,7 @@ const CONST = {
AUTO_CREATE_VENDOR: 'autoCreateVendor',
REIMBURSEMENT_ACCOUNT_ID: 'reimbursementAccountID',
COLLECTION_ACCOUNT_ID: 'collectionAccountID',
+ ACCOUNTING_METHOD: 'accountingMethod',
},
XERO_CONFIG: {
@@ -2460,7 +2477,7 @@ const CONST = {
BANCORP_WALLET_PROGRAM_ID: '660',
PROGRAM_ISSUERS: {
EXPENSIFY_PAYMENTS: 'Expensify Payments LLC',
- BANCORP_BANK: 'The Bancorp Bank',
+ BANCORP_BANK: 'The Bancorp Bank, N.A.',
},
},
@@ -3363,7 +3380,7 @@ const CONST = {
`(? {
- return `search?q=${encodeURIComponent(query)}${groupBy ? `&groupBy=${groupBy}` : ''}${name ? `&name=${name}` : ''}` as const;
+ getRoute: ({query, name}: {query: SearchQueryString; name?: string}) => {
+ return `search?q=${encodeURIComponent(query)}${name ? `&name=${name}` : ''}` as const;
},
},
SEARCH_SAVED_SEARCH_RENAME: {
@@ -79,9 +79,9 @@ const ROUTES = {
},
},
SEARCH_MONEY_REQUEST_REPORT: {
- route: 'search/report/:reportID',
+ route: 'search/r/:reportID',
getRoute: ({reportID, backTo}: {reportID: string; backTo?: string}) => {
- const baseRoute = `search/view/${reportID}` as const;
+ const baseRoute = `search/r/${reportID}` as const;
return getUrlWithBackToParam(baseRoute, backTo);
},
},
@@ -310,6 +310,7 @@ const ROUTES = {
NEW_CHAT_EDIT_NAME: 'new/chat/confirm/name/edit',
NEW_ROOM: 'new/room',
+ NEW_REPORT_WORKSPACE_SELECTION: 'new-report-workspace-selection',
REPORT: 'r',
REPORT_WITH_ID: {
route: 'r/:reportID?/:reportActionID?',
@@ -405,6 +406,10 @@ const ROUTES = {
route: 'r/:reportID/details/export/:connectionName',
getRoute: (reportID: string, connectionName: ConnectionName, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/details/export/${connectionName as string}` as const, backTo),
},
+ REPORT_WITH_ID_CHANGE_WORKSPACE: {
+ route: 'r/:reportID/change-workspace',
+ getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/change-workspace` as const, backTo),
+ },
REPORT_SETTINGS: {
route: 'r/:reportID/settings',
getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/settings` as const, backTo),
@@ -1171,11 +1176,19 @@ const ROUTES = {
},
WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ACCOUNT_SELECTOR: {
route: 'settings/workspaces/:policyID/accounting/quickbooks-online/account-selector',
- getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/account-selector` as const,
+ getRoute: (policyID: string | undefined) => `settings/workspaces/${policyID}/accounting/quickbooks-online/account-selector` as const,
},
WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR: {
route: 'settings/workspaces/:policyID/accounting/quickbooks-online/invoice-account-selector',
- getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/invoice-account-selector` as const,
+ getRoute: (policyID: string | undefined) => `settings/workspaces/${policyID}/accounting/quickbooks-online/invoice-account-selector` as const,
+ },
+ WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_AUTO_SYNC: {
+ route: 'settings/workspaces/:policyID/connections/quickbooks-online/advanced/autosync',
+ getRoute: (policyID: string | undefined) => `settings/workspaces/${policyID}/connections/quickbooks-online/advanced/autosync` as const,
+ },
+ WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ACCOUNTING_METHOD: {
+ route: 'settings/workspaces/:policyID/connections/quickbooks-online/advanced/autosync/accounting-method',
+ getRoute: (policyID: string | undefined) => `settings/workspaces/${policyID}/connections/quickbooks-online/advanced/autosync/accounting-method` as const,
},
WORKSPACE_ACCOUNTING_CARD_RECONCILIATION: {
route: 'settings/workspaces/:policyID/accounting/:connection/card-reconciliation',
@@ -1680,6 +1693,10 @@ const ROUTES = {
route: 'hold-expense-educational',
getRoute: (backTo?: string) => getUrlWithBackToParam('hold-expense-educational', backTo),
},
+ CHANGE_POLICY_EDUCATIONAL: {
+ route: 'change-workspace-educational',
+ getRoute: (backTo?: string) => getUrlWithBackToParam('change-workspace-educational', backTo),
+ },
TRAVEL_MY_TRIPS: 'travel',
TRAVEL_TCS: {
route: 'travel/terms/:domain/accept',
@@ -2074,11 +2091,11 @@ const ROUTES = {
},
POLICY_ACCOUNTING_NETSUITE_AUTO_SYNC: {
route: 'settings/workspaces/:policyID/connections/netsuite/advanced/autosync',
- getRoute: (policyID: string) => `settings/workspaces/${policyID}/connections/netsuite/advanced/autosync` as const,
+ getRoute: (policyID: string | undefined) => `settings/workspaces/${policyID}/connections/netsuite/advanced/autosync` as const,
},
POLICY_ACCOUNTING_NETSUITE_ACCOUNTING_METHOD: {
route: 'settings/workspaces/:policyID/connections/netsuite/advanced/autosync/accounting-method',
- getRoute: (policyID: string) => `settings/workspaces/${policyID}/connections/netsuite/advanced/autosync/accounting-method` as const,
+ getRoute: (policyID: string | undefined) => `settings/workspaces/${policyID}/connections/netsuite/advanced/autosync/accounting-method` as const,
},
POLICY_ACCOUNTING_NSQS_SETUP: {
route: 'settings/workspaces/:policyID/accounting/nsqs/setup',
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 0825b540244f..759755a04617 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -164,7 +164,9 @@ const SCREENS = {
NEW_CHAT: 'NewChat',
DETAILS: 'Details',
PROFILE: 'Profile',
+ NEW_REPORT_WORKSPACE_SELECTION: 'New_Report_Workspace_Selection',
REPORT_DETAILS: 'Report_Details',
+ REPORT_CHANGE_WORKSPACE: 'ReportChangeWorkspace',
WORKSPACE_CONFIRMATION: 'Workspace_Confirmation',
REPORT_SETTINGS: 'Report_Settings',
REPORT_DESCRIPTION: 'Report_Description',
@@ -327,12 +329,20 @@ const SCREENS = {
EDIT: 'PrivateNotes_Edit',
},
+ NEW_REPORT_WORKSPACE_SELECTION: {
+ ROOT: 'NewReportWorkspaceSelection_Root',
+ },
+
REPORT_DETAILS: {
ROOT: 'Report_Details_Root',
SHARE_CODE: 'Report_Details_Share_Code',
EXPORT: 'Report_Details_Export',
},
+ REPORT_CHANGE_WORKSPACE: {
+ ROOT: 'ReportChangeWorkspace_Root',
+ },
+
WORKSPACE_CONFIRMATION: {ROOT: 'Workspace_Confirmation_Root'},
WORKSPACE: {
@@ -361,6 +371,8 @@ const SCREENS = {
QUICKBOOKS_ONLINE_CLASSES_DISPLAYED_AS: 'Policy_Accounting_Quickbooks_Online_Import_Classes_Displayed_As',
QUICKBOOKS_ONLINE_CUSTOMERS_DISPLAYED_AS: 'Policy_Accounting_Quickbooks_Online_Import_Customers_Displayed_As',
QUICKBOOKS_ONLINE_LOCATIONS_DISPLAYED_AS: 'Policy_Accounting_Quickbooks_Online_Import_Locations_Displayed_As',
+ QUICKBOOKS_ONLINE_AUTO_SYNC: 'Policy_Accounting_Quickbooks_Online_Auto_Sync',
+ QUICKBOOKS_ONLINE_ACCOUNTING_METHOD: 'Policy_Accounting_Quickbooks_Online_Accounting_Method',
QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Company_Card_Expense_Account_Select',
QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_COMPANY_CARD_SELECT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Company_Card_Expense_Select',
QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Company_Card_Expense',
@@ -646,6 +658,7 @@ const SCREENS = {
DETAILS_ROOT: 'Details_Root',
PROFILE_ROOT: 'Profile_Root',
PROCESS_MONEY_REQUEST_HOLD_ROOT: 'ProcessMoneyRequestHold_Root',
+ CHANGE_POLICY_EDUCATIONAL_ROOT: 'ChangePolicyEducational_Root',
REPORT_DESCRIPTION_ROOT: 'Report_Description_Root',
REPORT_PARTICIPANTS: {
ROOT: 'ReportParticipants_Root',
diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx
index 2c07863ea2e8..93729a8e0da3 100644
--- a/src/components/Attachments/AttachmentView/index.tsx
+++ b/src/components/Attachments/AttachmentView/index.tsx
@@ -14,7 +14,7 @@ import Text from '@components/Text';
import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
-import useStyledSafeAreaInsets from '@hooks/useStyledSafeAreaInsets';
+import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -116,7 +116,7 @@ function AttachmentView({
const {updateCurrentlyPlayingURL} = usePlaybackContext();
const theme = useTheme();
- const {safeAreaPaddingBottomStyle} = useStyledSafeAreaInsets();
+ const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const [loadComplete, setLoadComplete] = useState(false);
diff --git a/src/components/AutoCompleteSuggestions/index.tsx b/src/components/AutoCompleteSuggestions/index.tsx
index 8d91525c25bc..a459cc193b40 100644
--- a/src/components/AutoCompleteSuggestions/index.tsx
+++ b/src/components/AutoCompleteSuggestions/index.tsx
@@ -64,7 +64,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu
const StyleUtils = useStyleUtils();
const insets = useSafeAreaInsets();
const {keyboardHeight, isKeyboardAnimatingRef} = useKeyboardState();
- const {paddingBottom: bottomInset, paddingTop: topInset} = StyleUtils.getSafeAreaPadding(insets ?? undefined);
+ const {paddingBottom: bottomInset, paddingTop: topInset} = StyleUtils.getPlatformSafeAreaPadding(insets ?? undefined);
useEffect(() => {
const container = containerRef.current;
@@ -103,8 +103,20 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu
let bottomValue = windowHeight - (cursorCoordinates.y - scrollValue + y) - keyboardHeight;
const widthValue = shouldUseNarrowLayout ? 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 isEnoughSpaceToRenderMenuAboveForBig = isEnoughSpaceToRenderMenuAboveCursor({
+ y,
+ cursorCoordinates,
+ scrollValue,
+ contentHeight: contentMaxHeight,
+ topInset,
+ });
+ const isEnoughSpaceToRenderMenuAboveForSmall = isEnoughSpaceToRenderMenuAboveCursor({
+ y,
+ cursorCoordinates,
+ scrollValue,
+ contentHeight: contentMinHeight,
+ topInset,
+ });
const newLeftOffset = shouldUseNarrowLayout ? 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
@@ -139,9 +151,11 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu
});
}, [measureParentContainerAndReportCursor, windowHeight, windowWidth, keyboardHeight, shouldUseNarrowLayout, suggestionsLength, bottomInset, topInset, isKeyboardAnimatingRef]);
- if ((containerState.width === 0 && containerState.left === 0 && containerState.bottom === 0) || (containerState.cursorCoordinates.x === 0 && containerState.cursorCoordinates.y === 0)) {
+ // Prevent rendering if container dimensions are not set or if we have no suggestions
+ if ((containerState.width === 0 && containerState.left === 0 && containerState.bottom === 0) || !suggestionsLength) {
return null;
}
+
return (
;
+
+ /** Whether to add bottom safe area padding to the view. */
+ addBottomSafeAreaPadding?: boolean;
};
type BlockingViewIconProps = {
@@ -94,7 +98,8 @@ function BlockingView({
animationWebStyle = {},
CustomSubtitle,
contentFitImage,
- containerStyle,
+ containerStyle: containerStyleProp,
+ addBottomSafeAreaPadding = false,
}: BlockingViewProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
@@ -132,6 +137,8 @@ function BlockingView({
);
}, [styles, subtitleText, shouldEmbedLinkWithSubtitle, CustomSubtitle]);
+ const containerStyle = useBottomSafeSafeAreaPaddingStyle({addBottomSafeAreaPadding, style: containerStyleProp});
+
return (
{!!animation && (
diff --git a/src/components/BlockingViews/FullPageNotFoundView.tsx b/src/components/BlockingViews/FullPageNotFoundView.tsx
index d751d8bc666b..b9afe868878c 100644
--- a/src/components/BlockingViews/FullPageNotFoundView.tsx
+++ b/src/components/BlockingViews/FullPageNotFoundView.tsx
@@ -50,6 +50,9 @@ type FullPageNotFoundViewProps = {
/** Whether we should display the button that opens new SearchRouter */
shouldDisplaySearchRouter?: boolean;
+
+ /** Whether to add bottom safe area padding to the view. */
+ addBottomSafeAreaPadding?: boolean;
};
// eslint-disable-next-line rulesdir/no-negated-variables
@@ -67,6 +70,7 @@ function FullPageNotFoundView({
shouldForceFullScreen = false,
subtitleStyle,
shouldDisplaySearchRouter,
+ addBottomSafeAreaPadding = true,
}: FullPageNotFoundViewProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
@@ -93,6 +97,7 @@ function FullPageNotFoundView({
shouldShowLink={shouldShowLink}
onLinkPress={onLinkPress}
subtitleStyle={subtitleStyle}
+ addBottomSafeAreaPadding={addBottomSafeAreaPadding}
/>
diff --git a/src/components/BlockingViews/FullPageOfflineBlockingView.tsx b/src/components/BlockingViews/FullPageOfflineBlockingView.tsx
index 787752dd4e72..dc5c210b3178 100644
--- a/src/components/BlockingViews/FullPageOfflineBlockingView.tsx
+++ b/src/components/BlockingViews/FullPageOfflineBlockingView.tsx
@@ -6,7 +6,12 @@ import useTheme from '@hooks/useTheme';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
import BlockingView from './BlockingView';
-function FullPageOfflineBlockingView({children}: ChildrenProps) {
+type FullPageOfflineBlockingViewProps = ChildrenProps & {
+ /** Whether to add bottom safe area padding to the view. */
+ addBottomSafeAreaPadding?: boolean;
+};
+
+function FullPageOfflineBlockingView({children, addBottomSafeAreaPadding = true}: FullPageOfflineBlockingViewProps) {
const {translate} = useLocalize();
const {isOffline} = useNetwork();
@@ -19,6 +24,7 @@ function FullPageOfflineBlockingView({children}: ChildrenProps) {
iconColor={theme.offline}
title={translate('common.youAppearToBeOffline')}
subtitle={translate('common.thisFeatureRequiresInternet')}
+ addBottomSafeAreaPadding={addBottomSafeAreaPadding}
/>
);
}
diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx
index 5baba39107ac..fe3e28dcf50c 100644
--- a/src/components/Button/index.tsx
+++ b/src/components/Button/index.tsx
@@ -2,7 +2,7 @@ import {useIsFocused} from '@react-navigation/native';
import type {ForwardedRef} from 'react';
import React, {useCallback, useMemo, useState} from 'react';
import type {GestureResponderEvent, LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native';
-import {ActivityIndicator, View} from 'react-native';
+import {ActivityIndicator, StyleSheet, View} from 'react-native';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
@@ -152,6 +152,12 @@ type ButtonProps = Partial & {
/** The text displays under the first line */
secondLineText?: string;
+
+ /**
+ * Whether the button should have a background layer in the color of theme.appBG.
+ * This is needed for buttons that allow content to display under them.
+ */
+ shouldBlendOpacity?: boolean;
};
type KeyboardShortcutComponentProps = Pick;
@@ -255,6 +261,7 @@ function Button(
isPressOnEnterActive,
isNested = false,
secondLineText = '',
+ shouldBlendOpacity = false,
...rest
}: ButtonProps,
ref: ForwardedRef,
@@ -356,6 +363,57 @@ function Button(
return textComponent;
};
+ const buttonStyles = useMemo>(
+ () => [
+ styles.button,
+ StyleUtils.getButtonStyleWithIcon(styles, small, medium, large, !!icon, !!(text?.length > 0), shouldShowRightIcon),
+ success ? styles.buttonSuccess : undefined,
+ danger ? styles.buttonDanger : undefined,
+ isDisabled ? styles.buttonOpacityDisabled : undefined,
+ isDisabled && !danger && !success ? styles.buttonDisabled : undefined,
+ shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
+ shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
+ text && shouldShowRightIcon ? styles.alignItemsStretch : undefined,
+ innerStyles,
+ link && styles.bgTransparent,
+ ],
+ [
+ StyleUtils,
+ danger,
+ icon,
+ innerStyles,
+ isDisabled,
+ large,
+ link,
+ medium,
+ shouldRemoveLeftBorderRadius,
+ shouldRemoveRightBorderRadius,
+ shouldShowRightIcon,
+ small,
+ styles,
+ success,
+ text,
+ ],
+ );
+
+ const buttonContainerStyles = useMemo>(
+ () => [buttonStyles, shouldBlendOpacity && styles.buttonBlendContainer],
+ [buttonStyles, shouldBlendOpacity, styles.buttonBlendContainer],
+ );
+
+ const buttonBlendForegroundStyle = useMemo>(() => {
+ if (!shouldBlendOpacity) {
+ return undefined;
+ }
+
+ const {backgroundColor, opacity} = StyleSheet.flatten(buttonStyles);
+
+ return {
+ backgroundColor,
+ opacity,
+ };
+ }, [buttonStyles, shouldBlendOpacity]);
+
return (
<>
{pressOnEnter && (
@@ -402,6 +460,7 @@ function Button(
onPressIn={onPressIn}
onPressOut={onPressOut}
onMouseDown={onMouseDown}
+ shouldBlendOpacity={shouldBlendOpacity}
disabled={isLoading || isDisabled}
wrapperStyle={[
isDisabled ? {...styles.cursorDisabled, ...styles.noSelect} : {},
@@ -410,19 +469,7 @@ function Button(
shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
style,
]}
- style={[
- styles.button,
- StyleUtils.getButtonStyleWithIcon(styles, small, medium, large, !!icon, !!(text?.length > 0), shouldShowRightIcon),
- success ? styles.buttonSuccess : undefined,
- danger ? styles.buttonDanger : undefined,
- isDisabled ? styles.buttonOpacityDisabled : undefined,
- isDisabled && !danger && !success ? styles.buttonDisabled : undefined,
- shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
- shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
- text && shouldShowRightIcon ? styles.alignItemsStretch : undefined,
- innerStyles,
- link && styles.bgTransparent,
- ]}
+ style={buttonContainerStyles}
isNested={isNested}
hoverStyle={[
shouldUseDefaultHover && !isDisabled ? styles.buttonDefaultHovered : undefined,
@@ -439,6 +486,7 @@ function Button(
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
>
+ {shouldBlendOpacity && }
{renderContent()}
{isLoading && (
+ {changeWorkspaceMenuSections.map((section) => (
+
+
+
+ ${convertToLTR(translate(section.titleTranslationKey))}`} />
+
+
+ ))}
+ >
+ );
+}
+
+ChangeWorkspaceMenuSectionList.displayName = 'ChangeWorkspaceMenuSectionList';
+export default ChangeWorkspaceMenuSectionList;
diff --git a/src/components/CurrencySelectionList/index.tsx b/src/components/CurrencySelectionList/index.tsx
index fd0d4306f7c6..1052082480d3 100644
--- a/src/components/CurrencySelectionList/index.tsx
+++ b/src/components/CurrencySelectionList/index.tsx
@@ -18,6 +18,7 @@ function CurrencySelectionList({
canSelectMultiple = false,
recentlyUsedCurrencies,
excludedCurrencies = [],
+ ...restProps
}: CurrencySelectionListProps) {
const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST);
const [searchValue, setSearchValue] = useState('');
@@ -91,6 +92,8 @@ function CurrencySelectionList({
return (
> & {
/** Label for the search text input */
searchInputLabel: string;
diff --git a/src/components/EmptyStateComponent/index.tsx b/src/components/EmptyStateComponent/index.tsx
index 058b2e564f89..d3a715ad8748 100644
--- a/src/components/EmptyStateComponent/index.tsx
+++ b/src/components/EmptyStateComponent/index.tsx
@@ -29,6 +29,7 @@ function EmptyStateComponent({
lottieWebViewStyles,
showsVerticalScrollIndicator,
minModalHeight = 400,
+ addBottomSafeAreaPadding = false,
}: EmptyStateComponentProps) {
const styles = useThemeStyles();
const [videoAspectRatio, setVideoAspectRatio] = useState(VIDEO_ASPECT_RATIO);
@@ -88,6 +89,7 @@ function EmptyStateComponent({
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
contentContainerStyle={[{minHeight: minModalHeight}, styles.flexGrow1, styles.flexShrink0, containerStyles]}
style={styles.flex1}
+ addBottomSafeAreaPadding={addBottomSafeAreaPadding}
>
= {
lottieWebViewStyles?: React.CSSProperties | undefined;
minModalHeight?: number;
showsVerticalScrollIndicator?: boolean;
+
+ /** Whether to add bottom safe area padding to the view. */
+ addBottomSafeAreaPadding?: boolean;
};
type MediaType = SharedProps & {
diff --git a/src/components/FeatureTrainingModal.tsx b/src/components/FeatureTrainingModal.tsx
index 613d786230f2..0307fdb90284 100644
--- a/src/components/FeatureTrainingModal.tsx
+++ b/src/components/FeatureTrainingModal.tsx
@@ -115,6 +115,12 @@ type FeatureTrainingModalSVGProps = {
/** Determines how the image should be resized to fit its container */
contentFitImage?: ImageContentFit;
+
+ /** The width of the image */
+ imageWidth?: number;
+
+ /** The height of the image */
+ imageHeight?: number;
};
// This page requires either an icon or a video/animation, but not both
@@ -143,6 +149,8 @@ function FeatureTrainingModal({
contentInnerContainerStyles,
contentOuterContainerStyles,
modalInnerContainerStyle,
+ imageWidth,
+ imageHeight,
isModalDisabled = true,
}: FeatureTrainingModalProps) {
const styles = useThemeStyles();
@@ -211,6 +219,8 @@ function FeatureTrainingModal({
)}
{!!videoURL && videoStatus === 'video' && (
@@ -241,6 +251,8 @@ function FeatureTrainingModal({
);
}, [
image,
+ imageHeight,
+ imageWidth,
contentFitImage,
illustrationAspectRatio,
styles.w100,
diff --git a/src/components/FixedFooter.tsx b/src/components/FixedFooter.tsx
index 90adaec7a27e..fe9179fa88b4 100644
--- a/src/components/FixedFooter.tsx
+++ b/src/components/FixedFooter.tsx
@@ -1,7 +1,8 @@
import type {ReactNode} from 'react';
-import React from 'react';
+import React, {useMemo} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
+import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
import useThemeStyles from '@hooks/useThemeStyles';
type FixedFooterProps = {
@@ -10,16 +11,41 @@ type FixedFooterProps = {
/** Styles to be assigned to Container */
style?: StyleProp;
+
+ /** Whether to add bottom safe area padding to the content. */
+ addBottomSafeAreaPadding?: boolean;
+
+ /** Whether to stick the footer to the bottom of the screen. */
+ shouldStickToBottom?: boolean;
};
-function FixedFooter({style, children}: FixedFooterProps) {
+function FixedFooter({style, children, addBottomSafeAreaPadding = false, shouldStickToBottom = false}: FixedFooterProps) {
const styles = useThemeStyles();
+ const {paddingBottom} = useSafeAreaPaddings(true);
+
+ const footerStyle = useMemo>(() => {
+ const totalPaddingBottom = styles.pb5.paddingBottom + paddingBottom;
+
+ // If the footer should stick to the bottom, we use absolute positioning instead of flex.
+ // In this case, we need to use style.bottom instead of style.paddingBottom.
+ if (shouldStickToBottom) {
+ return {position: 'absolute', left: 0, right: 0, bottom: addBottomSafeAreaPadding ? totalPaddingBottom : styles.pb5.paddingBottom};
+ }
+
+ // If the footer should not stick to the bottom, we use flex and add the safe area padding in styles.paddingBottom.
+ if (addBottomSafeAreaPadding) {
+ return {paddingBottom: totalPaddingBottom};
+ }
+
+ // Otherwise, we just use the default bottom padding.
+ return styles.pb5;
+ }, [addBottomSafeAreaPadding, paddingBottom, shouldStickToBottom, styles.pb5]);
if (!children) {
return null;
}
- return {children};
+ return {children};
}
FixedFooter.displayName = 'FixedFooter';
diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx
index bf3746b61776..6c8d3f989e16 100644
--- a/src/components/Form/FormProvider.tsx
+++ b/src/components/Form/FormProvider.tsx
@@ -75,6 +75,12 @@ type FormProviderProps = FormProps(null);
const formContentRef = useRef(null);
@@ -103,13 +110,78 @@ function FormWrapper({
focusInput?.focus?.();
}, [errors, formState?.errorFields, inputRefs]);
+ const {paddingBottom} = useSafeAreaPaddings(true);
+ const SubmitButton = useMemo(
+ () =>
+ isSubmitButtonVisible && (
+
+ ),
+ [
+ disablePressOnEnter,
+ enabledWhenOffline,
+ errorMessage,
+ errors,
+ footerContent,
+ formState?.errorFields,
+ formState?.isLoading,
+ isLoading,
+ isSubmitActionDangerous,
+ isSubmitButtonVisible,
+ isSubmitDisabled,
+ onFixTheErrorsLinkPressed,
+ onSubmit,
+ paddingBottom,
+ shouldHideFixErrorsAlert,
+ shouldSubmitButtonStickToBottom,
+ style,
+ styles.flex1,
+ styles.mh0,
+ styles.mt5,
+ styles.pb5.paddingBottom,
+ submitButtonStyles,
+ submitButtonText,
+ submitFlexEnabled,
+ ],
+ );
+
const scrollViewContent = useCallback(
() => (
{
if (!shouldScrollToEnd) {
return;
@@ -122,77 +194,50 @@ function FormWrapper({
}}
>
{children}
- {isSubmitButtonVisible && (
-
- )}
+ {!shouldSubmitButtonStickToBottom && SubmitButton}
),
- [
- formID,
- style,
- safeAreaInsetPaddingBottom,
- styles.pb5.paddingBottom,
- styles.mh0,
- styles.mt5,
- styles.flex1,
- children,
- isSubmitButtonVisible,
- submitButtonText,
- isSubmitDisabled,
- errors,
- formState?.errorFields,
- formState?.isLoading,
- shouldHideFixErrorsAlert,
- errorMessage,
- isLoading,
- onSubmit,
- footerContent,
- onFixTheErrorsLinkPressed,
- submitFlexEnabled,
- submitButtonStyles,
- enabledWhenOffline,
- isSubmitActionDangerous,
- disablePressOnEnter,
- shouldScrollToEnd,
- ],
+ [formID, style, styles.pb5, children, shouldSubmitButtonStickToBottom, SubmitButton, shouldScrollToEnd],
);
if (!shouldUseScrollView) {
+ if (shouldSubmitButtonStickToBottom) {
+ return (
+ <>
+ {scrollViewContent()}
+ {SubmitButton}
+ >
+ );
+ }
+
return scrollViewContent();
}
- return scrollContextEnabled ? (
-
- {scrollViewContent()}
-
- ) : (
-
- {scrollViewContent()}
-
+ return (
+
+ {scrollContextEnabled ? (
+
+ {scrollViewContent()}
+
+ ) : (
+
+ {scrollViewContent()}
+
+ )}
+ {shouldSubmitButtonStickToBottom && SubmitButton}
+
);
}
diff --git a/src/components/FormAlertWithSubmitButton.tsx b/src/components/FormAlertWithSubmitButton.tsx
index fabb5e54cb60..3eb0b0abd508 100644
--- a/src/components/FormAlertWithSubmitButton.tsx
+++ b/src/components/FormAlertWithSubmitButton.tsx
@@ -62,6 +62,12 @@ type FormAlertWithSubmitButtonProps = {
/** The priority to assign the enter key event listener to buttons. 0 is the highest priority. */
enterKeyEventListenerPriority?: number;
+
+ /**
+ * Whether the button should have a background layer in the color of theme.appBG.
+ * This is needed for buttons that allow content to display under them.
+ */
+ shouldBlendOpacity?: boolean;
};
function FormAlertWithSubmitButton({
@@ -83,6 +89,7 @@ function FormAlertWithSubmitButton({
useSmallerSubmitButtonSize = false,
errorMessageStyle,
enterKeyEventListenerPriority = 0,
+ shouldBlendOpacity = false,
}: FormAlertWithSubmitButtonProps) {
const styles = useThemeStyles();
const style = [!footerContent ? {} : styles.mb3, buttonStyles];
@@ -107,6 +114,7 @@ function FormAlertWithSubmitButton({
{isOffline && !enabledWhenOffline ? (
);
diff --git a/src/components/ScrollView.tsx b/src/components/ScrollView.tsx
index a61c592015ee..8a2a4519e0d4 100644
--- a/src/components/ScrollView.tsx
+++ b/src/components/ScrollView.tsx
@@ -2,9 +2,20 @@ import React from 'react';
import type {ForwardedRef} from 'react';
// eslint-disable-next-line no-restricted-imports
import {ScrollView as RNScrollView} from 'react-native';
-import type {ScrollViewProps} from 'react-native';
+import type {ScrollViewProps as RNScrollViewProps} from 'react-native';
+import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle';
+
+type ScrollViewProps = RNScrollViewProps & {
+ /** Whether to add bottom safe area padding to the content. */
+ addBottomSafeAreaPadding?: boolean;
+};
+
+function ScrollView(
+ {children, scrollIndicatorInsets, contentContainerStyle: contentContainerStyleProp, addBottomSafeAreaPadding = false, ...props}: ScrollViewProps,
+ ref: ForwardedRef,
+) {
+ const contentContainerStyle = useBottomSafeSafeAreaPaddingStyle({addBottomSafeAreaPadding, style: contentContainerStyleProp});
-function ScrollView({children, scrollIndicatorInsets, ...props}: ScrollViewProps, ref: ForwardedRef) {
return (
({filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.TYPE, text: type}));
}
+ case CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY: {
+ const filteredGroupBy = groupByAutocompleteList.filter(
+ (groupByValue) => groupByValue.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(groupByValue.toLowerCase()),
+ );
+ return filteredGroupBy.map((groupByValue) => ({filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.GROUP_BY, text: groupByValue}));
+ }
case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: {
const filteredStatuses = statusAutocompleteList
.filter((status) => status.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(status))
@@ -405,6 +412,7 @@ function SearchAutocompleteList(
workspaceCardFeeds,
cardAutocompleteList,
allCards,
+ groupByAutocompleteList,
]);
const sortedRecentSearches = useMemo(() => {
diff --git a/src/components/Search/SearchPageHeader/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader/SearchPageHeader.tsx
index 8197083b44c0..09f03326c555 100644
--- a/src/components/Search/SearchPageHeader/SearchPageHeader.tsx
+++ b/src/components/Search/SearchPageHeader/SearchPageHeader.tsx
@@ -46,12 +46,11 @@ type SearchPageHeaderProps = {
searchRouterListVisible?: boolean;
hideSearchRouterList?: () => void;
onSearchRouterFocus?: () => void;
- shouldGroupByReports?: boolean;
};
type SearchHeaderOptionValue = DeepValueOf | undefined;
-function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, shouldGroupByReports}: SearchPageHeaderProps) {
+function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus}: SearchPageHeaderProps) {
const {translate} = useLocalize();
const theme = useTheme();
const styles = useThemeStyles();
@@ -433,7 +432,6 @@ function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideS
searchName={searchName}
hideSearchRouterList={hideSearchRouterList}
inputRightComponent={InputRightComponent}
- shouldGroupByReports={shouldGroupByReports}
/>
void;
searchName?: string;
inputRightComponent: React.ReactNode;
- shouldGroupByReports?: boolean;
};
-function SearchPageHeaderInput({
- queryJSON,
- searchRouterListVisible,
- hideSearchRouterList,
- onSearchRouterFocus,
- searchName,
- inputRightComponent,
- shouldGroupByReports,
-}: SearchPageHeaderInputProps) {
+function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, searchName, inputRightComponent}: SearchPageHeaderInputProps) {
const {translate} = useLocalize();
const [showPopupButton, setShowPopupButton] = useState(true);
const styles = useThemeStyles();
@@ -252,7 +244,7 @@ function SearchPageHeaderInput({
onSearchQueryChange={onSearchQueryChange}
isFullWidth
onSubmit={() => {
- submitSearch(textInputValue);
+ KeyboardUtils.dismiss().then(() => submitSearch(textInputValue));
}}
autoFocus={false}
onFocus={onFocus}
@@ -271,7 +263,6 @@ function SearchPageHeaderInput({
>
diff --git a/src/components/Search/SearchPageHeader/SearchTypeMenuPopover.tsx b/src/components/Search/SearchPageHeader/SearchTypeMenuPopover.tsx
index 1f3f49886e71..8eac3505f1af 100644
--- a/src/components/Search/SearchPageHeader/SearchTypeMenuPopover.tsx
+++ b/src/components/Search/SearchPageHeader/SearchTypeMenuPopover.tsx
@@ -10,8 +10,8 @@ import type {SearchQueryJSON} from '@components/Search/types';
import ThreeDotsMenu from '@components/ThreeDotsMenu';
import useDeleteSavedSearch from '@hooks/useDeleteSavedSearch';
import useLocalize from '@hooks/useLocalize';
+import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
import useSingleExecution from '@hooks/useSingleExecution';
-import useStyledSafeAreaInsets from '@hooks/useStyledSafeAreaInsets';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
@@ -39,16 +39,15 @@ type SavedSearchMenuItem = MenuItemWithLink & {
type SearchTypeMenuNarrowProps = {
queryJSON: SearchQueryJSON;
searchName?: string;
- shouldGroupByReports?: boolean;
};
-function SearchTypeMenuPopover({queryJSON, searchName, shouldGroupByReports}: SearchTypeMenuNarrowProps) {
+function SearchTypeMenuPopover({queryJSON, searchName}: SearchTypeMenuNarrowProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {singleExecution} = useSingleExecution();
const {windowHeight} = useWindowDimensions();
const {translate} = useLocalize();
- const {hash, policyID} = queryJSON;
+ const {hash, policyID, groupBy} = queryJSON;
const {showDeleteModal, DeleteConfirmModal} = useDeleteSavedSearch();
const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
const [session] = useOnyx(ONYXKEYS.SESSION);
@@ -58,7 +57,8 @@ function SearchTypeMenuPopover({queryJSON, searchName, shouldGroupByReports}: Se
const [userCardList] = useOnyx(ONYXKEYS.CARD_LIST);
const [workspaceCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST);
const allCards = useMemo(() => mergeCardListWithWorkspaceFeeds(workspaceCardFeeds ?? CONST.EMPTY_OBJECT, userCardList), [userCardList, workspaceCardFeeds]);
- const {unmodifiedPaddings} = useStyledSafeAreaInsets();
+ const {unmodifiedPaddings} = useSafeAreaPaddings();
+ const shouldGroupByReports = groupBy === CONST.SEARCH.GROUP_BY.REPORTS;
const cardFeedNamesWithType = useMemo(() => {
return getCardFeedNamesWithType({workspaceCardFeeds, userCardList, translate});
}, [translate, workspaceCardFeeds, userCardList]);
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index 24bdb14e03eb..3fbf4d083147 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -52,7 +52,6 @@ type SearchProps = {
contentContainerStyle?: StyleProp;
isSearchScreenFocused?: boolean;
onContentSizeChange?: (w: number, h: number) => void;
- shouldGroupByReports?: boolean;
};
const transactionItemMobileHeight = 100;
@@ -127,7 +126,7 @@ function prepareTransactionsList(item: TransactionListItemType, selectedTransact
};
}
-function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentContainerStyle, onContentSizeChange, shouldGroupByReports}: SearchProps) {
+function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentContainerStyle, onContentSizeChange}: SearchProps) {
const {isOffline} = useNetwork();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const styles = useThemeStyles();
@@ -150,13 +149,14 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo
const {selectionMode} = useMobileSelectionMode();
const [offset, setOffset] = useState(0);
- const {type, status, sortBy, sortOrder, hash} = queryJSON;
+ const {type, status, sortBy, sortOrder, hash, groupBy} = queryJSON;
const [currentSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`);
const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION);
const previousTransactions = usePrevious(transactions);
const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS);
const previousReportActions = usePrevious(reportActions);
+ const shouldGroupByReports = groupBy === CONST.SEARCH.GROUP_BY.REPORTS;
useEffect(() => {
if (!currentSearchResults?.search?.type) {
@@ -267,7 +267,7 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo
return [];
}
return getSections(type, status, searchResults.data, searchResults.search, shouldGroupByReports);
- }, [searchResults, status, type, shouldGroupByReports]);
+ }, [searchResults, type, status, shouldGroupByReports]);
useEffect(() => {
/** We only want to display the skeleton for the status filters the first time we load them for a specific data type */
diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts
index 3dc408cc27c3..d5be6bb83cf4 100644
--- a/src/components/Search/types.ts
+++ b/src/components/Search/types.ts
@@ -58,6 +58,7 @@ type InvoiceSearchStatus = ValueOf;
type TripSearchStatus = ValueOf;
type ChatSearchStatus = ValueOf;
type SearchStatus = ExpenseSearchStatus | InvoiceSearchStatus | TripSearchStatus | ChatSearchStatus | Array;
+type SearchGroupBy = ValueOf;
type SearchContext = {
currentSearchHash: number;
@@ -96,7 +97,8 @@ type SearchFilterKey =
| ValueOf
| typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE
| typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS
- | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID;
+ | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID
+ | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY;
type UserFriendlyKey = ValueOf;
@@ -112,6 +114,7 @@ type SearchQueryAST = {
status: SearchStatus;
sortBy: SearchColumnType;
sortOrder: SortOrder;
+ groupBy?: SearchGroupBy;
filters: ASTNode;
policyID?: string;
};
diff --git a/src/components/SectionList/BaseSectionList.tsx b/src/components/SectionList/BaseSectionList.tsx
new file mode 100644
index 000000000000..dc40211030b4
--- /dev/null
+++ b/src/components/SectionList/BaseSectionList.tsx
@@ -0,0 +1,24 @@
+import React, {forwardRef} from 'react';
+import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle';
+import AnimatedSectionList from './AnimatedSectionList';
+import type {SectionListProps, SectionListRef} from './types';
+
+function BaseSectionList(
+ {addBottomSafeAreaPadding = false, contentContainerStyle: contentContainerStyleProp, ...restProps}: SectionListProps,
+ ref: SectionListRef,
+) {
+ const contentContainerStyle = useBottomSafeSafeAreaPaddingStyle({addBottomSafeAreaPadding, style: contentContainerStyleProp});
+
+ return (
+
+ );
+}
+
+BaseSectionList.displayName = 'BaseSectionList';
+
+export default forwardRef(BaseSectionList);
diff --git a/src/components/SectionList/index.android.tsx b/src/components/SectionList/index.android.tsx
index 157e546b3ea9..d9877fd5afca 100644
--- a/src/components/SectionList/index.android.tsx
+++ b/src/components/SectionList/index.android.tsx
@@ -1,10 +1,10 @@
import React, {forwardRef} from 'react';
-import AnimatedSectionList from './AnimatedSectionList';
+import BaseSectionList from './BaseSectionList';
import type {SectionListProps, SectionListRef} from './types';
function SectionListWithRef(props: SectionListProps, ref: SectionListRef) {
return (
- (props: SectionListProps, ref: SectionListRef) {
return (
- = RNSectionListProps & {
+ /** Whether to add bottom safe area padding to the content. */
+ addBottomSafeAreaPadding?: boolean;
+};
-type SectionListProps = SectionListPropsRN;
type SectionListRef = ForwardedRef>;
export type {SectionListProps, SectionListRef};
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index 67cf5e5a3841..c1e3818a12e2 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -16,13 +16,14 @@ import Text from '@components/Text';
import TextInput from '@components/TextInput';
import useActiveElementRole from '@hooks/useActiveElementRole';
import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
+import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useKeyboardState from '@hooks/useKeyboardState';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
+import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
import useScrollEnabled from '@hooks/useScrollEnabled';
import useSingleExecution from '@hooks/useSingleExecution';
-import useStyledSafeAreaInsets from '@hooks/useStyledSafeAreaInsets';
import useThemeStyles from '@hooks/useThemeStyles';
import getSectionsWithIndexOffset from '@libs/getSectionsWithIndexOffset';
import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/KeyboardShortcut/KeyDownPressListener';
@@ -33,7 +34,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject';
import arraysEqual from '@src/utils/arraysEqual';
import BaseSelectionListItemRenderer from './BaseSelectionListItemRenderer';
import FocusAwareCellRendererComponent from './FocusAwareCellRendererComponent';
-import type {BaseSelectionListProps, ButtonOrCheckBoxRoles, FlattenedSectionsReturn, ListItem, SectionListDataType, SectionWithIndexOffset, SelectionListHandle} from './types';
+import type {ButtonOrCheckBoxRoles, FlattenedSectionsReturn, ListItem, SectionListDataType, SectionWithIndexOffset, SelectionListHandle, SelectionListProps} from './types';
const getDefaultItemHeight = () => variables.optionRowHeight;
@@ -115,7 +116,7 @@ function BaseSelectionList(
listItemWrapperStyle,
shouldIgnoreFocus = false,
scrollEventThrottle,
- contentContainerStyle,
+ contentContainerStyle: contentContainerStyleProp,
shouldHighlightSelectedItem = false,
shouldKeepFocusedItemAtTopOfViewableArea = false,
shouldDebounceScrolling = false,
@@ -127,7 +128,8 @@ function BaseSelectionList(
listItemTitleContainerStyles,
isScreenFocused = false,
shouldSubscribeToArrowKeyEvents = true,
- }: BaseSelectionListProps,
+ addBottomSafeAreaPadding = false,
+ }: SelectionListProps,
ref: ForwardedRef,
) {
const styles = useThemeStyles();
@@ -823,11 +825,24 @@ function BaseSelectionList(
);
- const {safeAreaPaddingBottomStyle} = useStyledSafeAreaInsets();
+ const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings();
+ const paddingBottomStyle = useMemo(
+ () => (!isKeyboardShown || !!footerContent) && includeSafeAreaPaddingBottom && safeAreaPaddingBottomStyle,
+ [footerContent, includeSafeAreaPaddingBottom, isKeyboardShown, safeAreaPaddingBottomStyle],
+ );
+
+ // If the default confirm button is visible and it is bottom-sticky,
+ // we need to add additional padding bottom to the content container.
+ const contentContainerStyle = useBottomSafeSafeAreaPaddingStyle({
+ addBottomSafeAreaPadding: false, // Bottom safe area padding is already applied in the SectionList
+ style: contentContainerStyleProp,
+ });
+
+ const shouldHideContentBottomSafeAreaPadding = showConfirmButton || !!footerContent;
// TODO: test _every_ component that uses SelectionList
return (
-
+
{shouldShowTextInput && !shouldShowTextInputAfterHeader && renderInput()}
{/* If we are loading new options we will avoid showing any header message. This is mostly because one of the header messages says there are no options. */}
{/* This is misleading because we might be in the process of loading fresh options from the server. */}
@@ -884,6 +899,7 @@ function BaseSelectionList(
onEndReached={onEndReached}
onEndReachedThreshold={onEndReachedThreshold}
scrollEventThrottle={scrollEventThrottle}
+ addBottomSafeAreaPadding={!shouldHideContentBottomSafeAreaPadding && addBottomSafeAreaPadding}
contentContainerStyle={contentContainerStyle}
CellRendererComponent={shouldPreventActiveCellVirtualization ? FocusAwareCellRendererComponent : undefined}
/>
@@ -891,7 +907,10 @@ function BaseSelectionList(
>
)}
{showConfirmButton && (
-
+
)}
- {!!footerContent && {footerContent}}
+ {!!footerContent && (
+
+ {footerContent}
+
+ )}
);
}
diff --git a/src/components/SelectionList/BaseSelectionListItemRenderer.tsx b/src/components/SelectionList/BaseSelectionListItemRenderer.tsx
index 987a72e025c1..9f3623f75f43 100644
--- a/src/components/SelectionList/BaseSelectionListItemRenderer.tsx
+++ b/src/components/SelectionList/BaseSelectionListItemRenderer.tsx
@@ -4,10 +4,10 @@ import type useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
import type useSingleExecution from '@hooks/useSingleExecution';
import {isMobileChrome} from '@libs/Browser';
import {isReportListItemType} from '@libs/SearchUIUtils';
-import type {BaseListItemProps, BaseSelectionListProps, ExtendedTargetedEvent, ListItem} from './types';
+import type {BaseListItemProps, ExtendedTargetedEvent, ListItem, SelectionListProps} from './types';
type BaseSelectionListItemRendererProps = Omit, 'onSelectRow'> &
- Pick, 'ListItem' | 'shouldHighlightSelectedItem' | 'shouldIgnoreFocus' | 'shouldSingleExecuteRowSelect'> & {
+ Pick, 'ListItem' | 'shouldHighlightSelectedItem' | 'shouldIgnoreFocus' | 'shouldSingleExecuteRowSelect'> & {
index: number;
selectRow: (item: TItem, indexToFocus?: number) => void;
setFocusedIndex: ReturnType[1];
diff --git a/src/components/SelectionList/index.native.tsx b/src/components/SelectionList/index.native.tsx
index baccdf7c6024..4207f65c639c 100644
--- a/src/components/SelectionList/index.native.tsx
+++ b/src/components/SelectionList/index.native.tsx
@@ -2,9 +2,9 @@ import React, {forwardRef} from 'react';
import type {ForwardedRef} from 'react';
import {Keyboard} from 'react-native';
import BaseSelectionList from './BaseSelectionList';
-import type {BaseSelectionListProps, ListItem, SelectionListHandle} from './types';
+import type {ListItem, SelectionListHandle, SelectionListProps} from './types';
-function SelectionList(props: BaseSelectionListProps, ref: ForwardedRef) {
+function SelectionList(props: SelectionListProps, ref: ForwardedRef) {
return (
({onScroll, ...props}: BaseSelectionListProps, ref: ForwardedRef) {
+function SelectionList({onScroll, ...props}: SelectionListProps, ref: ForwardedRef) {
const [isScreenTouched, setIsScreenTouched] = useState(false);
const touchStart = () => setIsScreenTouched(true);
const touchEnd = () => setIsScreenTouched(false);
useEffect(() => {
- if (!DeviceCapabilities.canUseTouchScreen()) {
+ if (!canUseTouchScreen()) {
return;
}
@@ -73,7 +73,7 @@ function SelectionList({onScroll, ...props}: BaseSelecti
onScroll={onScroll ?? defaultOnScroll}
// Ignore the focus if it's caused by a touch event on mobile chrome.
// For example, a long press will trigger a focus event on mobile chrome.
- shouldIgnoreFocus={Browser.isMobileChrome() && isScreenTouched}
+ shouldIgnoreFocus={isMobileChrome() && isScreenTouched}
shouldDebounceScrolling={shouldDebounceScrolling}
/>
);
diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts
index 3eb63ae97242..c409c6a61d38 100644
--- a/src/components/SelectionList/types.ts
+++ b/src/components/SelectionList/types.ts
@@ -394,7 +394,7 @@ type SkeletonViewProps = {
shouldAnimate: boolean;
};
-type BaseSelectionListProps = Partial & {
+type SelectionListProps = Partial & {
/** Sections for the section list */
sections: Array> | typeof CONST.EMPTY_ARRAY;
@@ -668,6 +668,9 @@ type BaseSelectionListProps = Partial & {
/** Whether the screen is focused or not. (useIsFocused state does not work in tab screens, e.g. SearchPageBottomTab) */
isScreenFocused?: boolean;
+
+ /** Whether to add bottom safe area padding to the content. */
+ addBottomSafeAreaPadding?: boolean;
} & TRightHandSideComponent;
type SelectionListHandle = {
@@ -704,7 +707,7 @@ type SectionListDataType = ExtendedSectionListData = BaseSelectionListProps & {
+type SelectionListWithModalProps = SelectionListProps & {
turnOnSelectionModeOnLongPress?: boolean;
onTurnOnSelectionMode?: (item: TItem | null) => void;
isSelected?: (item: TItem) => boolean;
diff --git a/src/components/SidePane/Help/HelpContent.tsx b/src/components/SidePane/Help/HelpContent.tsx
new file mode 100644
index 000000000000..9080dd84cc32
--- /dev/null
+++ b/src/components/SidePane/Help/HelpContent.tsx
@@ -0,0 +1,61 @@
+import {findFocusedRoute} from '@react-navigation/native';
+import React, {useEffect, useRef} from 'react';
+import HeaderGap from '@components/HeaderGap';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScrollView from '@components/ScrollView';
+import getHelpContent from '@components/SidePane/getHelpContent';
+import useEnvironment from '@hooks/useEnvironment';
+import useLocalize from '@hooks/useLocalize';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useRootNavigationState from '@hooks/useRootNavigationState';
+import useSidePane from '@hooks/useSidePane';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import {substituteRouteParameters} from '@libs/SidePaneUtils';
+
+function HelpContent() {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {isProduction} = useEnvironment();
+ const {isExtraLargeScreenWidth} = useResponsiveLayout();
+ const {closeSidePane} = useSidePane();
+ const route = useRootNavigationState((state) => {
+ const params = (findFocusedRoute(state)?.params as Record) ?? {};
+ const activeRoute = Navigation.getActiveRouteWithoutParams();
+ return substituteRouteParameters(activeRoute, params);
+ });
+
+ const wasPreviousNarrowScreen = useRef(!isExtraLargeScreenWidth);
+ useEffect(() => {
+ // Close the side pane when the screen size changes from large to small
+ if (!isExtraLargeScreenWidth && !wasPreviousNarrowScreen.current) {
+ closeSidePane(true);
+ wasPreviousNarrowScreen.current = true;
+ }
+
+ // Reset the trigger when the screen size changes back to large
+ if (isExtraLargeScreenWidth) {
+ wasPreviousNarrowScreen.current = false;
+ }
+ }, [isExtraLargeScreenWidth, closeSidePane]);
+
+ return (
+ <>
+
+ closeSidePane(false)}
+ onCloseButtonPress={() => closeSidePane(false)}
+ shouldShowBackButton={!isExtraLargeScreenWidth}
+ shouldShowCloseButton={isExtraLargeScreenWidth}
+ shouldDisplayHelpButton={false}
+ />
+ {getHelpContent(styles, route, isProduction)}
+ >
+ );
+}
+
+HelpContent.displayName = 'HelpContent';
+
+export default HelpContent;
diff --git a/src/components/SidePane/Help/index.android.tsx b/src/components/SidePane/Help/index.android.tsx
new file mode 100644
index 000000000000..8be21c35a535
--- /dev/null
+++ b/src/components/SidePane/Help/index.android.tsx
@@ -0,0 +1,38 @@
+import {useFocusEffect} from '@react-navigation/native';
+import React, {useCallback} from 'react';
+// eslint-disable-next-line no-restricted-imports
+import {Animated, BackHandler} from 'react-native';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
+import useThemeStyles from '@hooks/useThemeStyles';
+import HelpContent from './HelpContent';
+import type HelpProps from './types';
+
+function Help({sidePaneTranslateX, closeSidePane}: HelpProps) {
+ const styles = useThemeStyles();
+ const {isExtraLargeScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
+ const {paddingTop, paddingBottom} = useSafeAreaPaddings();
+
+ // SidePane isn't a native screen, this handles the back button press on Android
+ useFocusEffect(
+ useCallback(() => {
+ const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
+ closeSidePane();
+ // Return true to indicate that the back button press is handled here
+ return true;
+ });
+
+ return () => backHandler.remove();
+ }, [closeSidePane]),
+ );
+
+ return (
+
+
+
+ );
+}
+
+Help.displayName = 'Help';
+
+export default Help;
diff --git a/src/components/SidePane/Help/index.ios.tsx b/src/components/SidePane/Help/index.ios.tsx
new file mode 100644
index 000000000000..171b98e2c14d
--- /dev/null
+++ b/src/components/SidePane/Help/index.ios.tsx
@@ -0,0 +1,58 @@
+import React from 'react';
+// eslint-disable-next-line no-restricted-imports
+import {Animated, Dimensions} from 'react-native';
+import {Gesture, GestureDetector} from 'react-native-gesture-handler';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+import HelpContent from './HelpContent';
+import type HelpProps from './types';
+
+const SCREEN_WIDTH = Dimensions.get('window').width;
+
+function Help({sidePaneTranslateX, closeSidePane}: HelpProps) {
+ const styles = useThemeStyles();
+ const {isExtraLargeScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
+ const {paddingTop, paddingBottom} = useSafeAreaPaddings();
+
+ // SidePane isn't a native screen, this simulates the 'close swipe gesture' on iOS
+ const panGesture = Gesture.Pan()
+ .runOnJS(true)
+ .hitSlop({left: 0, width: 20})
+ .onUpdate((event) => {
+ if (event.translationX <= 0) {
+ return;
+ }
+ sidePaneTranslateX.current.setValue(event.translationX);
+ })
+ .onEnd((event) => {
+ if (event.translationX > 100) {
+ // If swiped far enough, animate out and close
+ Animated.timing(sidePaneTranslateX.current, {
+ toValue: SCREEN_WIDTH,
+ duration: CONST.ANIMATED_TRANSITION,
+ useNativeDriver: false,
+ }).start(() => closeSidePane());
+ } else {
+ // Otherwise, animate back to original position
+ Animated.spring(sidePaneTranslateX.current, {
+ toValue: 0,
+ useNativeDriver: false,
+ }).start();
+ }
+ });
+
+ return (
+
+
+
+
+
+ );
+}
+
+Help.displayName = 'Help';
+export default Help;
diff --git a/src/components/SidePane/Help/index.tsx b/src/components/SidePane/Help/index.tsx
new file mode 100644
index 000000000000..597bff5391ad
--- /dev/null
+++ b/src/components/SidePane/Help/index.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+// eslint-disable-next-line no-restricted-imports
+import {Animated} from 'react-native';
+import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+import HelpContent from './HelpContent';
+import type HelpProps from './types';
+
+function Help({sidePaneTranslateX, closeSidePane}: HelpProps) {
+ const styles = useThemeStyles();
+ const {isExtraLargeScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
+ const {paddingTop, paddingBottom} = useSafeAreaPaddings();
+
+ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => closeSidePane(), {isActive: !isExtraLargeScreenWidth});
+
+ return (
+
+
+
+ );
+}
+
+Help.displayName = 'Help';
+
+export default Help;
diff --git a/src/components/SidePane/Help/types.ts b/src/components/SidePane/Help/types.ts
new file mode 100644
index 000000000000..8054cc6cd43d
--- /dev/null
+++ b/src/components/SidePane/Help/types.ts
@@ -0,0 +1,10 @@
+import type {MutableRefObject} from 'react';
+// eslint-disable-next-line no-restricted-imports
+import type {Animated} from 'react-native';
+
+type HelpProps = {
+ sidePaneTranslateX: MutableRefObject;
+ closeSidePane: (shouldUpdateNarrow?: boolean) => void;
+};
+
+export default HelpProps;
diff --git a/src/components/SidePane/HelpButton.tsx b/src/components/SidePane/HelpButton.tsx
index 3ad251797378..a7b305e7f616 100644
--- a/src/components/SidePane/HelpButton.tsx
+++ b/src/components/SidePane/HelpButton.tsx
@@ -10,6 +10,7 @@ import useSidePane from '@hooks/useSidePane';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {triggerSidePane} from '@libs/actions/SidePane';
+import KeyboardUtils from '@src/utils/keyboard';
type HelpButtonProps = {
style?: StyleProp;
@@ -31,7 +32,13 @@ function HelpButton({style}: HelpButtonProps) {
triggerSidePane(isExtraLargeScreenWidth ? !sidePane?.open : !sidePane?.openNarrowScreen, {shouldUpdateNarrowLayout: !isExtraLargeScreenWidth})}
+ onPress={() => {
+ KeyboardUtils.dismiss();
+ triggerSidePane({
+ isOpen: isExtraLargeScreenWidth ? !sidePane?.open : !sidePane?.openNarrowScreen,
+ isOpenNarrowScreen: isExtraLargeScreenWidth ? undefined : !sidePane?.openNarrowScreen,
+ });
+ }}
>
= {
r: {
content: (styles: ThemeStyles) => (
<>
- Chat Reports
- ... general chat reports help ...
+ Inbox
+
+ Chat is the foundation of New Expensify. Every expense, expense report, workspace, or member has an associated "chat", which you can use to record additional details, or
+ collaborate with others. Every chat has the following components:
+
+
+ Header
+
+ This shows who you are chatting with (or what you are chatting about). You can press the header for more details on the chat, or additional actions to take upon it.
+
+
+ Comments
+ The core of the chat are its comments, which come in many forms:
+
+ {CONST.BULLET} Text - Rich text messages stored securely and delivered via web, app, email, or SMS.
+
+
+ {CONST.BULLET} Images & Documents - Insert photos, screenshots, movies, PDFs, or more, using copy/paste, drag/drop, or the attach
+ button
+
+
+ {CONST.BULLET} Expenses - Share an expense in the chat, either to simply track and document it, or to submit for reimbursement.
+
+
+ {CONST.BULLET} Tasks - Record a task, and optionally assign it to someone (or yourself!)
+
+
+ Actions
+ Hover (or long press) on a comment to see additional options, including:
+
+ {CONST.BULLET} React - Throw a ♥️😂🔥 like on anything!
+
+
+ {CONST.BULLET} Reply in thread - Go deeper by creating a new chat on any comment.
+
+
+ {CONST.BULLET} Mark unread - Flag it for reading later, at your convenience.
+
+
+ Composer
+ Use the composer at the bottom to write new messages:
+
+ {CONST.BULLET} Markdown - Format text using *bold*,{' '}
+ _italics_, and more.
+
+
+ {CONST.BULLET} Mention - Invite or tag anyone in the world to any chat by putting an @ in front of their email address or phone
+ number (eg,
+ @awong@marslink.web, or @415-867-5309).
+
+
+ {CONST.BULLET} Mention - Invite or tag anyone in the world to any chat by putting an @ in front of their email address or phone
+ number (eg,
+ @awong@marslink.web, or @415-867-5309).
+
+ >
+ ),
+ },
+ home: {
+ content: (styles: ThemeStyles) => (
+ <>
+ Inbox
+
+ Chat is the foundation of New Expensify. Every expense, expense report, workspace, or member has an associated "chat", which you can use to record additional details, or
+ collaborate with others. Every chat has the following components:
+
+
+ Header
+
+ This shows who you are chatting with (or what you are chatting about). You can press the header for more details on the chat, or additional actions to take upon it.
+
+
+ Comments
+ The core of the chat are its comments, which come in many forms:
+
+ {CONST.BULLET} Text - Rich text messages stored securely and delivered via web, app, email, or SMS.
+
+
+ {CONST.BULLET} Images & Documents - Insert photos, screenshots, movies, PDFs, or more, using copy/paste, drag/drop, or the attach
+ button
+
+
+ {CONST.BULLET} Expenses - Share an expense in the chat, either to simply track and document it, or to submit for reimbursement.
+
+
+ {CONST.BULLET} Tasks - Record a task, and optionally assign it to someone (or yourself!)
+
+
+ Actions
+ Hover (or long press) on a comment to see additional options, including:
+
+ {CONST.BULLET} React - Throw a ❤️😂🔥 or anything you like on anything!
+
+
+ {CONST.BULLET} Reply in thread - Go deeper by creating a new chat on any comment.
+
+
+ {CONST.BULLET} Mark unread - Flag it for reading later, at your convenience.
+
+
+ Composer
+ Use the composer at the bottom to write new messages:
+
+ {CONST.BULLET} Markdown - Format text using *bold*,{' '}
+ _italics_, and more.
+
+
+ {CONST.BULLET} Mention - Invite or tag anyone in the world to any chat by putting an @ in front of their email address or phone
+ number (eg,
+ @awong@marslink.web, or @415-867-5309).
+
>
),
- children: {
- ':reportID': {
- content: (styles: ThemeStyles) => (
- <>
- Chat Report
- ... general chat report help ...
- >
- ),
- },
- },
},
search: {
content: (styles: ThemeStyles) => (
<>
- Searching Reports
- ... general search help ...
+ Reports
+ Virtually all data can be analyzed and reported upon in the Reports page. The major elements of this page include:
+
+ Data type
+ Start first by choosing the type of data you want to analyze, which can be:
+
+ {CONST.BULLET} Expense - Individual standalone expenses.
+
+
+ {CONST.BULLET} Expense reports - Groups of expenses processed in a batch.
+
+
+ {CONST.BULLET} Chats - Comments written by you and others.
+
+
+ {CONST.BULLET} Invoices - Expenses submitted to clients for payment.
+
+
+ {CONST.BULLET} Trips - Travel expenses booked with Expensify Travel or scanned with SmartScan.
+
+
+ Search
+ A quick method of narrowing the results by keyword or more.
+
+ State filter
+ Simple methods to filter the results by "state", including:
+
+ {CONST.BULLET} All - Everything in every state.
+
+
+ Expenses/Expense Reports/Invoices
+
+ {CONST.BULLET} Draft - Only you can see that hasn't been shared yet.
+
+
+ {CONST.BULLET} Outstanding - Submitted to someone and awaiting action.
+
+
+ {CONST.BULLET} Approved - Approved, but awaiting payment.
+
+
+ {CONST.BULLET} Done - Fully processed, no further action needed.
+
+
+ {CONST.BULLET} Paid - Fully paid, no further action needed.
+
+
+ Chats
+
+ {CONST.BULLET} Unread - Not seen yet by you.
+
+
+ {CONST.BULLET} Sent - Sent by you.
+
+
+ {CONST.BULLET} Attachments - Image, movie, or document.
+
+
+ {CONST.BULLET} Links - Hyperlinks.
+
+
+ {CONST.BULLET} Pinned - Highlighted by you as important.
+
+
+ Trips
+
+ {CONST.BULLET} Current - Happening or in the future.
+
+
+ {CONST.BULLET} Past - Already happened.
+
+
+ Results
+ The core of the Reports page are the search results themselves.
+ {CONST.BULLET} Select a row to see additional options.
+ {CONST.BULLET} Tap on a row to see more detail.
>
),
},
@@ -47,23 +223,137 @@ const helpContentMap: Record = {
content: (styles: ThemeStyles) => (
<>
Settings
- ... general settings help ...
+ Here is where you configure Expensify exactly to your specifications:
+
+
+ {CONST.BULLET} Profile - Configure how you appear to others.
+
+
+ {CONST.BULLET} Wallet - See and manage your credit cards and bank accounts.
+
+
+ {CONST.BULLET} Preferences - Adjust how the app works for you.
+
+
+ {CONST.BULLET} Security - Lock down how you and others access your account.
+
+
+ {CONST.BULLET} Workspaces - Organize expenses for yourself and share with others.
+
+
+ {CONST.BULLET} Subscriptions - Manage payment details and history.
+
+
+ {CONST.BULLET} Domains - Advanced security and corporate card configuration.
+
+
+ {CONST.BULLET} Switch to Expensify Classic - Battle-tested and reliable.
+
+
+ {CONST.BULLET} Save the World - Let Expensify.org help your favorite teacher!
+
>
),
children: {
workspaces: {
content: (styles: ThemeStyles) => (
<>
- Workspaces
- ... general workspaces help ...
+ Settings > Workspaces
+ Workspaces allow for a wide range of features, including:
+
+
+ {CONST.BULLET} Categorize and submit expenses.
+
+
+ {CONST.BULLET} Approve and reimburse expenses.
+
+
+ {CONST.BULLET} Sync with accounting packages.
+
+
+ {CONST.BULLET} Connect to company card feeds.
+
+
+ {CONST.BULLET} Manage Expensify Cards.
+
+
+ {CONST.BULLET} Chat with colleagues, partners, and clients.
+
+ {CONST.BULLET} … and lots more!
+
+ Workspace Variations
+ Workspaces come in two variations:
+
+
+ {CONST.BULLET} Collect workspaces start at $5/member, and include all the basics for running a small business.
+
+
+ {CONST.BULLET} Control workspaces start at $9/member, and provide advanced capabilities, more powerful accounting sync, and
+ more sophisticated approval flows.
+
+
+ Managing Workspaces
+ In general, you would create one Workspace for each company you manage. You can create and join as many workspaces as you like.
>
),
children: {
':policyID': {
content: (styles: ThemeStyles) => (
<>
- Workspace Settings
- ... general workspace settings help ...
+ Workspaces
+ This is where you configure all the settings of the many features associated with your workspace.
+
+ Default Features
+ Here are the features that are enabled by default:
+
+
+ {CONST.BULLET} Overview - Configure how it appears to others.
+
+
+ {CONST.BULLET} Members - Add/remove members and admins.
+
+
+ {CONST.BULLET} Workflows - Configure submission, approval, and reimbursement.
+
+
+ {CONST.BULLET} Categories - Group expenses into a chart of accounts.
+
+
+ {CONST.BULLET} Expensify Card - Issue native Expensify Cards to employees.
+
+
+ {CONST.BULLET} Accounting - Sync with external accounting systems.
+
+
+ Optional Features
+
+ These can be enabled via More Features:
+
+
+
+ {CONST.BULLET} Distance rates - Configure mileage reimbursement.
+
+
+ {CONST.BULLET} Company card - Connect and manage third-party corporate card feeds.
+
+
+ {CONST.BULLET} Per diem - Configure daily rates.
+
+
+ {CONST.BULLET} Rules - Customize expense violations and set policy.
+
+
+ {CONST.BULLET} Invoices - Collect revenue from customers.
+
+
+ {CONST.BULLET} Tags - Group expenses by project or client.
+
+
+ {CONST.BULLET} Taxes - Track VAT and other taxes.
+
+
+ Report Fields
+ Capture extra expense report information.
>
),
},
@@ -76,35 +366,44 @@ const helpContentMap: Record = {
type DiagnosticDataProps = {
styles: ThemeStyles;
route: string;
+ currentRoute?: string;
isExactMatch?: boolean;
children?: ReactNode;
};
-function DiagnosticData({styles, route, children, isExactMatch}: DiagnosticDataProps) {
+function DiagnosticData({styles, route, currentRoute, children, isExactMatch}: DiagnosticDataProps) {
const diagnosticTitle = isExactMatch ? 'Help content found for route:' : 'Missing help content for route:';
return (
-
- {diagnosticTitle}
- {route}
+ <>
{!!children && (
<>
-
{children}
+
+ >
+ )}
+ {diagnosticTitle}
+ {route}
+ {!isExactMatch && !!currentRoute && (
+ <>
+ Using content from:
+ {currentRoute}
>
)}
-
+ >
);
}
function getHelpContent(styles: ThemeStyles, route: string, isProduction: boolean): ReactNode {
const [firstPart, ...routeParts] = route.substring(1).split('/');
+ const currentRoute = [firstPart];
let currentNode: HelpContent = helpContentMap[firstPart];
let isExactMatch = true;
for (const part of routeParts) {
if (currentNode?.children?.[part]) {
currentNode = currentNode.children[part];
+ currentRoute.push(part);
isExactMatch = true;
} else {
isExactMatch = false;
@@ -114,7 +413,7 @@ function getHelpContent(styles: ThemeStyles, route: string, isProduction: boolea
if (currentNode?.content) {
if (isProduction) {
- return {currentNode.content(styles)};
+ return currentNode.content(styles);
}
return (
@@ -122,6 +421,7 @@ function getHelpContent(styles: ThemeStyles, route: string, isProduction: boolea
styles={styles}
route={route}
isExactMatch={isExactMatch}
+ currentRoute={`/${currentRoute.join('/')}`}
>
{currentNode.content(styles)}
diff --git a/src/components/SidePane/index.tsx b/src/components/SidePane/index.tsx
index 1b7e6b59fe5e..4a2f7adba8d3 100644
--- a/src/components/SidePane/index.tsx
+++ b/src/components/SidePane/index.tsx
@@ -1,69 +1,14 @@
-import {findFocusedRoute} from '@react-navigation/native';
-import React, {useCallback, useEffect, useRef} from 'react';
-// eslint-disable-next-line no-restricted-imports
-import {Animated, View} from 'react-native';
-import HeaderGap from '@components/HeaderGap';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import useEnvironment from '@hooks/useEnvironment';
-import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
-import useLocalize from '@hooks/useLocalize';
-import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import React from 'react';
+import {View} from 'react-native';
import useRootNavigationState from '@hooks/useRootNavigationState';
import useSidePane from '@hooks/useSidePane';
-import useStyledSafeAreaInsets from '@hooks/useStyledSafeAreaInsets';
-import useThemeStyles from '@hooks/useThemeStyles';
-import {triggerSidePane} from '@libs/actions/SidePane';
-import Navigation from '@libs/Navigation/Navigation';
-import {substituteRouteParameters} from '@libs/SidePaneUtils';
-import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
-import getHelpContent from './getHelpContent';
+import Help from './Help';
import SidePaneOverlay from './SidePaneOverlay';
function SidePane() {
- const styles = useThemeStyles();
- const {translate} = useLocalize();
- const {isProduction} = useEnvironment();
- const {route, isInNarrowPaneModal} = useRootNavigationState((state) => {
- const params = (findFocusedRoute(state)?.params as Record) ?? {};
- const activeRoute = Navigation.getActiveRouteWithoutParams();
-
- return {
- route: substituteRouteParameters(activeRoute, params),
- isInNarrowPaneModal: state?.routes.at(-1)?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR,
- };
- });
-
- const {isExtraLargeScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
- const {sidePaneTranslateX, shouldHideSidePane, shouldHideSidePaneBackdrop, sidePane} = useSidePane();
- const {paddingTop} = useStyledSafeAreaInsets();
-
- const onClose = useCallback(
- (shouldUpdateNarrow = false) => {
- if (!sidePane) {
- return;
- }
-
- triggerSidePane(false, {shouldOnlyUpdateNarrowLayout: !isExtraLargeScreenWidth || shouldUpdateNarrow});
- },
- [isExtraLargeScreenWidth, sidePane],
- );
-
- const sizeChangedFromLargeToNarrow = useRef(!isExtraLargeScreenWidth);
- useEffect(() => {
- // Close the side pane when the screen size changes from large to small
- if (!isExtraLargeScreenWidth && !sizeChangedFromLargeToNarrow.current) {
- onClose(true);
- sizeChangedFromLargeToNarrow.current = true;
- }
-
- // Reset the trigger when the screen size changes back to large
- if (isExtraLargeScreenWidth) {
- sizeChangedFromLargeToNarrow.current = false;
- }
- }, [isExtraLargeScreenWidth, onClose]);
-
- useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => onClose(), {shouldBubble: shouldHideSidePane, isActive: !isExtraLargeScreenWidth});
+ const {shouldHideSidePane, sidePaneTranslateX, shouldHideSidePaneBackdrop, closeSidePane} = useSidePane();
+ const isInNarrowPaneModal = useRootNavigationState((state) => state?.routes.at(-1)?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR);
if (shouldHideSidePane) {
return null;
@@ -74,24 +19,15 @@ function SidePane() {
{!shouldHideSidePaneBackdrop && (
)}
-
-
- onClose(false)}
- onCloseButtonPress={() => onClose(false)}
- shouldShowBackButton={!isExtraLargeScreenWidth}
- shouldShowCloseButton={isExtraLargeScreenWidth}
- shouldDisplayHelpButton={false}
- />
- {getHelpContent(styles, route, isProduction)}
-
+
>
);
}
diff --git a/src/components/SubStepForms/AddressStep.tsx b/src/components/SubStepForms/AddressStep.tsx
index 5dd5a164f25c..79ca63f9f829 100644
--- a/src/components/SubStepForms/AddressStep.tsx
+++ b/src/components/SubStepForms/AddressStep.tsx
@@ -9,6 +9,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {getFieldRequiredErrors, isValidAddress, isValidZipCode, isValidZipCodeInternational} from '@libs/ValidationUtils';
import AddressFormFields from '@pages/ReimbursementAccount/AddressFormFields';
import HelpLinks from '@pages/ReimbursementAccount/USD/Requestor/PersonalInfo/HelpLinks';
+import type {TranslationPaths} from '@src/languages/types';
import type {OnyxFormValuesMapping} from '@src/ONYXKEYS';
type AddressValues = {
@@ -63,6 +64,9 @@ type AddressStepProps = SubStepProp
/** Callback to be called when the country is changed */
onCountryChange?: (country: unknown) => void;
+
+ /** Translation key of street field */
+ streetTranslationKey?: TranslationPaths;
};
function AddressStep({
@@ -82,6 +86,7 @@ function AddressStep({
stateSelectorModalHeaderTitle,
stateSelectorSearchInputTitle,
onCountryChange,
+ streetTranslationKey = 'common.streetAddress',
}: AddressStepProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
@@ -128,7 +133,7 @@ function AddressStep({
{!!formPOBoxDisclaimer && {formPOBoxDisclaimer}}
= SubStep
/** Should the submit button be enabled when offline */
enabledWhenOffline?: boolean;
+
+ /** Set the default value to the input if there is a valid saved value */
+ shouldUseDefaultValue?: boolean;
+
+ /** Should the input be disabled */
+ disabled?: boolean;
+
+ /** Placeholder displayed inside input */
+ placeholder?: string;
};
function SingleFieldStep({
@@ -65,6 +74,9 @@ function SingleFieldStep({
shouldShowHelpLinks = true,
maxLength,
enabledWhenOffline,
+ shouldUseDefaultValue = true,
+ disabled = false,
+ placeholder,
}: SingleFieldStepProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
@@ -94,6 +106,9 @@ function SingleFieldStep({
defaultValue={defaultValue}
maxLength={maxLength}
shouldSaveDraft={!isEditing}
+ shouldUseDefaultValue={shouldUseDefaultValue}
+ disabled={disabled}
+ placeholder={placeholder}
autoFocus
/>
diff --git a/src/components/TestToolsModal.tsx b/src/components/TestToolsModal.tsx
index 7c2e161b2d11..dbfadbe72db9 100644
--- a/src/components/TestToolsModal.tsx
+++ b/src/components/TestToolsModal.tsx
@@ -1,10 +1,11 @@
-import React from 'react';
+import React, {useMemo} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
+import {getBrowser, isChromeIOS} from '@libs/Browser';
import Navigation from '@navigation/Navigation';
import toggleTestToolsModal from '@userActions/TestTool';
import CONST from '@src/CONST';
@@ -26,6 +27,17 @@ function TestToolsModal() {
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const shouldShowProfileTool = useMemo(() => {
+ const browser = getBrowser();
+ const isSafariOrFirefox = browser === CONST.BROWSER.SAFARI || browser === CONST.BROWSER.FIREFOX;
+
+ if (isSafariOrFirefox || isChromeIOS()) {
+ return false;
+ }
+
+ return true;
+ }, []);
+
return (
{translate('initialSettingsPage.troubleshoot.releaseOptions')}
-
+ {shouldShowProfileTool && }
{!!shouldStoreLogs && (
diff --git a/src/components/TransactionItemRow/DataCells/CategoryCell.tsx b/src/components/TransactionItemRow/DataCells/CategoryCell.tsx
new file mode 100644
index 000000000000..b561e3e73cf3
--- /dev/null
+++ b/src/components/TransactionItemRow/DataCells/CategoryCell.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import * as Expensicons from '@components/Icon/Expensicons';
+import TextWithIconCell from '@components/SelectionList/Search/TextWithIconCell';
+import TextWithTooltip from '@components/TextWithTooltip';
+import useThemeStyles from '@hooks/useThemeStyles';
+import type TransactionDataCellProps from './TransactionDataCellProps';
+
+function CategoryCell({shouldUseNarrowLayout, shouldShowTooltip, transactionItem}: TransactionDataCellProps) {
+ const styles = useThemeStyles();
+ return shouldUseNarrowLayout ? (
+
+ ) : (
+
+ );
+}
+
+CategoryCell.displayName = 'CategoryCell';
+export default CategoryCell;
diff --git a/src/components/TransactionItemRow/DataCells/DateCell.tsx b/src/components/TransactionItemRow/DataCells/DateCell.tsx
new file mode 100644
index 000000000000..25ae738fb29b
--- /dev/null
+++ b/src/components/TransactionItemRow/DataCells/DateCell.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import TextWithTooltip from '@components/TextWithTooltip';
+import useThemeStyles from '@hooks/useThemeStyles';
+import DateUtils from '@libs/DateUtils';
+import {getCreated as getTransactionCreated} from '@libs/TransactionUtils';
+import CONST from '@src/CONST';
+import type TransactionDataCellProps from './TransactionDataCellProps';
+
+function DateCell({transactionItem, shouldShowTooltip, shouldUseNarrowLayout}: TransactionDataCellProps) {
+ const styles = useThemeStyles();
+
+ const created = getTransactionCreated(transactionItem);
+ const date = DateUtils.formatWithUTCTimeZone(created, DateUtils.doesDateBelongToAPastYear(created) ? CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT : CONST.DATE.MONTH_DAY_ABBR_FORMAT);
+
+ return (
+
+ );
+}
+
+DateCell.displayName = 'DateCell';
+export default DateCell;
diff --git a/src/components/TransactionItemRow/DataCells/MerchantCell.tsx b/src/components/TransactionItemRow/DataCells/MerchantCell.tsx
new file mode 100644
index 000000000000..ec75a5828cf0
--- /dev/null
+++ b/src/components/TransactionItemRow/DataCells/MerchantCell.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import TextWithTooltip from '@components/TextWithTooltip';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+import type TransactionDataCellProps from './TransactionDataCellProps';
+
+function MerchantCell({transactionItem, shouldShowTooltip}: TransactionDataCellProps) {
+ const styles = useThemeStyles();
+
+ const merchantToDisplay = !transactionItem?.merchant || transactionItem?.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT ? '' : transactionItem.merchant;
+ return (
+
+ );
+}
+
+MerchantCell.displayName = 'MerchantCell';
+export default MerchantCell;
diff --git a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
new file mode 100644
index 000000000000..7e6899b70940
--- /dev/null
+++ b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
@@ -0,0 +1,56 @@
+import {Str} from 'expensify-common';
+import React from 'react';
+import {View} from 'react-native';
+import {Receipt} from '@components/Icon/Expensicons';
+import ReceiptImage from '@components/ReceiptImage';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {getFileName} from '@libs/fileDownload/FileUtils';
+import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils';
+import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot';
+import variables from '@styles/variables';
+import type {Transaction} from '@src/types/onyx';
+
+function ReceiptCell({transactionItem, isSelected}: {transactionItem: Transaction; isSelected: boolean}) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const backgroundStyles = isSelected ? StyleUtils.getBackgroundColorStyle(theme.buttonHoveredBG) : StyleUtils.getBackgroundColorStyle(theme.border);
+
+ let source = transactionItem?.receipt?.source ?? '';
+
+ if (source) {
+ const filename = getFileName(source);
+ const receiptURIs = getThumbnailAndImageURIs(transactionItem, null, filename);
+ const isReceiptPDF = Str.isPDF(filename);
+ source = tryResolveUrlFromApiRoot(isReceiptPDF && !receiptURIs.isLocalFile ? receiptURIs.thumbnail ?? '' : receiptURIs.image ?? '');
+ }
+
+ return (
+
+
+
+ );
+}
+
+ReceiptCell.displayName = 'ReceiptCell';
+export default ReceiptCell;
diff --git a/src/components/TransactionItemRow/DataCells/TagCell.tsx b/src/components/TransactionItemRow/DataCells/TagCell.tsx
new file mode 100644
index 000000000000..b79e527ea217
--- /dev/null
+++ b/src/components/TransactionItemRow/DataCells/TagCell.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import * as Expensicons from '@components/Icon/Expensicons';
+import TextWithIconCell from '@components/SelectionList/Search/TextWithIconCell';
+import TextWithTooltip from '@components/TextWithTooltip';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {getTagForDisplay} from '@libs/TransactionUtils';
+import type TransactionDataCellProps from './TransactionDataCellProps';
+
+function TagCell({shouldUseNarrowLayout, shouldShowTooltip, transactionItem}: TransactionDataCellProps) {
+ const styles = useThemeStyles();
+ return shouldUseNarrowLayout ? (
+
+ ) : (
+
+ );
+}
+
+TagCell.displayName = 'TagCell';
+export default TagCell;
diff --git a/src/components/TransactionItemRow/DataCells/TotalCell.tsx b/src/components/TransactionItemRow/DataCells/TotalCell.tsx
new file mode 100644
index 000000000000..b9ecc4036476
--- /dev/null
+++ b/src/components/TransactionItemRow/DataCells/TotalCell.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import TextWithTooltip from '@components/TextWithTooltip';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {convertToDisplayString} from '@libs/CurrencyUtils';
+import {getCurrency as getTransactionCurrency, hasReceipt, isReceiptBeingScanned} from '@libs/TransactionUtils';
+import type TransactionDataCellProps from './TransactionDataCellProps';
+
+function TotalCell({shouldShowTooltip, transactionItem}: TransactionDataCellProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const currency = getTransactionCurrency(transactionItem);
+
+ let amount = convertToDisplayString(Math.abs(transactionItem.amount), currency);
+
+ if (hasReceipt(transactionItem) && isReceiptBeingScanned(transactionItem)) {
+ amount = translate('iou.receiptStatusTitle');
+ }
+
+ return (
+
+ );
+}
+
+TotalCell.displayName = 'TotalCell';
+export default TotalCell;
diff --git a/src/components/TransactionItemRow/DataCells/TransactionDataCellProps.ts b/src/components/TransactionItemRow/DataCells/TransactionDataCellProps.ts
new file mode 100644
index 000000000000..df905ea34fc2
--- /dev/null
+++ b/src/components/TransactionItemRow/DataCells/TransactionDataCellProps.ts
@@ -0,0 +1,9 @@
+import type Transaction from '@src/types/onyx/Transaction';
+
+type TransactionDataCellProps = {
+ transactionItem: Transaction;
+ shouldShowTooltip: boolean;
+ shouldUseNarrowLayout?: boolean;
+};
+
+export default TransactionDataCellProps;
diff --git a/src/components/TransactionItemRow/DataCells/TypeCell.tsx b/src/components/TransactionItemRow/DataCells/TypeCell.tsx
new file mode 100644
index 000000000000..6da5f339e0ee
--- /dev/null
+++ b/src/components/TransactionItemRow/DataCells/TypeCell.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import Icon from '@components/Icon';
+import * as Expensicons from '@components/Icon/Expensicons';
+import TextWithTooltip from '@components/TextWithTooltip';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+import type TransactionDataCellProps from './TransactionDataCellProps';
+
+// If the transaction is cash, it has the type CONST.EXPENSE.TYPE.CASH_CARD_NAME.
+// If there is no credit card name, it means it couldn't be a card transaction,
+// so we assume it's cash. Any other type is treated as a card transaction.
+// same in getTypeText
+
+const getTypeIcon = (type?: string) => {
+ switch (type) {
+ case CONST.EXPENSE.TYPE.CASH_CARD_NAME:
+ return Expensicons.Cash;
+ case undefined:
+ return Expensicons.Cash;
+ default:
+ return Expensicons.CreditCard;
+ }
+};
+
+const getTypeText = (type?: string) => {
+ switch (type) {
+ case CONST.EXPENSE.TYPE.CASH_CARD_NAME:
+ return 'Cash';
+ case undefined:
+ return 'Cash';
+ default:
+ return 'CreditCard';
+ }
+};
+
+function TypeCell({transactionItem, shouldUseNarrowLayout, shouldShowTooltip}: TransactionDataCellProps) {
+ const theme = useTheme();
+ const typeIcon = getTypeIcon(transactionItem.cardName);
+ const typeText = getTypeText(transactionItem.cardName);
+ const styles = useThemeStyles();
+
+ return shouldUseNarrowLayout ? (
+
+ ) : (
+
+ );
+}
+
+TypeCell.displayName = 'TypeCell';
+export default TypeCell;
diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx
new file mode 100644
index 000000000000..14428f934dde
--- /dev/null
+++ b/src/components/TransactionItemRow/index.tsx
@@ -0,0 +1,153 @@
+import React from 'react';
+import {View} from 'react-native';
+import Hoverable from '@components/Hoverable';
+import Text from '@components/Text';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+import type Transaction from '@src/types/onyx/Transaction';
+import CategoryCell from './DataCells/CategoryCell';
+import DateCell from './DataCells/DateCell';
+import MerchantCell from './DataCells/MerchantCell';
+import ReceiptCell from './DataCells/ReceiptCell';
+import TagCell from './DataCells/TagCell';
+import TotalCell from './DataCells/TotalCell';
+import TypeCell from './DataCells/TypeCell';
+
+function TransactionItemRow({
+ transactionItem,
+ shouldUseNarrowLayout,
+ isSelected,
+ shouldShowTooltip,
+}: {
+ transactionItem: Transaction;
+ shouldUseNarrowLayout: boolean;
+ isSelected: boolean;
+ shouldShowTooltip: boolean;
+}) {
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+
+ const backgroundColor = isSelected ? styles.buttonDefaultBG : styles.highlightBG;
+
+ return (
+
+ {shouldUseNarrowLayout ? (
+
+ {(hovered) => (
+
+
+
+
+
+
+
+
+ •
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ ) : (
+
+ {(hovered) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ )}
+
+ );
+}
+
+TransactionItemRow.displayName = 'TransactionItemRow';
+
+export default TransactionItemRow;
diff --git a/src/hooks/useBottomSafeSafeAreaPaddingStyle.ts b/src/hooks/useBottomSafeSafeAreaPaddingStyle.ts
new file mode 100644
index 000000000000..de56f88d8ec0
--- /dev/null
+++ b/src/hooks/useBottomSafeSafeAreaPaddingStyle.ts
@@ -0,0 +1,63 @@
+import {useMemo} from 'react';
+import {StyleSheet} from 'react-native';
+import type {StyleProp, ViewStyle} from 'react-native';
+import useSafeAreaPaddings from './useSafeAreaPaddings';
+
+/** The parameters for the useBottomSafeSafeAreaPaddingStyle hook. */
+type UseBottomSafeAreaPaddingStyleParams = {
+ /** Whether to add bottom safe area padding to the content. */
+ addBottomSafeAreaPadding?: boolean;
+
+ /** The style to adapt and add bottom safe area padding to. */
+ style?: StyleProp;
+
+ /** The additional padding to add to the bottom of the content. */
+ additionalPaddingBottom?: number;
+};
+
+/**
+ * useBottomSafeSafeAreaPaddingStyle is a hook that creates or adapts a given style and adds bottom safe area padding.
+ * It is useful for creating new styles or updating existing style props (e.g. contentContainerStyle).
+ * @param params - The parameters for the hook.
+ * @returns The style with bottom safe area padding applied.
+ */
+function useBottomSafeSafeAreaPaddingStyle(params?: UseBottomSafeAreaPaddingStyleParams) {
+ const {paddingBottom: safeAreaPaddingBottom} = useSafeAreaPaddings(true);
+
+ const {addBottomSafeAreaPadding, style, additionalPaddingBottom} = params ?? {};
+
+ return useMemo>(() => {
+ let totalPaddingBottom: number | string = additionalPaddingBottom ?? 0;
+
+ // Add the safe area padding to the total padding if the flag is enabled
+ if (addBottomSafeAreaPadding) {
+ totalPaddingBottom += safeAreaPaddingBottom;
+ }
+
+ // If there is no bottom safe area or additional padding, return the style as is
+ if (totalPaddingBottom === 0) {
+ return style;
+ }
+
+ // If a style is provided, flatten the style and add the padding to it
+ if (style) {
+ const contentContainerStyleFlattened = StyleSheet.flatten(style);
+ const stylePaddingBottom = contentContainerStyleFlattened?.paddingBottom;
+
+ if (typeof stylePaddingBottom === 'number') {
+ totalPaddingBottom += stylePaddingBottom;
+ } else if (typeof stylePaddingBottom === 'string') {
+ totalPaddingBottom = `calc(${totalPaddingBottom}px + ${stylePaddingBottom})`;
+ } else if (stylePaddingBottom !== undefined) {
+ return style;
+ }
+
+ return [style, {paddingBottom: totalPaddingBottom}];
+ }
+
+ // If no style is provided, return the padding as an object
+ return {paddingBottom: totalPaddingBottom};
+ }, [addBottomSafeAreaPadding, style, additionalPaddingBottom, safeAreaPaddingBottom]);
+}
+
+export default useBottomSafeSafeAreaPaddingStyle;
diff --git a/src/hooks/useSafeAreaInsets.ts b/src/hooks/useSafeAreaInsets.ts
index f954a6c99e94..33a76e3c0053 100644
--- a/src/hooks/useSafeAreaInsets.ts
+++ b/src/hooks/useSafeAreaInsets.ts
@@ -5,8 +5,8 @@ import useStyleUtils from './useStyleUtils';
/**
* Note: if you're looking for a hook to implement safe area padding in your screen, please either:
- * - use a component and set `includeSafeAreaPaddingBottom` to `true`. Or
- * - use the `useStyledSafeAreaInsets` hook.
+ * - add the `addBottomSafeAreaPadding` prop to generic components like ScrollView, SelectionList or FormProvider.
+ * - use the `useSafeAreaPaddings` hook.
*
* This hook is only meant for internal use cases where you need to access the raw safe area insets.
*/
diff --git a/src/hooks/useStyledSafeAreaInsets.ts b/src/hooks/useSafeAreaPaddings.ts
similarity index 71%
rename from src/hooks/useStyledSafeAreaInsets.ts
rename to src/hooks/useSafeAreaPaddings.ts
index b5a7bd2413c4..9087cd52a338 100644
--- a/src/hooks/useStyledSafeAreaInsets.ts
+++ b/src/hooks/useSafeAreaPaddings.ts
@@ -4,8 +4,7 @@ import useSafeAreaInsets from './useSafeAreaInsets';
import useStyleUtils from './useStyleUtils';
/**
- * Custom hook to get the styled safe area insets. The top and bottom padding values are adjusted
- * so that they will only ever be applied once per .
+ * Custom hook to get safe area padding values and styles.
*
* This hook utilizes the `SafeAreaInsetsContext` to retrieve the current safe area insets
* and applies styling adjustments using the `useStyleUtils` hook.
@@ -26,28 +25,41 @@ import useStyleUtils from './useStyleUtils';
* }
*
* function MyComponent() {
- * const { paddingTop, paddingBottom, safeAreaPaddingBottomStyle } = useStyledSafeAreaInsets();
+ * const { paddingTop, paddingBottom, safeAreaPaddingBottomStyle } = useSafeAreaPaddings();
*
* // Use these values to style your component accordingly
* }
*/
-function useStyledSafeAreaInsets() {
+function useSafeAreaPaddings(enableEdgeToEdgeBottomSafeAreaPadding = false) {
const StyleUtils = useStyleUtils();
const insets = useSafeAreaInsets();
- const {paddingTop, paddingBottom} = StyleUtils.getSafeAreaPadding(insets);
+ const {paddingTop, paddingBottom} = useMemo(() => StyleUtils.getPlatformSafeAreaPadding(insets), [StyleUtils, insets]);
const screenWrapperStatusContext = useContext(ScreenWrapperStatusContext);
const isSafeAreaTopPaddingApplied = screenWrapperStatusContext?.isSafeAreaTopPaddingApplied ?? false;
const isSafeAreaBottomPaddingApplied = screenWrapperStatusContext?.isSafeAreaBottomPaddingApplied ?? false;
+ const adaptedPaddingBottom = isSafeAreaBottomPaddingApplied ? 0 : paddingBottom;
+ const safeAreaPaddingBottomStyle = useMemo(
+ () => ({paddingBottom: enableEdgeToEdgeBottomSafeAreaPadding ? paddingBottom : adaptedPaddingBottom}),
+ [adaptedPaddingBottom, enableEdgeToEdgeBottomSafeAreaPadding, paddingBottom],
+ );
+
+ if (enableEdgeToEdgeBottomSafeAreaPadding) {
+ return {
+ paddingTop,
+ paddingBottom,
+ unmodifiedPaddings: {},
+ insets,
+ safeAreaPaddingBottomStyle,
+ };
+ }
+
const adaptedInsets = {
...insets,
top: isSafeAreaTopPaddingApplied ? 0 : insets?.top,
bottom: isSafeAreaBottomPaddingApplied ? 0 : insets?.bottom,
};
- const adaptedPaddingBottom = isSafeAreaBottomPaddingApplied ? 0 : paddingBottom;
-
- const safeAreaPaddingBottomStyle = useMemo(() => ({paddingBottom: adaptedPaddingBottom}), [adaptedPaddingBottom]);
return {
paddingTop: isSafeAreaTopPaddingApplied ? 0 : paddingTop,
@@ -61,4 +73,4 @@ function useStyledSafeAreaInsets() {
};
}
-export default useStyledSafeAreaInsets;
+export default useSafeAreaPaddings;
diff --git a/src/hooks/useSidePane.ts b/src/hooks/useSidePane.ts
index 73e1f24a16ca..a879696359fc 100644
--- a/src/hooks/useSidePane.ts
+++ b/src/hooks/useSidePane.ts
@@ -1,14 +1,14 @@
-import {useEffect, useRef, useState} from 'react';
+import {useCallback, useEffect, useRef, useState} from 'react';
// Import Animated directly from 'react-native' as animations are used with navigation.
// eslint-disable-next-line no-restricted-imports
import {Animated} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
+import {triggerSidePane} from '@libs/actions/SidePane';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type * as OnyxTypes from '@src/types/onyx';
-import useEnvironment from './useEnvironment';
import useResponsiveLayout from './useResponsiveLayout';
import useWindowDimensions from './useWindowDimensions';
@@ -26,7 +26,6 @@ function isSidePaneHidden(sidePane: OnyxEntry, isExtraLargeS
function useSidePane() {
const {isExtraLargeScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
const {windowWidth} = useWindowDimensions();
- const {isProduction} = useEnvironment();
const [sidePaneNVP] = useOnyx(ONYXKEYS.NVP_SIDE_PANE);
const [language] = useOnyx(ONYXKEYS.NVP_PREFERRED_LOCALE);
@@ -37,13 +36,16 @@ function useSidePane() {
const shouldApplySidePaneOffset = isExtraLargeScreenWidth && !isPaneHidden;
const [shouldHideSidePane, setShouldHideSidePane] = useState(true);
- const shouldHideSidePaneBackdrop = isPaneHidden || isExtraLargeScreenWidth || shouldUseNarrowLayout;
+ const [isAnimatingExtraLargeScree, setIsAnimatingExtraLargeScreen] = useState(false);
- // The help button is hidden in production if the side pane nvp is not present or the language is unsupported.
- const shouldHideOnProduction = isProduction && (!sidePaneNVP || isLanguageUnsupported);
+ const shouldHideSidePaneBackdrop = isPaneHidden || isExtraLargeScreenWidth || shouldUseNarrowLayout;
+ const shouldHideToolTip = isExtraLargeScreenWidth ? isAnimatingExtraLargeScree : !shouldHideSidePane;
- // The help button is also hidden if the side pane is displayed currently.
- const shouldHideHelpButton = shouldHideOnProduction || !isPaneHidden;
+ // The help button is hidden when:
+ // - side pane nvp is not set
+ // - side pane is displayed currently
+ // - language is unsupported
+ const shouldHideHelpButton = !sidePaneNVP || !isPaneHidden || isLanguageUnsupported;
const sidePaneOffset = useRef(new Animated.Value(shouldApplySidePaneOffset ? variables.sideBarWidth : 0));
const sidePaneTranslateX = useRef(new Animated.Value(isPaneHidden ? sidePaneWidth : 0));
@@ -52,6 +54,9 @@ function useSidePane() {
if (!isPaneHidden) {
setShouldHideSidePane(false);
}
+ if (isExtraLargeScreenWidth) {
+ setIsAnimatingExtraLargeScreen(true);
+ }
Animated.parallel([
Animated.timing(sidePaneOffset.current, {
@@ -66,8 +71,24 @@ function useSidePane() {
}),
]).start(() => {
setShouldHideSidePane(isPaneHidden);
+ setIsAnimatingExtraLargeScreen(false);
});
- }, [isPaneHidden, shouldApplySidePaneOffset, shouldUseNarrowLayout, sidePaneWidth]);
+ }, [isPaneHidden, shouldApplySidePaneOffset, shouldUseNarrowLayout, sidePaneWidth, isExtraLargeScreenWidth]);
+
+ const closeSidePane = useCallback(
+ (shouldUpdateNarrow = false) => {
+ if (!sidePaneNVP) {
+ return;
+ }
+
+ const shouldOnlyUpdateNarrowLayout = !isExtraLargeScreenWidth || shouldUpdateNarrow;
+ triggerSidePane({
+ isOpen: shouldOnlyUpdateNarrowLayout ? undefined : false,
+ isOpenNarrowScreen: shouldOnlyUpdateNarrowLayout ? false : undefined,
+ });
+ },
+ [isExtraLargeScreenWidth, sidePaneNVP],
+ );
return {
sidePane: sidePaneNVP,
@@ -76,6 +97,8 @@ function useSidePane() {
shouldHideHelpButton,
sidePaneOffset,
sidePaneTranslateX,
+ shouldHideToolTip,
+ closeSidePane,
};
}
diff --git a/src/hooks/useWorkspaceList.ts b/src/hooks/useWorkspaceList.ts
new file mode 100644
index 000000000000..ed7dbb2e6c82
--- /dev/null
+++ b/src/hooks/useWorkspaceList.ts
@@ -0,0 +1,113 @@
+import {useMemo} from 'react';
+import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
+import * as Expensicons from '@components/Icon/Expensicons';
+import type {ListItem, SectionListDataType} from '@components/SelectionList/types';
+import {isPolicyAdmin, shouldShowPolicy, sortWorkspacesBySelected} from '@libs/PolicyUtils';
+import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils';
+import type {BrickRoad} from '@libs/WorkspacesSettingsUtils';
+import CONST from '@src/CONST';
+import type {Policy} from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+
+type WorkspaceListItem = {
+ text: string;
+ policyID?: string;
+ isPolicyAdmin?: boolean;
+ brickRoadIndicator?: BrickRoad;
+} & ListItem;
+
+type UseWorkspaceListParams = {
+ policies: OnyxCollection;
+ currentUserLogin: string | undefined;
+ isOffline: boolean;
+ selectedPolicyID: string | undefined;
+ searchTerm: string;
+ additionalFilter?: (policy: OnyxEntry) => boolean;
+} & (
+ | {
+ isWorkspaceSwitcher: true;
+ hasUnreadData: (policyID?: string) => boolean;
+ getIndicatorTypeForPolicy: (policyID?: string) => BrickRoad;
+ }
+ | {
+ isWorkspaceSwitcher?: false | undefined;
+ hasUnreadData?: never;
+ getIndicatorTypeForPolicy?: never;
+ }
+);
+
+function useWorkspaceList({
+ policies,
+ currentUserLogin,
+ selectedPolicyID,
+ searchTerm,
+ isOffline,
+ isWorkspaceSwitcher = false,
+ hasUnreadData,
+ getIndicatorTypeForPolicy,
+ additionalFilter,
+}: UseWorkspaceListParams) {
+ const usersWorkspaces = useMemo(() => {
+ if (!policies || isEmptyObject(policies)) {
+ return [];
+ }
+
+ return Object.values(policies)
+ .filter((policy) => !!policy && shouldShowPolicy(policy, !!isOffline, currentUserLogin) && !policy?.isJoinRequestPending && (additionalFilter ? additionalFilter(policy) : true))
+ .map((policy) => ({
+ text: policy?.name ?? '',
+ policyID: policy?.id,
+ icons: [
+ {
+ source: policy?.avatarURL ? policy.avatarURL : getDefaultWorkspaceAvatar(policy?.name),
+ fallbackIcon: Expensicons.FallbackWorkspaceAvatar,
+ name: policy?.name,
+ type: CONST.ICON_TYPE_WORKSPACE,
+ id: policy?.id,
+ },
+ ],
+ keyForList: policy?.id,
+ isPolicyAdmin: isPolicyAdmin(policy),
+ isSelected: selectedPolicyID === policy?.id,
+ ...(isWorkspaceSwitcher &&
+ hasUnreadData &&
+ getIndicatorTypeForPolicy && {
+ isBold: hasUnreadData(policy?.id),
+ brickRoadIndicator: getIndicatorTypeForPolicy(policy?.id),
+ }),
+ }));
+ }, [policies, isOffline, currentUserLogin, additionalFilter, selectedPolicyID, getIndicatorTypeForPolicy, hasUnreadData, isWorkspaceSwitcher]);
+
+ const filteredAndSortedUserWorkspaces = useMemo(
+ () =>
+ usersWorkspaces
+ .filter((policy) => policy.text?.toLowerCase().includes(searchTerm?.toLowerCase() ?? ''))
+ .sort((policy1, policy2) => sortWorkspacesBySelected({policyID: policy1.policyID, name: policy1.text}, {policyID: policy2.policyID, name: policy2.text}, selectedPolicyID)),
+ [searchTerm, usersWorkspaces, selectedPolicyID],
+ );
+
+ const sections = useMemo(() => {
+ const options: Array> = [
+ {
+ data: filteredAndSortedUserWorkspaces,
+ shouldShow: true,
+ indexOffset: 1,
+ },
+ ];
+ return options;
+ }, [filteredAndSortedUserWorkspaces]);
+
+ const shouldShowNoResultsFoundMessage = filteredAndSortedUserWorkspaces.length === 0 && usersWorkspaces.length;
+ const shouldShowSearchInput = usersWorkspaces.length >= CONST.STANDARD_LIST_ITEM_LIMIT;
+ const shouldShowCreateWorkspace = isWorkspaceSwitcher && usersWorkspaces.length === 0;
+
+ return {
+ sections,
+ shouldShowNoResultsFoundMessage,
+ shouldShowSearchInput,
+ shouldShowCreateWorkspace,
+ };
+}
+
+export default useWorkspaceList;
+export type {WorkspaceListItem};
diff --git a/src/languages/en.ts b/src/languages/en.ts
index dc55db4db924..5b9876e3cdf0 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -37,7 +37,7 @@ import type {
ChangeOwnerDuplicateSubscriptionParams,
ChangeOwnerHasFailedSettlementsParams,
ChangeOwnerSubscriptionParams,
- ChangePolicyParams,
+ ChangeReportPolicyParams,
ChangeTypeParams,
CharacterLengthLimitParams,
CharacterLimitParams,
@@ -1099,6 +1099,13 @@ const translations = {
whatIsHoldExplain: 'Hold is like hitting “pause” on an expense to ask for more details before approval or payment.',
holdIsLeftBehind: 'Held expenses are left behind even if you approve an entire report.',
unholdWhenReady: "Unhold expenses when you're ready to approve or pay.",
+ changePolicyEducational: {
+ title: 'You moved this report!',
+ description: 'Double-check these items, which tend to change when moving reports to a new workspace.',
+ reCategorize: 'Re-categorize any expenses to comply with workspace rules.',
+ workflows: 'This report may now be subject to a different approval workflow.',
+ },
+ changeWorkspace: 'Change workspace',
set: 'set',
changed: 'changed',
removed: 'removed',
@@ -1943,6 +1950,34 @@ const translations = {
`We've been unable to deliver SMS messages to ${login}, so we've suspended it for 24 hours. Please try validating your number:`,
validationFailed: 'Validation failed because it hasn’t been 24 hours since your last attempt.',
validationSuccess: 'Your number has been validated! Click below to send a new magic sign-in code.',
+ pleaseWaitBeforeTryingAgain: ({timeData}: {timeData?: {days?: number; hours?: number; minutes?: number}}) => {
+ if (!timeData) {
+ return 'Please wait a moment before trying again.';
+ }
+
+ const parts = [];
+ if (timeData.days) {
+ parts.push(`${timeData.days} ${timeData.days === 1 ? 'day' : 'days'}`);
+ }
+ if (timeData.hours) {
+ parts.push(`${timeData.hours} ${timeData.hours === 1 ? 'hour' : 'hours'}`);
+ }
+ if (timeData.minutes) {
+ parts.push(`${timeData.minutes} ${timeData.minutes === 1 ? 'minute' : 'minutes'}`);
+ }
+
+ let timeText;
+ if (parts.length === 1) {
+ timeText = parts.at(0);
+ } else if (parts.length === 2) {
+ timeText = parts.join(' and ');
+ } else {
+ const lastPart = parts.pop();
+ timeText = `${parts.join(', ')} and ${lastPart}`;
+ }
+
+ return `Please wait ${timeText} before trying again.`;
+ },
},
welcomeSignUpForm: {
join: 'Join',
@@ -2163,7 +2198,7 @@ const translations = {
noOverdraftOrCredit: 'No overdraft/credit feature.',
electronicFundsWithdrawal: 'Electronic funds withdrawal',
standard: 'Standard',
- reviewTheFees: 'Please review the fees below.',
+ reviewTheFees: 'Take a look at some fees.',
checkTheBoxes: 'Please check the boxes below.',
agreeToTerms: 'Agree to the terms and you’ll be good to go!',
shortTermsForm: {
@@ -2178,7 +2213,7 @@ const translations = {
customerService: 'Customer service',
automatedOrLive: '(automated or live agent)',
afterTwelveMonths: '(after 12 months with no transactions)',
- weChargeOneFee: 'We charge one type of fee.',
+ weChargeOneFee: 'We charge 1 other type of fee. It is:',
fdicInsurance: 'Your funds are eligible for FDIC insurance.',
generalInfo: 'For general information about prepaid accounts, visit',
conditionsDetails: 'For details and conditions for all fees and services, visit',
@@ -2188,9 +2223,9 @@ const translations = {
},
longTermsForm: {
listOfAllFees: 'A list of all Expensify Wallet fees',
- typeOfFeeHeader: 'Type of fee',
- feeAmountHeader: 'Fee amount',
- moreDetailsHeader: 'More details',
+ typeOfFeeHeader: 'All fees',
+ feeAmountHeader: 'Amount',
+ moreDetailsHeader: 'Details',
openingAccountTitle: 'Opening an account',
openingAccountDetails: "There's no fee to open an account.",
monthlyFeeDetails: "There's no monthly fee.",
@@ -2210,7 +2245,8 @@ const translations = {
fdicInsuranceBancorp: ({amount}: TermsParams) =>
'Your funds are eligible for FDIC insurance. Your funds will be held at or ' +
`transferred to ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK}, an FDIC-insured institution. Once there, your funds are insured up ` +
- `to ${amount} by the FDIC in the event ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK} fails. See`,
+ `to ${amount} by the FDIC in the event ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK} fails, if specific deposit insurance requirements ` +
+ `are met and your card is registered. See`,
fdicInsuranceBancorp2: 'for details.',
contactExpensifyPayments: `Contact ${CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS} by calling +1 833-400-0904, by email at`,
contactExpensifyPayments2: 'or sign in at',
@@ -2896,6 +2932,7 @@ const translations = {
locations: 'Locations',
customers: 'Customers/projects',
accountsDescription: 'Your QuickBooks Online chart of accounts will import into Expensify as categories.',
+ autoSyncDescription: 'Sync QuickBooks Online and Expensify automatically, every day. Export finalized report in realtime',
accountsSwitchTitle: 'Choose to import new accounts as enabled or disabled categories.',
accountsSwitchDescription: 'Enabled categories will be available for members to select when creating their expenses.',
classesDescription: 'Choose how to handle QuickBooks Online classes in Expensify.',
@@ -3230,18 +3267,6 @@ const translations = {
[CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_APPROVED_BOTH]: 'Supervisor and accounting approved',
},
},
- accountingMethods: {
- label: 'When to Export',
- description: 'Choose when to export the expenses:',
- values: {
- [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Accrual',
- [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Cash',
- },
- alternateText: {
- [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Out-of-pocket expenses will export when final approved',
- [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Out-of-pocket expenses will export when paid',
- },
- },
exportVendorBillsTo: {
label: 'Vendor bill approval level',
description: 'Once a vendor bill is approved in Expensify and exported to NetSuite, you can set an additional level of approval in NetSuite prior to posting.',
@@ -3535,6 +3560,18 @@ const translations = {
}
},
},
+ accountingMethods: {
+ label: 'When to Export',
+ description: 'Choose when to export the expenses:',
+ values: {
+ [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Accrual',
+ [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Cash',
+ },
+ alternateText: {
+ [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Out-of-pocket expenses will export when final approved',
+ [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Out-of-pocket expenses will export when paid',
+ },
+ },
multiConnectionSelector: {
title: ({connectionName}: ConnectionNameParams) => `${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]} setup`,
description: ({connectionName}: ConnectionNameParams) => `Select your ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]} version to continue.`,
@@ -5145,6 +5182,10 @@ const translations = {
},
},
report: {
+ newReport: {
+ createReport: 'Create report',
+ chooseWorkspace: 'Choose a workspace for this report.',
+ },
genericCreateReportFailureMessage: 'Unexpected error creating this chat. Please try again later.',
genericAddCommentFailureMessage: 'Unexpected error posting the comment. Please try again later.',
genericUpdateReportFieldFailureMessage: 'Unexpected error updating the field. Please try again later.',
@@ -5154,7 +5195,8 @@ const translations = {
type: {
changeField: ({oldValue, newValue, fieldName}: ChangeFieldParams) => `changed ${fieldName} from ${oldValue} to ${newValue}`,
changeFieldEmpty: ({newValue, fieldName}: ChangeFieldParams) => `changed ${fieldName} to ${newValue}`,
- changePolicy: ({fromPolicy, toPolicy}: ChangePolicyParams) => `changed the workspace to ${toPolicy} (previously ${fromPolicy})`,
+ changeReportPolicy: ({fromPolicyName, toPolicyName}: ChangeReportPolicyParams) =>
+ `changed the workspace to ${toPolicyName}${fromPolicyName ? ` (previously ${fromPolicyName})` : ''}`,
changeType: ({oldType, newType}: ChangeTypeParams) => `changed type from ${oldType} to ${newType}`,
delegateSubmit: ({delegateUser, originalManager}: DelegateSubmitParams) => `sent this report to ${delegateUser} since ${originalManager} is on vacation`,
exportedToCSV: `exported this report to CSV`,
@@ -5496,6 +5538,7 @@ const translations = {
confirmDetails: `Confirm the details you're keeping`,
confirmDuplicatesInfo: `The duplicate requests you don't keep will be held for the member to delete`,
hold: 'Hold',
+ resolvedDuplicates: 'resolved the duplicate',
},
reportViolations: {
[CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: ({fieldName}: RequiredFieldParams) => `${fieldName} is required`,
diff --git a/src/languages/es.ts b/src/languages/es.ts
index a88fd18f5024..e6d7a163f874 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -36,7 +36,7 @@ import type {
ChangeOwnerDuplicateSubscriptionParams,
ChangeOwnerHasFailedSettlementsParams,
ChangeOwnerSubscriptionParams,
- ChangePolicyParams,
+ ChangeReportPolicyParams,
ChangeTypeParams,
CharacterLengthLimitParams,
CharacterLimitParams,
@@ -1096,6 +1096,13 @@ const translations = {
whatIsHoldExplain: 'Retener es como "pausar" un gasto para solicitar más detalles antes de aprobarlo o pagarlo.',
holdIsLeftBehind: 'Si apruebas un informe, los gastos retenidos se quedan fuera de esa aprobación.',
unholdWhenReady: 'Desbloquea los gastos cuando estés listo para aprobarlos o pagarlos.',
+ changePolicyEducational: {
+ title: '¡Has movido este informe!',
+ description: 'Revisa cuidadosamente estos elementos, que tienden a cambiar al trasladar informes a un nuevo espacio de trabajo.',
+ reCategorize: 'Vuelve a categorizar los gastos para cumplir con las reglas del espacio de trabajo.',
+ workflows: 'Este informe ahora puede estar sujeto a un flujo de aprobación diferente.',
+ },
+ changeWorkspace: 'Cambiar espacio de trabajo',
set: 'estableció',
changed: 'cambió',
removed: 'eliminó',
@@ -1947,6 +1954,34 @@ const translations = {
`No hemos podido entregar mensajes SMS a ${login}, así que lo hemos suspendido durante 24 horas. Por favor, intenta validar tu número:`,
validationFailed: 'La validación falló porque no han pasado 24 horas desde tu último intento.',
validationSuccess: '¡Tu número ha sido validado! Haz clic abajo para enviar un nuevo código mágico de inicio de sesión.',
+ pleaseWaitBeforeTryingAgain: ({timeData}: {timeData?: {days?: number; hours?: number; minutes?: number}}) => {
+ if (!timeData) {
+ return 'Por favor, espera un momento antes de intentarlo de nuevo.';
+ }
+
+ const parts = [];
+ if (timeData.days) {
+ parts.push(`${timeData.days} ${timeData.days === 1 ? 'día' : 'días'}`);
+ }
+ if (timeData.hours) {
+ parts.push(`${timeData.hours} ${timeData.hours === 1 ? 'hora' : 'horas'}`);
+ }
+ if (timeData.minutes) {
+ parts.push(`${timeData.minutes} ${timeData.minutes === 1 ? 'minuto' : 'minutos'}`);
+ }
+
+ let timeText;
+ if (parts.length === 1) {
+ timeText = parts.at(0);
+ } else if (parts.length === 2) {
+ timeText = parts.join(' y ');
+ } else {
+ const lastPart = parts.pop();
+ timeText = `${parts.join(', ')} y ${lastPart}`;
+ }
+
+ return `Por favor, espera ${timeText} antes de intentarlo de nuevo.`;
+ },
},
welcomeSignUpForm: {
join: 'Unirse',
@@ -2185,7 +2220,7 @@ const translations = {
noOverdraftOrCredit: 'Sin función de sobregiro/crédito',
electronicFundsWithdrawal: 'Retiro electrónico de fondos',
standard: 'Estándar',
- reviewTheFees: 'Por favor, revisa las siguientes tarifas.',
+ reviewTheFees: 'Echa un vistazo a algunas de las tarifas.',
checkTheBoxes: 'Por favor, marca las siguientes casillas.',
agreeToTerms: 'Debes aceptar los términos y condiciones para continuar.',
shortTermsForm: {
@@ -2200,7 +2235,7 @@ const translations = {
customerService: 'Servicio al cliente',
automatedOrLive: '(agente automatizado o en vivo)',
afterTwelveMonths: '(después de 12 meses sin transacciones)',
- weChargeOneFee: 'Cobramos un tipo de tarifa.',
+ weChargeOneFee: 'Cobramos otro tipo de tarifa. Es:',
fdicInsurance: 'Tus fondos pueden acogerse al seguro de la FDIC.',
generalInfo: 'Para obtener información general sobre cuentas de prepago, visite',
conditionsDetails: 'Encuentra detalles y condiciones para todas las tarifas y servicios visitando',
@@ -2210,9 +2245,9 @@ const translations = {
},
longTermsForm: {
listOfAllFees: 'Una lista de todas las tarifas de la Billetera Expensify',
- typeOfFeeHeader: 'Tipo de tarifa',
- feeAmountHeader: 'Importe de la tarifa',
- moreDetailsHeader: 'Más detalles',
+ typeOfFeeHeader: 'Todas las tarifas',
+ feeAmountHeader: 'Cantidad',
+ moreDetailsHeader: 'Descripción',
openingAccountTitle: 'Abrir una cuenta',
openingAccountDetails: 'No hay tarifa para abrir una cuenta.',
monthlyFeeDetails: 'No hay tarifa mensual.',
@@ -2233,7 +2268,8 @@ const translations = {
fdicInsuranceBancorp: ({amount}: TermsParams) =>
'Tus fondos pueden acogerse al seguro de la FDIC. Tus fondos se mantendrán o serán ' +
`transferidos a ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK}, una institución asegurada por la FDIC. Una vez allí, tus fondos ` +
- `están asegurados hasta ${amount} por la FDIC en caso de que ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK} quiebre. Ver`,
+ `están asegurados hasta ${amount} por la FDIC en caso de que ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK} quiebre, si se cumplen ` +
+ `los requisitos específicos del seguro de depósitos y tu tarjeta está registrada. Ver`,
fdicInsuranceBancorp2: 'para más detalles.',
contactExpensifyPayments: `Comunícate con ${CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS} llamando al + 1833-400-0904, o por correo electrónico a`,
contactExpensifyPayments2: 'o inicie sesión en',
@@ -2922,6 +2958,7 @@ const translations = {
locations: 'Lugares',
customers: 'Clientes/proyectos',
accountsDescription: 'Tu plan de cuentas de QuickBooks Online se importará a Expensify como categorías.',
+ autoSyncDescription: 'Sincroniza QuickBooks Online y Expensify automáticamente, todos los días. Exporta el informe finalizado en tiempo real',
accountsSwitchTitle: 'Elige importar cuentas nuevas como categorías activadas o desactivadas.',
accountsSwitchDescription: 'Las categorías activas estarán disponibles para ser escogidas cuando se crea un gasto.',
classesDescription: 'Elige cómo gestionar las clases de QuickBooks Online en Expensify.',
@@ -3268,18 +3305,6 @@ const translations = {
[CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_APPROVED_BOTH]: 'Aprobado por supervisor y contabilidad',
},
},
- accountingMethods: {
- label: 'Cuándo Exportar',
- description: 'Elige cuándo exportar los gastos:',
- values: {
- [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Devengo',
- [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Efectivo',
- },
- alternateText: {
- [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Los gastos por cuenta propia se exportarán cuando estén aprobados definitivamente',
- [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Los gastos por cuenta propia se exportarán cuando estén pagados',
- },
- },
exportVendorBillsTo: {
label: 'Nivel de aprobación de facturas de proveedores',
description:
@@ -3574,6 +3599,18 @@ const translations = {
}
},
},
+ accountingMethods: {
+ label: 'Cuándo Exportar',
+ description: 'Elige cuándo exportar los gastos:',
+ values: {
+ [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Devengo',
+ [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Efectivo',
+ },
+ alternateText: {
+ [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Los gastos por cuenta propia se exportarán cuando estén aprobados definitivamente',
+ [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Los gastos por cuenta propia se exportarán cuando estén pagados',
+ },
+ },
multiConnectionSelector: {
title: ({connectionName}: ConnectionNameParams) => `${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]} configuración`,
description: ({connectionName}: ConnectionNameParams) => `Selecciona tu versión de ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]} para continuar.`,
@@ -5200,6 +5237,10 @@ const translations = {
},
},
report: {
+ newReport: {
+ createReport: 'Crear informe',
+ chooseWorkspace: 'Elige un espacio de trabajo para este informe.',
+ },
genericCreateReportFailureMessage: 'Error inesperado al crear el chat. Por favor, inténtalo más tarde.',
genericAddCommentFailureMessage: 'Error inesperado al añadir el comentario. Por favor, inténtalo más tarde.',
genericUpdateReportFieldFailureMessage: 'Error inesperado al actualizar el campo. Por favor, inténtalo más tarde.',
@@ -5209,7 +5250,8 @@ const translations = {
type: {
changeField: ({oldValue, newValue, fieldName}: ChangeFieldParams) => `cambió ${fieldName} de ${oldValue} a ${newValue}`,
changeFieldEmpty: ({newValue, fieldName}: ChangeFieldParams) => `cambió ${fieldName} a ${newValue}`,
- changePolicy: ({fromPolicy, toPolicy}: ChangePolicyParams) => `cambió el espacio de trabajo a ${toPolicy} (previamente ${fromPolicy})`,
+ changeReportPolicy: ({fromPolicyName, toPolicyName}: ChangeReportPolicyParams) =>
+ `cambió el espacio de trabajo a ${toPolicyName}${fromPolicyName ? ` (previamente ${fromPolicyName})` : ''}`,
changeType: ({oldType, newType}: ChangeTypeParams) => `cambió type de ${oldType} a ${newType}`,
delegateSubmit: ({delegateUser, originalManager}: DelegateSubmitParams) => `envié este informe a ${delegateUser} ya que ${originalManager} está de vacaciones`,
exportedToCSV: `exportó este informe a CSV`,
@@ -6013,6 +6055,7 @@ const translations = {
confirmDetails: 'Confirma los detalles que conservas',
confirmDuplicatesInfo: 'Los duplicados que no conserves se guardarán para que el usuario los elimine',
hold: 'Retenido',
+ resolvedDuplicates: 'resolvió el duplicado',
},
reportViolations: {
[CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: ({fieldName}: RequiredFieldParams) => `${fieldName} es obligatorio`,
diff --git a/src/languages/params.ts b/src/languages/params.ts
index b8f1f34ff3a0..d1c6751e123c 100644
--- a/src/languages/params.ts
+++ b/src/languages/params.ts
@@ -293,7 +293,7 @@ type HeldRequestParams = {comment: string};
type ChangeFieldParams = {oldValue?: string; newValue: string; fieldName: string};
-type ChangePolicyParams = {fromPolicy: string; toPolicy: string};
+type ChangeReportPolicyParams = {fromPolicyName?: string; toPolicyName: string};
type UpdatedPolicyDescriptionParams = {oldDescription: string; newDescription: string};
@@ -829,7 +829,7 @@ export type {
WelcomeToRoomParams,
ZipCodeExampleFormatParams,
ChangeFieldParams,
- ChangePolicyParams,
+ ChangeReportPolicyParams,
ChangeTypeParams,
ExportedToIntegrationParams,
DelegateSubmitParams,
diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts
index af029c862f07..5c87694c53ed 100644
--- a/src/libs/API/index.ts
+++ b/src/libs/API/index.ts
@@ -2,7 +2,7 @@ import type {OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {SetRequired} from 'type-fest';
import Log from '@libs/Log';
-import {HandleUnusedOptimisticID, Logging, Pagination, Reauthentication, RecheckConnection, SaveResponseInOnyx} from '@libs/Middleware';
+import {handleDeletedAccount, HandleUnusedOptimisticID, Logging, Pagination, Reauthentication, RecheckConnection, SaveResponseInOnyx} from '@libs/Middleware';
import {isOffline} from '@libs/Network/NetworkStore';
import {push as pushToSequentialQueue, waitForIdle as waitForSequentialQueueIdle} from '@libs/Network/SequentialQueue';
import Pusher from '@libs/Pusher';
@@ -28,6 +28,9 @@ use(RecheckConnection);
// Reauthentication - Handles jsonCode 407 which indicates an expired authToken. We need to reauthenticate and get a new authToken with our stored credentials.
use(Reauthentication);
+// Handles the case when the copilot has been deleted. The response contains jsonCode 408 and a message indicating account deletion
+use(handleDeletedAccount);
+
// If an optimistic ID is not used by the server, this will update the remaining serialized requests using that optimistic ID to use the correct ID instead.
use(HandleUnusedOptimisticID);
diff --git a/src/libs/API/parameters/ChangeReportPolicyParams.ts b/src/libs/API/parameters/ChangeReportPolicyParams.ts
new file mode 100644
index 000000000000..d91e3409b699
--- /dev/null
+++ b/src/libs/API/parameters/ChangeReportPolicyParams.ts
@@ -0,0 +1,7 @@
+type ChangeReportPolicyParams = {
+ reportID: string;
+ policyID: string;
+ reportPreviewReportActionID: string;
+ changePolicyReportActionID: string;
+};
+export default ChangeReportPolicyParams;
diff --git a/src/libs/API/parameters/CreateAppReportParams.ts b/src/libs/API/parameters/CreateAppReportParams.ts
new file mode 100644
index 000000000000..5fccf0e7a166
--- /dev/null
+++ b/src/libs/API/parameters/CreateAppReportParams.ts
@@ -0,0 +1,8 @@
+type CreateAppReportParams = {
+ reportName: string;
+ policyID?: string;
+ type: string;
+ reportID: string;
+ reportActionID: string;
+};
+export default CreateAppReportParams;
diff --git a/src/libs/API/parameters/TransactionMergeParams.ts b/src/libs/API/parameters/MergeDuplicatesParams.ts
similarity index 74%
rename from src/libs/API/parameters/TransactionMergeParams.ts
rename to src/libs/API/parameters/MergeDuplicatesParams.ts
index ad718d37e6c8..b5ef56a52502 100644
--- a/src/libs/API/parameters/TransactionMergeParams.ts
+++ b/src/libs/API/parameters/MergeDuplicatesParams.ts
@@ -1,4 +1,4 @@
-type TransactionMergeParams = {
+type MergeDuplicatesParams = {
transactionID: string | undefined;
transactionIDList: string[];
created: string;
@@ -12,6 +12,7 @@ type TransactionMergeParams = {
tag: string;
receiptID: number;
reportID: string | undefined;
+ reportActionID?: string | undefined;
};
-export default TransactionMergeParams;
+export default MergeDuplicatesParams;
diff --git a/src/libs/API/parameters/UpdateQuickbooksOnlineAccountingMethodParams.ts b/src/libs/API/parameters/UpdateQuickbooksOnlineAccountingMethodParams.ts
new file mode 100644
index 000000000000..617570d5c365
--- /dev/null
+++ b/src/libs/API/parameters/UpdateQuickbooksOnlineAccountingMethodParams.ts
@@ -0,0 +1,9 @@
+import type {CONST as COMMON_CONST} from 'expensify-common';
+import type {ValueOf} from 'type-fest';
+
+type UpdateQuickbooksOnlineAccountingMethodParams = {
+ policyID: string;
+ accountingMethod: ValueOf;
+};
+
+export default UpdateQuickbooksOnlineAccountingMethodParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index 3a0d51a9dac5..3675620d8a6c 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -20,6 +20,7 @@ export type {default as SyncPolicyToQuickbooksOnlineParams} from './SyncPolicyTo
export type {default as SyncPolicyToXeroParams} from './SyncPolicyToXeroParams';
export type {default as SyncPolicyToNetSuiteParams} from './SyncPolicyToNetSuiteParams';
export type {default as UpdateNetSuiteAccountingMethodParams} from './UpdateNetSuiteAccountingMethodParams';
+export type {default as UpdateQuickbooksOnlineAccountingMethodParams} from './UpdateQuickbooksOnlineAccountingMethodParams';
export type {default as SyncPolicyToQuickbooksDesktopParams} from './SyncPolicyToQuickbooksDesktopParams';
export type {default as DeleteContactMethodParams} from './DeleteContactMethodParams';
export type {default as DeletePaymentBankAccountParams} from './DeletePaymentBankAccountParams';
@@ -32,6 +33,7 @@ export type {default as GetOlderActionsParams} from './GetOlderActionsParams';
export type {default as GetPolicyCategoriesParams} from './GetPolicyCategories';
export type {default as GetReportPrivateNoteParams} from './GetReportPrivateNoteParams';
export type {default as GetRouteParams} from './GetRouteParams';
+export type {default as CreateAppReportParams} from './CreateAppReportParams';
export type {default as GetStatementPDFParams} from './GetStatementPDFParams';
export type {default as HandleRestrictedEventParams} from './HandleRestrictedEventParams';
export type {default as LogOutParams} from './LogOutParams';
@@ -244,7 +246,7 @@ export type {default as SearchParams} from './Search';
export type {default as SendInvoiceParams} from './SendInvoiceParams';
export type {default as PayInvoiceParams} from './PayInvoiceParams';
export type {default as MarkAsCashParams} from './MarkAsCashParams';
-export type {default as TransactionMergeParams} from './TransactionMergeParams';
+export type {default as MergeDuplicatesParams} from './MergeDuplicatesParams';
export type {default as ResolveDuplicatesParams} from './ResolveDuplicatesParams';
export type {default as UpdateSubscriptionTypeParams} from './UpdateSubscriptionTypeParams';
export type {default as SignUpUserParams} from './SignUpUserParams';
@@ -379,3 +381,4 @@ export type {default as GetCorpayOnboardingFieldsParams} from './GetCorpayOnboar
export type {SaveCorpayOnboardingCompanyDetailsParams} from './SaveCorpayOnboardingCompanyDetailsParams';
export type {default as AcceptSpotnanaTermsParams} from './AcceptSpotnanaTermsParams';
export type {default as SaveCorpayOnboardingBeneficialOwnerParams} from './SaveCorpayOnboardingBeneficialOwnerParams';
+export type {default as ChangeReportPolicyParams} from './ChangeReportPolicyParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index ad99f057b3c6..244748e8dce3 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -110,6 +110,7 @@ const WRITE_COMMANDS = {
TOGGLE_PINNED_CHAT: 'TogglePinnedChat',
DELETE_COMMENT: 'DeleteComment',
UPDATE_COMMENT: 'UpdateComment',
+ CREATE_APP_REPORT: 'CreateAppReport',
UPDATE_REPORT_NOTIFICATION_PREFERENCE: 'UpdateReportNotificationPreference',
UPDATE_ROOM_VISIBILITY: 'UpdateRoomVisibility',
UPDATE_ROOM_DESCRIPTION: 'UpdateRoomDescription',
@@ -267,6 +268,7 @@ const WRITE_COMMANDS = {
UPDATE_QUICKBOOKS_ONLINE_SYNC_PEOPLE: 'UpdateQuickbooksOnlineSyncPeople',
UPDATE_QUICKBOOKS_ONLINE_REIMBURSEMENT_ACCOUNT_ID: 'UpdateQuickbooksOnlineReimbursementAccountID',
UPDATE_QUICKBOOKS_ONLINE_EXPORT: 'UpdateQuickbooksOnlineExport',
+ UPDATE_QUICKBOOKS_ONLINE_ACCOUNTING_METHOD: 'UpdateQuickbooksOnlineAccountingMethod',
UPDATE_QUICKBOOKS_DESKTOP_EXPORT_DATE: 'UpdateQuickbooksDesktopExportDate',
UPDATE_MANY_POLICY_CONNECTION_CONFIGS: 'UpdateManyPolicyConnectionConfigurations',
UPDATE_QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION: 'UpdateQuickbooksDesktopNonReimbursableExpensesExportDestination',
@@ -310,7 +312,7 @@ const WRITE_COMMANDS = {
SEND_INVOICE: 'SendInvoice',
PAY_INVOICE: 'PayInvoice',
MARK_AS_CASH: 'MarkAsCash',
- TRANSACTION_MERGE: 'Transaction_Merge',
+ MERGE_DUPLICATES: 'MergeDuplicates',
RESOLVE_DUPLICATES: 'ResolveDuplicates',
UPDATE_SUBSCRIPTION_TYPE: 'UpdateSubscriptionType',
SIGN_UP_USER: 'SignUpUser',
@@ -461,6 +463,7 @@ const WRITE_COMMANDS = {
RESET_SMS_DELIVERY_FAILURE_STATUS: 'ResetSMSDeliveryFailureStatus',
SAVE_CORPAY_ONBOARDING_COMPANY_DETAILS: 'SaveCorpayOnboardingCompanyDetails',
SAVE_CORPAY_ONBOARDING_BENEFICIAL_OWNER: 'SaveCorpayOnboardingBeneficialOwner',
+ CHANGE_REPORT_POLICY: 'ChangeReportPolicy',
} as const;
type WriteCommand = ValueOf;
@@ -545,6 +548,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.DISABLE_TWO_FACTOR_AUTH]: Parameters.DisableTwoFactorAuthParams;
[WRITE_COMMANDS.ADD_COMMENT]: Parameters.AddCommentOrAttachementParams;
[WRITE_COMMANDS.ADD_ATTACHMENT]: Parameters.AddCommentOrAttachementParams;
+ [WRITE_COMMANDS.CREATE_APP_REPORT]: Parameters.CreateAppReportParams;
[WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT]: Parameters.AddCommentOrAttachementParams;
[WRITE_COMMANDS.CONNECT_BANK_ACCOUNT_WITH_PLAID]: Parameters.ConnectBankAccountParams;
[WRITE_COMMANDS.ADD_PERSONAL_BANK_ACCOUNT]: Parameters.AddPersonalBankAccountParams;
@@ -747,6 +751,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_EXPORT_DATE]: Parameters.UpdateQuickbooksOnlineGenericTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_EXPENSES_ACCOUNT]: Parameters.UpdateQuickbooksOnlineGenericTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_COLLECTION_ACCOUNT_ID]: Parameters.UpdateQuickbooksOnlineGenericTypeParams;
+ [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_ACCOUNTING_METHOD]: Parameters.UpdateQuickbooksOnlineAccountingMethodParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_EXPORT_DATE]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_MARK_CHECKS_TO_BE_PRINTED]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_AUTO_CREATE_VENDOR]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
@@ -781,7 +786,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.SEND_INVOICE]: Parameters.SendInvoiceParams;
[WRITE_COMMANDS.PAY_INVOICE]: Parameters.PayInvoiceParams;
[WRITE_COMMANDS.MARK_AS_CASH]: Parameters.MarkAsCashParams;
- [WRITE_COMMANDS.TRANSACTION_MERGE]: Parameters.TransactionMergeParams;
+ [WRITE_COMMANDS.MERGE_DUPLICATES]: Parameters.MergeDuplicatesParams;
[WRITE_COMMANDS.RESOLVE_DUPLICATES]: Parameters.ResolveDuplicatesParams;
[WRITE_COMMANDS.UPDATE_SUBSCRIPTION_TYPE]: Parameters.UpdateSubscriptionTypeParams;
[WRITE_COMMANDS.SIGN_UP_USER]: Parameters.SignUpUserParams;
@@ -933,6 +938,9 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.JOIN_ACCESSIBLE_POLICY]: Parameters.JoinAccessiblePolicyParams;
// Dismis Product Training
[WRITE_COMMANDS.DISMISS_PRODUCT_TRAINING]: Parameters.DismissProductTrainingParams;
+
+ // Change report policy
+ [WRITE_COMMANDS.CHANGE_REPORT_POLICY]: Parameters.ChangeReportPolicyParams;
};
const READ_COMMANDS = {
diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts
index 8e26bb0c8b7b..4cd7762d9ce5 100644
--- a/src/libs/CardUtils.ts
+++ b/src/libs/CardUtils.ts
@@ -531,7 +531,7 @@ function checkIfFeedConnectionIsBroken(feedCards: Record | undefin
return false;
}
- return Object.values(feedCards).some((card) => card.bank !== feedToExclude && card.lastScrapeResult !== 200);
+ return Object.values(feedCards).some((card) => !isEmptyObject(card) && card.bank !== feedToExclude && card.lastScrapeResult !== 200);
}
/**
diff --git a/src/libs/Middleware/HandleDeletedAccount.ts b/src/libs/Middleware/HandleDeletedAccount.ts
new file mode 100644
index 000000000000..ec5532baa230
--- /dev/null
+++ b/src/libs/Middleware/HandleDeletedAccount.ts
@@ -0,0 +1,18 @@
+import {signOutAndRedirectToSignIn} from '@libs/actions/Session';
+import type {Middleware} from '@libs/Request';
+
+/**
+ * Handles the case when the user's copilot has been deleted.
+ * If the response contains jsonCode 408 and a message indicating copilot deletion,
+ * the function signs the user out and redirects them to the sign-in page.
+ */
+
+const handleDeletedAccount: Middleware = (requestResponse) =>
+ requestResponse.then((response) => {
+ if (response?.jsonCode !== 408 || !response?.message?.includes('The account you are trying to use is deleted.')) {
+ return response;
+ }
+ signOutAndRedirectToSignIn(true, false, true, true);
+ });
+
+export default handleDeletedAccount;
diff --git a/src/libs/Middleware/index.ts b/src/libs/Middleware/index.ts
index 7f02e23ad9b8..f64473afc0cf 100644
--- a/src/libs/Middleware/index.ts
+++ b/src/libs/Middleware/index.ts
@@ -1,3 +1,4 @@
+import handleDeletedAccount from './HandleDeletedAccount';
import HandleUnusedOptimisticID from './HandleUnusedOptimisticID';
import Logging from './Logging';
import {Pagination} from './Pagination';
@@ -5,4 +6,4 @@ import Reauthentication from './Reauthentication';
import RecheckConnection from './RecheckConnection';
import SaveResponseInOnyx from './SaveResponseInOnyx';
-export {HandleUnusedOptimisticID, Logging, Reauthentication, RecheckConnection, SaveResponseInOnyx, Pagination};
+export {HandleUnusedOptimisticID, Logging, Reauthentication, RecheckConnection, SaveResponseInOnyx, Pagination, handleDeletedAccount};
diff --git a/src/libs/NavBarManager/index.android.ts b/src/libs/NavBarManager/index.android.ts
index 81a4626bfb08..1bcfd24507c0 100644
--- a/src/libs/NavBarManager/index.android.ts
+++ b/src/libs/NavBarManager/index.android.ts
@@ -1,11 +1,13 @@
import {NativeModules} from 'react-native';
-import type StartupTimer from './types';
-import type {NavBarButtonStyle} from './types';
+import type NavBarManager from './types';
-const navBarManager: StartupTimer = {
- setButtonStyle: (style: NavBarButtonStyle) => {
+const navBarManager: NavBarManager = {
+ setButtonStyle: (style) => {
NativeModules.RNNavBarManager.setButtonStyle(style);
},
+ getType: () => {
+ return NativeModules.RNNavBarManager.getType();
+ },
};
export default navBarManager;
diff --git a/src/libs/NavBarManager/index.ts b/src/libs/NavBarManager/index.ts
index 79c9ef85fdcd..a7378895a2fe 100644
--- a/src/libs/NavBarManager/index.ts
+++ b/src/libs/NavBarManager/index.ts
@@ -1,7 +1,10 @@
+import getPlatform from '@libs/getPlatform';
+import CONST from '@src/CONST';
import type NavBarManager from './types';
const navBarManager: NavBarManager = {
setButtonStyle: () => {},
+ getType: () => (getPlatform() === CONST.PLATFORM.IOS ? CONST.NAVIGATION_BAR_TYPE.GESTURE_BAR : CONST.NAVIGATION_BAR_TYPE.NONE),
};
export default navBarManager;
diff --git a/src/libs/NavBarManager/types.ts b/src/libs/NavBarManager/types.ts
index 443db391da9d..f2003be8110c 100644
--- a/src/libs/NavBarManager/types.ts
+++ b/src/libs/NavBarManager/types.ts
@@ -1,8 +1,14 @@
+import type {ValueOf} from 'type-fest';
+import type CONST from '@src/CONST';
+
type NavBarButtonStyle = 'light' | 'dark';
+type NavigationBarType = ValueOf;
+
type NavBarManager = {
setButtonStyle: (style: NavBarButtonStyle) => void;
+ getType: () => NavigationBarType;
};
export default NavBarManager;
-export type {NavBarButtonStyle};
+export type {NavBarButtonStyle, NavigationBarType};
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index dd09c5eb27c3..67fa614edb04 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -10,12 +10,14 @@ import type {
MissingPersonalDetailsParamList,
MoneyRequestNavigatorParamList,
NewChatNavigatorParamList,
+ NewReportWorkspaceSelectionNavigatorParamList,
NewTaskNavigatorParamList,
ParticipantsNavigatorParamList,
PrivateNotesNavigatorParamList,
ProfileNavigatorParamList,
ReferralDetailsNavigatorParamList,
ReimbursementAccountNavigatorParamList,
+ ReportChangeWorkspaceNavigatorParamList,
ReportDescriptionNavigatorParamList,
ReportDetailsNavigatorParamList,
ReportSettingsNavigatorParamList,
@@ -128,12 +130,20 @@ const ProfileModalStackNavigator = createModalStackNavigator require('../../../../pages/ProfilePage').default,
});
+const NewReportWorkspaceSelectionModalStackNavigator = createModalStackNavigator({
+ [SCREENS.NEW_REPORT_WORKSPACE_SELECTION.ROOT]: () => require('../../../../pages/NewReportWorkspaceSelectionPage').default,
+});
+
const ReportDetailsModalStackNavigator = createModalStackNavigator({
[SCREENS.REPORT_DETAILS.ROOT]: () => require('../../../../pages/ReportDetailsPage').default,
[SCREENS.REPORT_DETAILS.SHARE_CODE]: () => require('../../../../pages/home/report/ReportDetailsShareCodePage').default,
[SCREENS.REPORT_DETAILS.EXPORT]: () => require('../../../../pages/home/report/ReportDetailsExportPage').default,
});
+const ReportChangeWorkspaceModalStackNavigator = createModalStackNavigator({
+ [SCREENS.REPORT_CHANGE_WORKSPACE.ROOT]: () => require('../../../../pages/ReportChangeWorkspacePage').default,
+});
+
const ReportSettingsModalStackNavigator = createModalStackNavigator({
[SCREENS.REPORT_SETTINGS.ROOT]: () => require('../../../../pages/settings/Report/ReportSettingsPage').default,
[SCREENS.REPORT_SETTINGS.NAME]: () => require('../../../../pages/settings/Report/NamePage').default,
@@ -343,6 +353,9 @@ const SettingsModalStackNavigator = createModalStackNavigator('@pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_COMPANY_CARD_SELECT]: () =>
require('../../../../pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectCardPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_AUTO_SYNC]: () => require('../../../../pages/workspace/accounting/qbo/advanced/QuickbooksAutoSyncPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_ACCOUNTING_METHOD]: () =>
+ require('../../../../pages/workspace/accounting/qbo/advanced/QuickbooksAccountingMethodPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT]: () =>
require('../../../../pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_EXPORT_PREFERRED_EXPORTER]: () =>
@@ -658,6 +671,10 @@ const ProcessMoneyRequestHoldStackNavigator = createModalStackNavigator({
[SCREENS.PROCESS_MONEY_REQUEST_HOLD_ROOT]: () => require('../../../../pages/ProcessMoneyRequestHoldPage').default,
});
+const ChangePolicyEducationalStackNavigator = createModalStackNavigator({
+ [SCREENS.CHANGE_POLICY_EDUCATIONAL_ROOT]: () => require('../../../../pages/ChangePolicyEducationalModal').default,
+});
+
const TransactionDuplicateStackNavigator = createModalStackNavigator({
[SCREENS.TRANSACTION_DUPLICATE.REVIEW]: () => require('../../../../pages/TransactionDuplicate/Review').default,
[SCREENS.TRANSACTION_DUPLICATE.MERCHANT]: () => require('../../../../pages/TransactionDuplicate/ReviewMerchant').default,
@@ -733,12 +750,15 @@ export {
NewTeachersUniteNavigator,
PrivateNotesModalStackNavigator,
ProcessMoneyRequestHoldStackNavigator,
+ ChangePolicyEducationalStackNavigator,
ProfileModalStackNavigator,
ReferralModalStackNavigator,
TravelModalStackNavigator,
ReimbursementAccountModalStackNavigator,
+ NewReportWorkspaceSelectionModalStackNavigator,
ReportDescriptionModalStackNavigator,
ReportDetailsModalStackNavigator,
+ ReportChangeWorkspaceModalStackNavigator,
ReportParticipantsModalStackNavigator,
ReportSettingsModalStackNavigator,
RoomMembersModalStackNavigator,
diff --git a/src/libs/Navigation/AppNavigator/Navigators/FeatureTrainingModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/FeatureTrainingModalNavigator.tsx
index db110d53bc63..2041f7b0e4a5 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/FeatureTrainingModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/FeatureTrainingModalNavigator.tsx
@@ -4,6 +4,7 @@ import NoDropZone from '@components/DragAndDrop/NoDropZone';
import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation';
import type {FeatureTrainingNavigatorParamList} from '@libs/Navigation/types';
+import ChangePolicyEducationalModal from '@pages/ChangePolicyEducationalModal';
import ProcessMoneyRequestHoldPage from '@pages/ProcessMoneyRequestHoldPage';
import TrackTrainingPage from '@pages/TrackTrainingPage';
import SCREENS from '@src/SCREENS';
@@ -23,6 +24,10 @@ function FeatureTrainingModalNavigator() {
name={SCREENS.PROCESS_MONEY_REQUEST_HOLD_ROOT}
component={ProcessMoneyRequestHoldPage}
/>
+
diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
index b1d923c69b29..ceb20d4cccb5 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
@@ -108,10 +108,18 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) {
name={SCREENS.RIGHT_MODAL.DEBUG}
component={ModalStackNavigators.DebugModalStackNavigator}
/>
+
+ | null, path: Route, options?: LinkToOptions) {
diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts
index a986641b3e5b..a68ef1ddf9b3 100755
--- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts
+++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts
@@ -55,6 +55,8 @@ const WORKSPACE_TO_RHP: Partial['config'] = {
exact: true,
},
[SCREENS.PROCESS_MONEY_REQUEST_HOLD_ROOT]: ROUTES.PROCESS_MONEY_REQUEST_HOLD.route,
+ [SCREENS.CHANGE_POLICY_EDUCATIONAL_ROOT]: ROUTES.CHANGE_POLICY_EDUCATIONAL.route,
},
},
[NAVIGATORS.WELCOME_VIDEO_MODAL_NAVIGATOR]: {
@@ -357,6 +358,12 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_LOCATIONS_DISPLAYED_AS]: {
path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_LOCATIONS_DISPLAYED_AS.route,
},
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_AUTO_SYNC]: {
+ path: ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_AUTO_SYNC.route,
+ },
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_ACCOUNTING_METHOD]: {
+ path: ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ACCOUNTING_METHOD.route,
+ },
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT]: {
path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT.route,
},
@@ -998,6 +1005,11 @@ const config: LinkingOptions['config'] = {
[SCREENS.PRIVATE_NOTES.EDIT]: ROUTES.PRIVATE_NOTES_EDIT.route,
},
},
+ [SCREENS.RIGHT_MODAL.NEW_REPORT_WORKSPACE_SELECTION]: {
+ screens: {
+ [SCREENS.NEW_REPORT_WORKSPACE_SELECTION.ROOT]: ROUTES.NEW_REPORT_WORKSPACE_SELECTION,
+ },
+ },
[SCREENS.RIGHT_MODAL.REPORT_DETAILS]: {
screens: {
[SCREENS.REPORT_DETAILS.ROOT]: ROUTES.REPORT_WITH_ID_DETAILS.route,
@@ -1005,6 +1017,11 @@ const config: LinkingOptions['config'] = {
[SCREENS.REPORT_DETAILS.EXPORT]: ROUTES.REPORT_WITH_ID_DETAILS_EXPORT.route,
},
},
+ [SCREENS.RIGHT_MODAL.REPORT_CHANGE_WORKSPACE]: {
+ screens: {
+ [SCREENS.REPORT_CHANGE_WORKSPACE.ROOT]: ROUTES.REPORT_WITH_ID_CHANGE_WORKSPACE.route,
+ },
+ },
[SCREENS.RIGHT_MODAL.REPORT_SETTINGS]: {
screens: {
[SCREENS.REPORT_SETTINGS.ROOT]: {
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index e568582a3437..951af6f8e6ce 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -1038,6 +1038,10 @@ type ProfileNavigatorParamList = {
};
};
+type NewReportWorkspaceSelectionNavigatorParamList = {
+ [SCREENS.NEW_REPORT_WORKSPACE_SELECTION.ROOT]: undefined;
+};
+
type ReportDetailsNavigatorParamList = {
[SCREENS.REPORT_DETAILS.ROOT]: {
reportID: string;
@@ -1055,6 +1059,13 @@ type ReportDetailsNavigatorParamList = {
};
};
+type ReportChangeWorkspaceNavigatorParamList = {
+ [SCREENS.REPORT_CHANGE_WORKSPACE.ROOT]: {
+ reportID: string;
+ backTo?: Routes;
+ };
+};
+
type ReportSettingsNavigatorParamList = {
[SCREENS.REPORT_SETTINGS.ROOT]: {
reportID: string;
@@ -1460,6 +1471,7 @@ type SignInNavigatorParamList = {
type FeatureTrainingNavigatorParamList = {
[SCREENS.FEATURE_TRAINING_ROOT]: undefined;
[SCREENS.PROCESS_MONEY_REQUEST_HOLD_ROOT]: undefined;
+ [SCREENS.CHANGE_POLICY_EDUCATIONAL_ROOT]: undefined;
};
type ReferralDetailsNavigatorParamList = {
@@ -1526,7 +1538,9 @@ type RightModalNavigatorParamList = {
[SCREENS.RIGHT_MODAL.DETAILS]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.PROFILE]: NavigatorScreenParams;
[SCREENS.SETTINGS.SHARE_CODE]: undefined;
+ [SCREENS.RIGHT_MODAL.NEW_REPORT_WORKSPACE_SELECTION]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.REPORT_DETAILS]: NavigatorScreenParams;
+ [SCREENS.RIGHT_MODAL.REPORT_CHANGE_WORKSPACE]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.REPORT_SETTINGS]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.SETTINGS_CATEGORIES]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.SETTINGS_TAGS]: NavigatorScreenParams;
@@ -1958,8 +1972,10 @@ export type {
PublicScreensParamList,
ReferralDetailsNavigatorParamList,
ReimbursementAccountNavigatorParamList,
+ NewReportWorkspaceSelectionNavigatorParamList,
ReportDescriptionNavigatorParamList,
ReportDetailsNavigatorParamList,
+ ReportChangeWorkspaceNavigatorParamList,
ReportSettingsNavigatorParamList,
ReportsSplitNavigatorParamList,
RestrictedActionParamList,
diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts
index 70a1179a8a72..635e996c322f 100644
--- a/src/libs/Permissions.ts
+++ b/src/libs/Permissions.ts
@@ -54,6 +54,10 @@ function canUseCustomRules(betas: OnyxEntry): boolean {
return !!betas?.includes(CONST.BETAS.CUSTOM_RULES) || canUseAllBetas(betas);
}
+function canUseTableReportView(betas: OnyxEntry): boolean {
+ return !!betas?.includes(CONST.BETAS.TABLE_REPORT_VIEW) || canUseAllBetas(betas);
+}
+
export default {
canUseDefaultRooms,
canUseLinkPreviews,
@@ -66,4 +70,5 @@ export default {
canUseInternationalBankAccount,
canUseNSQS,
canUseCustomRules,
+ canUseTableReportView,
};
diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts
index dc35a2532727..2c413722e2eb 100644
--- a/src/libs/PersonalDetailsUtils.ts
+++ b/src/libs/PersonalDetailsUtils.ts
@@ -163,6 +163,16 @@ function getAccountIDsByLogins(logins: string[]): number[] {
}, []);
}
+/**
+ * Given an accountID, find the associated personal detail and return related login.
+ *
+ * @param accountID User accountID
+ * @returns Login according to passed accountID
+ */
+function getLoginByAccountID(accountID: number): string | undefined {
+ return allPersonalDetails?.[accountID]?.login;
+}
+
/**
* Given a list of accountIDs, find the associated personal detail and return related logins.
*
@@ -171,9 +181,9 @@ function getAccountIDsByLogins(logins: string[]): number[] {
*/
function getLoginsByAccountIDs(accountIDs: number[]): string[] {
return accountIDs.reduce((foundLogins: string[], accountID) => {
- const currentDetail: Partial = allPersonalDetails?.[accountID] ?? {};
- if (currentDetail.login) {
- foundLogins.push(currentDetail.login);
+ const currentLogin = getLoginByAccountID(accountID);
+ if (currentLogin) {
+ foundLogins.push(currentLogin);
}
return foundLogins;
}, []);
@@ -405,4 +415,5 @@ export {
getPersonalDetailsLength,
getUserNameByEmail,
getDefaultCountry,
+ getLoginByAccountID,
};
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index 5cdb1c4601a7..54a43f360b77 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -38,7 +38,7 @@ import {getCategoryApproverRule} from './CategoryUtils';
import {translateLocal} from './Localize';
import Navigation from './Navigation/Navigation';
import {isOffline as isOfflineNetworkStore} from './Network/NetworkStore';
-import {getAccountIDsByLogins, getLoginsByAccountIDs, getPersonalDetailByEmail} from './PersonalDetailsUtils';
+import {getAccountIDsByLogins, getLoginByAccountID, getLoginsByAccountIDs, getPersonalDetailByEmail} from './PersonalDetailsUtils';
import {getAllSortedTransactions, getCategory, getTag} from './TransactionUtils';
import {isPublicDomain} from './ValidationUtils';
@@ -1087,6 +1087,39 @@ const sortWorkspacesBySelected = (workspace1: WorkspaceDetails, workspace2: Work
return workspace1.name?.toLowerCase().localeCompare(workspace2.name?.toLowerCase() ?? '') ?? 0;
};
+/**
+ * Determines whether the report can be moved to the workspace.
+ */
+const isWorkspaceEligibleForReportChange = (newPolicy: OnyxEntry, report: OnyxEntry, oldPolicy: OnyxEntry, currentUserLogin: string | undefined): boolean => {
+ const currentUserAccountID = getCurrentUserAccountID();
+ const isCurrentUserMember = !!currentUserLogin && !!newPolicy?.employeeList?.[currentUserLogin];
+ if (!isCurrentUserMember) {
+ return false;
+ }
+
+ // Submitters: workspaces where the submitter is a member of
+ const isCurrentUserSubmitter = report?.ownerAccountID === currentUserAccountID;
+ if (isCurrentUserSubmitter) {
+ return true;
+ }
+
+ // Approvers: workspaces where both the approver AND submitter are members of
+ const reportApproverAccountID = getSubmitToAccountID(oldPolicy, report);
+ const isCurrentUserApprover = currentUserAccountID === reportApproverAccountID;
+ if (isCurrentUserApprover) {
+ const reportSubmitterLogin = report?.ownerAccountID ? getLoginByAccountID(report?.ownerAccountID) : undefined;
+ const isReportSubmitterMember = !!reportSubmitterLogin && !!newPolicy?.employeeList?.[reportSubmitterLogin];
+ return isCurrentUserApprover && isReportSubmitterMember;
+ }
+
+ // Admins: same as approvers OR workspaces where the admin is an admin of (note that the submitter is invited to the workspace in this case)
+ if (isPolicyOwner(newPolicy, currentUserAccountID) || isUserPolicyAdmin(newPolicy, currentUserLogin)) {
+ return true;
+ }
+
+ return false;
+};
+
/**
* Takes removes pendingFields and errorFields from a customUnit
*/
@@ -1221,6 +1254,13 @@ function areAllGroupPoliciesExpenseChatDisabled(policies = allPolicies) {
return !groupPolicies.some((policy) => !!policy?.isPolicyExpenseChatEnabled);
}
+function getGroupPaidPoliciesWithExpenseChatEnabled(policies: OnyxCollection | null = allPolicies) {
+ if (isEmptyObject(policies)) {
+ return CONST.EMPTY_ARRAY;
+ }
+ return Object.values(policies).filter((policy) => isPaidGroupPolicy(policy) && policy?.isPolicyExpenseChatEnabled);
+}
+
// eslint-disable-next-line rulesdir/no-negated-variables
function shouldDisplayPolicyNotFoundPage(policyID: string): boolean {
const policy = getPolicy(policyID);
@@ -1464,6 +1504,7 @@ export {
getCurrentTaxID,
areSettingsInErrorFields,
settingsPendingAction,
+ getGroupPaidPoliciesWithExpenseChatEnabled,
getForwardsToAccount,
getSubmitToAccountID,
getWorkspaceAccountID,
@@ -1478,7 +1519,6 @@ export {
getActivePolicy,
getUserFriendlyWorkspaceType,
isPolicyAccessible,
- areAllGroupPoliciesExpenseChatDisabled,
shouldDisplayPolicyNotFoundPage,
hasOtherControlWorkspaces,
getManagerAccountEmail,
@@ -1488,9 +1528,11 @@ export {
getPolicyNameByID,
getMostFrequentEmailDomain,
getDescriptionForPolicyDomainCard,
+ isWorkspaceEligibleForReportChange,
getManagerAccountID,
isPrefferedExporter,
isAutoSyncEnabled,
+ areAllGroupPoliciesExpenseChatDisabled,
};
export type {MemberEmailsToAccountIDs};
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index 592cae57daf3..1eb9e4d8859e 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -30,7 +30,7 @@ import Log from './Log';
import type {MessageElementBase, MessageTextElement} from './MessageElement';
import Parser from './Parser';
import {getEffectiveDisplayName, getPersonalDetailsByIDs} from './PersonalDetailsUtils';
-import {getPolicy, getPolicyNameByID, isPolicyAdmin as isPolicyAdminPolicyUtils} from './PolicyUtils';
+import {getPolicy, isPolicyAdmin as isPolicyAdminPolicyUtils} from './PolicyUtils';
import type {getReportName, OptimisticIOUReportAction, PartialReportAction} from './ReportUtils';
import StringUtils from './StringUtils';
import {isOnHoldByTransactionID} from './TransactionUtils';
@@ -1280,7 +1280,6 @@ function isOldDotReportAction(action: ReportAction | OldDotReportAction) {
}
return [
CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD,
- CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY,
CONST.REPORT.ACTIONS.TYPE.CHANGE_TYPE,
CONST.REPORT.ACTIONS.TYPE.DELEGATE_SUBMIT,
CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV,
@@ -1336,10 +1335,6 @@ function getMessageOfOldDotReportAction(oldDotAction: PartialReportAction | OldD
}
return translateLocal('report.actions.type.changeField', {oldValue, newValue, fieldName});
}
- case CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY: {
- const {fromPolicy, toPolicy} = originalMessage;
- return translateLocal('report.actions.type.changePolicy', {fromPolicy: getPolicyNameByID(fromPolicy), toPolicy: getPolicyNameByID(toPolicy)});
- }
case CONST.REPORT.ACTIONS.TYPE.DELEGATE_SUBMIT: {
const {delegateUser, originalManager} = originalMessage;
return translateLocal('report.actions.type.delegateSubmit', {delegateUser, originalManager});
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 6245df9f150f..1e3d5322010c 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -9,7 +9,7 @@ import lodashMaxBy from 'lodash/maxBy';
import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {SvgProps} from 'react-native-svg';
-import type {OriginalMessageIOU, OriginalMessageModifiedExpense} from 'src/types/onyx/OriginalMessage';
+import type {OriginalMessageChangePolicy, OriginalMessageIOU, OriginalMessageModifiedExpense} from 'src/types/onyx/OriginalMessage';
import type {SetRequired, TupleToUnion, ValueOf} from 'type-fest';
import type {FileObject} from '@components/AttachmentModal';
import {FallbackAvatar, IntacctSquare, NetSuiteSquare, NSQSSquare, QBOSquare, XeroSquare} from '@components/Icon/Expensicons';
@@ -91,6 +91,7 @@ import {
getForwardsToAccount,
getManagerAccountEmail,
getPolicyEmployeeListByIdWithoutCurrentUser,
+ getPolicyNameByID,
getRuleApprovers,
getSubmitToAccountID,
isExpensifyTeam,
@@ -283,6 +284,24 @@ type OptimisticExpenseReport = Pick<
| 'fieldList'
>;
+type OptimisticNewReport = Pick<
+ Report,
+ | 'reportID'
+ | 'policyID'
+ | 'type'
+ | 'ownerAccountID'
+ | 'reportName'
+ | 'stateNum'
+ | 'statusNum'
+ | 'total'
+ | 'nonReimbursableTotal'
+ | 'parentReportID'
+ | 'lastVisibleActionCreated'
+ | 'parentReportActionID'
+ | 'participants'
+ | 'managerID'
+>;
+
type OptimisticIOUReportAction = Pick<
ReportAction,
| 'actionName'
@@ -3085,7 +3104,7 @@ function isWaitingForAssigneeToCompleteAction(report: OnyxEntry, parentR
return true;
}
- if (!report?.hasParentAccess && isReportManager(report)) {
+ if (report?.hasParentAccess === false && isReportManager(report)) {
if (isOpenTaskReport(report, parentReportAction)) {
return true;
}
@@ -3728,7 +3747,7 @@ function canHoldUnholdReportAction(reportAction: OnyxInputOrEntry)
const canModifyUnholdStatus = !isTrackExpenseMoneyReport && (isAdmin || (isActionOwner && isHoldActionCreator) || isApprover);
const isDeletedParentActionLocal = isEmptyObject(parentReportAction) || isDeletedAction(parentReportAction);
- const canHoldOrUnholdRequest = !isRequestSettled && !isApproved && !isDeletedParentActionLocal && !isClosed;
+ const canHoldOrUnholdRequest = !isRequestSettled && !isApproved && !isDeletedParentActionLocal && !isClosed && !isDeletedParentAction(reportAction);
const canHoldRequest = canHoldOrUnholdRequest && !isOnHold && (isRequestIOU || canModifyStatus) && !isScanning;
const canUnholdRequest = !!(canHoldOrUnholdRequest && isOnHold && !isDuplicate(transaction.transactionID, true) && (isRequestIOU ? isHoldActionCreator : canModifyUnholdStatus));
@@ -5419,6 +5438,16 @@ function getDeletedTransactionMessage(action: ReportAction) {
return message;
}
+function getPolicyChangeMessage(action: ReportAction) {
+ const PolicyChangeOriginalMessage = getOriginalMessage(action as ReportAction) ?? {};
+ const {fromPolicyID, toPolicyID} = PolicyChangeOriginalMessage as OriginalMessageChangePolicy;
+ const message = translateLocal('report.actions.type.changeReportPolicy', {
+ fromPolicyName: fromPolicyID ? getPolicyNameByID(fromPolicyID) : undefined,
+ toPolicyName: getPolicyNameByID(toPolicyID),
+ });
+ return message;
+}
+
/**
* @param iouReportID - the report ID of the IOU report the action belongs to
* @param type - IOUReportAction type. Can be oneOf(create, decline, cancel, pay, split)
@@ -5696,6 +5725,54 @@ function buildOptimisticMovedReportAction(fromPolicyID: string | undefined, toPo
};
}
+/**
+ * Builds an optimistic CHANGEPOLICY report action with a randomly generated reportActionID.
+ * This action is used when we change the workspace of a report.
+ */
+function buildOptimisticChangePolicyReportAction(fromPolicyID: string | undefined, toPolicyID: string): ReportAction {
+ const originalMessage = {
+ fromPolicyID,
+ toPolicyID,
+ };
+
+ const fromPolicy = getPolicy(fromPolicyID);
+ const toPolicy = getPolicy(toPolicyID);
+
+ const changePolicyReportActionMessage = [
+ {
+ type: CONST.REPORT.MESSAGE.TYPE.TEXT,
+ text: `changed the workspace to ${toPolicy?.name}`,
+ },
+ ...(fromPolicyID
+ ? [
+ {
+ type: CONST.REPORT.MESSAGE.TYPE.TEXT,
+ text: ` (previously ${fromPolicy?.name})`,
+ },
+ ]
+ : []),
+ ];
+
+ return {
+ actionName: CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY,
+ actorAccountID: currentUserAccountID,
+ avatar: getCurrentUserAvatar(),
+ created: DateUtils.getDBTime(),
+ originalMessage,
+ message: changePolicyReportActionMessage,
+ person: [
+ {
+ style: 'strong',
+ text: getCurrentUserDisplayNameOrEmail(),
+ type: 'TEXT',
+ },
+ ],
+ reportActionID: rand64(),
+ shouldShow: true,
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ };
+}
+
/**
* Builds an optimistic SUBMITTED report action with a randomly generated reportActionID.
*
@@ -6528,6 +6605,32 @@ function buildOptimisticDismissedViolationReportAction(
};
}
+function buildOptimisticResolvedDuplicatesReportAction(): OptimisticDismissedViolationReportAction {
+ return {
+ actionName: CONST.REPORT.ACTIONS.TYPE.RESOLVED_DUPLICATES,
+ actorAccountID: currentUserAccountID,
+ avatar: getCurrentUserAvatar(),
+ created: DateUtils.getDBTime(),
+ message: [
+ {
+ type: CONST.REPORT.MESSAGE.TYPE.TEXT,
+ style: 'normal',
+ text: translateLocal('violations.resolvedDuplicates'),
+ },
+ ],
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ person: [
+ {
+ type: CONST.REPORT.MESSAGE.TYPE.TEXT,
+ style: 'strong',
+ text: getCurrentUserDisplayNameOrEmail(),
+ },
+ ],
+ reportActionID: rand64(),
+ shouldShow: true,
+ };
+}
+
function buildOptimisticAnnounceChat(policyID: string, accountIDs: number[]): OptimisticAnnounceChat {
const announceReport = getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID);
const policy = getPolicy(policyID);
@@ -9589,7 +9692,11 @@ export {
buildOptimisticSelfDMReport,
isHiddenForCurrentUser,
prepareOnboardingOnyxData,
+ buildOptimisticResolvedDuplicatesReportAction,
getReportSubtitlePrefix,
+ buildOptimisticChangePolicyReportAction,
+ getPolicyChangeMessage,
+ getExpenseReportStateAndStatus,
};
export type {
@@ -9606,4 +9713,5 @@ export type {
PartialReportAction,
ParsingDetails,
MissingPaymentMethod,
+ OptimisticNewReport,
};
diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts
index 8d81ce7da593..613e425b0725 100644
--- a/src/libs/SearchAutocompleteUtils.ts
+++ b/src/libs/SearchAutocompleteUtils.ts
@@ -148,6 +148,7 @@ function filterOutRangesWithCorrectValue(
const typeList = Object.values(CONST.SEARCH.DATA_TYPES) as string[];
const expenseTypeList = Object.values(CONST.SEARCH.TRANSACTION_TYPE) as string[];
const statusList = Object.values({...CONST.SEARCH.STATUS.EXPENSE, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP}) as string[];
+ const groupByList = Object.values(CONST.SEARCH.GROUP_BY) as string[];
switch (range.key) {
case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN:
@@ -172,6 +173,8 @@ function filterOutRangesWithCorrectValue(
return categoryList.get().includes(range.value);
case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG:
return tagList.get().includes(range.value);
+ case CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY:
+ return groupByList.includes(range.value);
default:
return false;
}
diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js
index fae0339d4ac9..5ee9b3942896 100644
--- a/src/libs/SearchParser/autocompleteParser.js
+++ b/src/libs/SearchParser/autocompleteParser.js
@@ -215,12 +215,14 @@ function peg$parse(input, options) {
var peg$c29 = "paid";
var peg$c30 = "exported";
var peg$c31 = "posted";
- var peg$c32 = "feed";
- var peg$c33 = "!=";
- var peg$c34 = ">=";
- var peg$c35 = ">";
- var peg$c36 = "<=";
- var peg$c37 = "<";
+ var peg$c32 = "groupby";
+ var peg$c33 = "group-by";
+ var peg$c34 = "feed";
+ var peg$c35 = "!=";
+ var peg$c36 = ">=";
+ var peg$c37 = ">";
+ var peg$c38 = "<=";
+ var peg$c39 = "<";
var peg$r0 = /^[:=]/;
var peg$r1 = /^[^ ,"\u201D\u201C\t\n\r\xA0]/;
@@ -262,22 +264,24 @@ function peg$parse(input, options) {
var peg$e30 = peg$literalExpectation("paid", true);
var peg$e31 = peg$literalExpectation("exported", true);
var peg$e32 = peg$literalExpectation("posted", true);
- var peg$e33 = peg$literalExpectation("feed", true);
- var peg$e34 = peg$otherExpectation("operator");
- var peg$e35 = peg$classExpectation([":", "="], false, false);
- var peg$e36 = peg$literalExpectation("!=", false);
- var peg$e37 = peg$literalExpectation(">=", false);
- var peg$e38 = peg$literalExpectation(">", false);
- var peg$e39 = peg$literalExpectation("<=", false);
- var peg$e40 = peg$literalExpectation("<", false);
- var peg$e41 = peg$otherExpectation("quote");
- var peg$e42 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false);
- var peg$e43 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false);
- var peg$e44 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false);
- var peg$e45 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false);
- var peg$e46 = peg$otherExpectation("word");
- var peg$e47 = peg$otherExpectation("whitespace");
- var peg$e48 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false);
+ var peg$e33 = peg$literalExpectation("groupBy", true);
+ var peg$e34 = peg$literalExpectation("group-by", true);
+ var peg$e35 = peg$literalExpectation("feed", true);
+ var peg$e36 = peg$otherExpectation("operator");
+ var peg$e37 = peg$classExpectation([":", "="], false, false);
+ var peg$e38 = peg$literalExpectation("!=", false);
+ var peg$e39 = peg$literalExpectation(">=", false);
+ var peg$e40 = peg$literalExpectation(">", false);
+ var peg$e41 = peg$literalExpectation("<=", false);
+ var peg$e42 = peg$literalExpectation("<", false);
+ var peg$e43 = peg$otherExpectation("quote");
+ var peg$e44 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false);
+ var peg$e45 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false);
+ var peg$e46 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false);
+ var peg$e47 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false);
+ var peg$e48 = peg$otherExpectation("word");
+ var peg$e49 = peg$otherExpectation("whitespace");
+ var peg$e50 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false);
var peg$f0 = function(ranges) { return { autocomplete, ranges }; };
var peg$f1 = function(filters) { return filters.filter(Boolean).flat(); };
@@ -351,18 +355,19 @@ function peg$parse(input, options) {
var peg$f27 = function() { return "paid"; };
var peg$f28 = function() { return "exported"; };
var peg$f29 = function() { return "posted"; };
- var peg$f30 = function() { return "feed"; };
- var peg$f31 = function() { return "eq"; };
- var peg$f32 = function() { return "neq"; };
- var peg$f33 = function() { return "gte"; };
- var peg$f34 = function() { return "gt"; };
- var peg$f35 = function() { return "lte"; };
- var peg$f36 = function() { return "lt"; };
- var peg$f37 = function(start, inner, end) { //handle no-breaking space
+ var peg$f30 = function() { return "groupBy"; };
+ var peg$f31 = function() { return "feed"; };
+ var peg$f32 = function() { return "eq"; };
+ var peg$f33 = function() { return "neq"; };
+ var peg$f34 = function() { return "gte"; };
+ var peg$f35 = function() { return "gt"; };
+ var peg$f36 = function() { return "lte"; };
+ var peg$f37 = function() { return "lt"; };
+ var peg$f38 = function(start, inner, end) { //handle no-breaking space
return [...start, '"', ...inner, '"', ...end].join("");
};
- var peg$f38 = function(chars) { return chars.join("").trim(); };
- var peg$f39 = function() { return "and"; };
+ var peg$f39 = function(chars) { return chars.join("").trim(); };
+ var peg$f40 = function() { return "and"; };
var peg$currPos = options.peg$currPos | 0;
var peg$savedPos = peg$currPos;
var peg$posDetailsCache = [{ line: 1, column: 1 }];
@@ -665,6 +670,9 @@ function peg$parse(input, options) {
s1 = peg$parsecardID();
if (s1 === peg$FAILED) {
s1 = peg$parsefeed();
+ if (s1 === peg$FAILED) {
+ s1 = peg$parsegroupBy();
+ }
}
}
}
@@ -1307,20 +1315,49 @@ function peg$parse(input, options) {
return s0;
}
+ function peg$parsegroupBy() {
+ var s0, s1;
+
+ s0 = input.substr(peg$currPos, 7);
+ if (s0.toLowerCase() === peg$c32) {
+ peg$currPos += 7;
+ } else {
+ s0 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e33); }
+ }
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ s1 = input.substr(peg$currPos, 8);
+ if (s1.toLowerCase() === peg$c33) {
+ peg$currPos += 8;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e34); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f30();
+ }
+ s0 = s1;
+ }
+
+ return s0;
+ }
+
function peg$parsefeed() {
var s0, s1;
s0 = peg$currPos;
s1 = input.substr(peg$currPos, 4);
- if (s1.toLowerCase() === peg$c32) {
+ if (s1.toLowerCase() === peg$c34) {
peg$currPos += 4;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e33); }
+ if (peg$silentFails === 0) { peg$fail(peg$e35); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
- s1 = peg$f30();
+ s1 = peg$f31();
}
s0 = s1;
@@ -1337,81 +1374,81 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e35); }
+ if (peg$silentFails === 0) { peg$fail(peg$e37); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
- s1 = peg$f31();
+ s1 = peg$f32();
}
s0 = s1;
if (s0 === peg$FAILED) {
s0 = peg$currPos;
- if (input.substr(peg$currPos, 2) === peg$c33) {
- s1 = peg$c33;
+ if (input.substr(peg$currPos, 2) === peg$c35) {
+ s1 = peg$c35;
peg$currPos += 2;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e36); }
+ if (peg$silentFails === 0) { peg$fail(peg$e38); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
- s1 = peg$f32();
+ s1 = peg$f33();
}
s0 = s1;
if (s0 === peg$FAILED) {
s0 = peg$currPos;
- if (input.substr(peg$currPos, 2) === peg$c34) {
- s1 = peg$c34;
+ if (input.substr(peg$currPos, 2) === peg$c36) {
+ s1 = peg$c36;
peg$currPos += 2;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e37); }
+ if (peg$silentFails === 0) { peg$fail(peg$e39); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
- s1 = peg$f33();
+ s1 = peg$f34();
}
s0 = s1;
if (s0 === peg$FAILED) {
s0 = peg$currPos;
if (input.charCodeAt(peg$currPos) === 62) {
- s1 = peg$c35;
+ s1 = peg$c37;
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e38); }
+ if (peg$silentFails === 0) { peg$fail(peg$e40); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
- s1 = peg$f34();
+ s1 = peg$f35();
}
s0 = s1;
if (s0 === peg$FAILED) {
s0 = peg$currPos;
- if (input.substr(peg$currPos, 2) === peg$c36) {
- s1 = peg$c36;
+ if (input.substr(peg$currPos, 2) === peg$c38) {
+ s1 = peg$c38;
peg$currPos += 2;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e39); }
+ if (peg$silentFails === 0) { peg$fail(peg$e41); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
- s1 = peg$f35();
+ s1 = peg$f36();
}
s0 = s1;
if (s0 === peg$FAILED) {
s0 = peg$currPos;
if (input.charCodeAt(peg$currPos) === 60) {
- s1 = peg$c37;
+ s1 = peg$c39;
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e40); }
+ if (peg$silentFails === 0) { peg$fail(peg$e42); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
- s1 = peg$f36();
+ s1 = peg$f37();
}
s0 = s1;
}
@@ -1422,7 +1459,7 @@ function peg$parse(input, options) {
peg$silentFails--;
if (s0 === peg$FAILED) {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e34); }
+ if (peg$silentFails === 0) { peg$fail(peg$e36); }
}
return s0;
@@ -1439,7 +1476,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e42); }
+ if (peg$silentFails === 0) { peg$fail(peg$e44); }
}
while (s2 !== peg$FAILED) {
s1.push(s2);
@@ -1448,7 +1485,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e42); }
+ if (peg$silentFails === 0) { peg$fail(peg$e44); }
}
}
s2 = input.charAt(peg$currPos);
@@ -1456,7 +1493,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e43); }
+ if (peg$silentFails === 0) { peg$fail(peg$e45); }
}
if (s2 !== peg$FAILED) {
s3 = [];
@@ -1465,7 +1502,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s4 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e44); }
+ if (peg$silentFails === 0) { peg$fail(peg$e46); }
}
while (s4 !== peg$FAILED) {
s3.push(s4);
@@ -1474,7 +1511,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s4 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e44); }
+ if (peg$silentFails === 0) { peg$fail(peg$e46); }
}
}
s4 = input.charAt(peg$currPos);
@@ -1482,7 +1519,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s4 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e43); }
+ if (peg$silentFails === 0) { peg$fail(peg$e45); }
}
if (s4 !== peg$FAILED) {
s5 = [];
@@ -1491,7 +1528,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s6 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e45); }
+ if (peg$silentFails === 0) { peg$fail(peg$e47); }
}
while (s6 !== peg$FAILED) {
s5.push(s6);
@@ -1500,11 +1537,11 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s6 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e45); }
+ if (peg$silentFails === 0) { peg$fail(peg$e47); }
}
}
peg$savedPos = s0;
- s0 = peg$f37(s1, s3, s5);
+ s0 = peg$f38(s1, s3, s5);
} else {
peg$currPos = s0;
s0 = peg$FAILED;
@@ -1516,7 +1553,7 @@ function peg$parse(input, options) {
peg$silentFails--;
if (s0 === peg$FAILED) {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e41); }
+ if (peg$silentFails === 0) { peg$fail(peg$e43); }
}
return s0;
@@ -1533,7 +1570,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e45); }
+ if (peg$silentFails === 0) { peg$fail(peg$e47); }
}
if (s2 !== peg$FAILED) {
while (s2 !== peg$FAILED) {
@@ -1543,7 +1580,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e45); }
+ if (peg$silentFails === 0) { peg$fail(peg$e47); }
}
}
} else {
@@ -1551,13 +1588,13 @@ function peg$parse(input, options) {
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
- s1 = peg$f38(s1);
+ s1 = peg$f39(s1);
}
s0 = s1;
peg$silentFails--;
if (s0 === peg$FAILED) {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e46); }
+ if (peg$silentFails === 0) { peg$fail(peg$e48); }
}
return s0;
@@ -1569,7 +1606,7 @@ function peg$parse(input, options) {
s0 = peg$currPos;
s1 = peg$parse_();
peg$savedPos = s0;
- s1 = peg$f39();
+ s1 = peg$f40();
s0 = s1;
return s0;
@@ -1585,7 +1622,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e48); }
+ if (peg$silentFails === 0) { peg$fail(peg$e50); }
}
while (s1 !== peg$FAILED) {
s0.push(s1);
@@ -1594,12 +1631,12 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e48); }
+ if (peg$silentFails === 0) { peg$fail(peg$e50); }
}
}
peg$silentFails--;
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e47); }
+ if (peg$silentFails === 0) { peg$fail(peg$e49); }
return s0;
}
diff --git a/src/libs/SearchParser/autocompleteParser.peggy b/src/libs/SearchParser/autocompleteParser.peggy
index ddb472d2e2cc..8ed9e00c6869 100644
--- a/src/libs/SearchParser/autocompleteParser.peggy
+++ b/src/libs/SearchParser/autocompleteParser.peggy
@@ -67,6 +67,7 @@ autocompleteKey "key"
/ status
/ cardID
/ feed
+ / groupBy
)
identifier
diff --git a/src/libs/SearchParser/baseRules.peggy b/src/libs/SearchParser/baseRules.peggy
index c6f9bdbebebd..d141746e7d19 100644
--- a/src/libs/SearchParser/baseRules.peggy
+++ b/src/libs/SearchParser/baseRules.peggy
@@ -46,6 +46,7 @@ approved = "approved"i { return "approved"; }
paid = "paid"i { return "paid"; }
exported = "exported"i { return "exported"; }
posted = "posted"i { return "posted"; }
+groupBy = "groupBy"i / "group-by"i { return "groupBy"; }
feed = "feed"i { return "feed"; }
operator "operator"
diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js
index cedcd1af5991..446554bb4bc9 100644
--- a/src/libs/SearchParser/searchParser.js
+++ b/src/libs/SearchParser/searchParser.js
@@ -216,12 +216,14 @@ function peg$parse(input, options) {
var peg$c29 = "paid";
var peg$c30 = "exported";
var peg$c31 = "posted";
- var peg$c32 = "feed";
- var peg$c33 = "!=";
- var peg$c34 = ">=";
- var peg$c35 = ">";
- var peg$c36 = "<=";
- var peg$c37 = "<";
+ var peg$c32 = "groupby";
+ var peg$c33 = "group-by";
+ var peg$c34 = "feed";
+ var peg$c35 = "!=";
+ var peg$c36 = ">=";
+ var peg$c37 = ">";
+ var peg$c38 = "<=";
+ var peg$c39 = "<";
var peg$r0 = /^[^ \t\r\n\xA0]/;
var peg$r1 = /^[:=]/;
@@ -266,22 +268,24 @@ function peg$parse(input, options) {
var peg$e32 = peg$literalExpectation("paid", true);
var peg$e33 = peg$literalExpectation("exported", true);
var peg$e34 = peg$literalExpectation("posted", true);
- var peg$e35 = peg$literalExpectation("feed", true);
- var peg$e36 = peg$otherExpectation("operator");
- var peg$e37 = peg$classExpectation([":", "="], false, false);
- var peg$e38 = peg$literalExpectation("!=", false);
- var peg$e39 = peg$literalExpectation(">=", false);
- var peg$e40 = peg$literalExpectation(">", false);
- var peg$e41 = peg$literalExpectation("<=", false);
- var peg$e42 = peg$literalExpectation("<", false);
- var peg$e43 = peg$otherExpectation("quote");
- var peg$e44 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false);
- var peg$e45 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false);
- var peg$e46 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false);
- var peg$e47 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false);
- var peg$e48 = peg$otherExpectation("word");
- var peg$e49 = peg$otherExpectation("whitespace");
- var peg$e50 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false);
+ var peg$e35 = peg$literalExpectation("groupBy", true);
+ var peg$e36 = peg$literalExpectation("group-by", true);
+ var peg$e37 = peg$literalExpectation("feed", true);
+ var peg$e38 = peg$otherExpectation("operator");
+ var peg$e39 = peg$classExpectation([":", "="], false, false);
+ var peg$e40 = peg$literalExpectation("!=", false);
+ var peg$e41 = peg$literalExpectation(">=", false);
+ var peg$e42 = peg$literalExpectation(">", false);
+ var peg$e43 = peg$literalExpectation("<=", false);
+ var peg$e44 = peg$literalExpectation("<", false);
+ var peg$e45 = peg$otherExpectation("quote");
+ var peg$e46 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false);
+ var peg$e47 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false);
+ var peg$e48 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false);
+ var peg$e49 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false);
+ var peg$e50 = peg$otherExpectation("word");
+ var peg$e51 = peg$otherExpectation("whitespace");
+ var peg$e52 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false);
var peg$f0 = function(filters) { return applyDefaults(filters); };
var peg$f1 = function(head, tail) {
@@ -368,18 +372,19 @@ function peg$parse(input, options) {
var peg$f28 = function() { return "paid"; };
var peg$f29 = function() { return "exported"; };
var peg$f30 = function() { return "posted"; };
- var peg$f31 = function() { return "feed"; };
- var peg$f32 = function() { return "eq"; };
- var peg$f33 = function() { return "neq"; };
- var peg$f34 = function() { return "gte"; };
- var peg$f35 = function() { return "gt"; };
- var peg$f36 = function() { return "lte"; };
- var peg$f37 = function() { return "lt"; };
- var peg$f38 = function(start, inner, end) { //handle no-breaking space
+ var peg$f31 = function() { return "groupBy"; };
+ var peg$f32 = function() { return "feed"; };
+ var peg$f33 = function() { return "eq"; };
+ var peg$f34 = function() { return "neq"; };
+ var peg$f35 = function() { return "gte"; };
+ var peg$f36 = function() { return "gt"; };
+ var peg$f37 = function() { return "lte"; };
+ var peg$f38 = function() { return "lt"; };
+ var peg$f39 = function(start, inner, end) { //handle no-breaking space
return [...start, '"', ...inner, '"', ...end].join("");
};
- var peg$f39 = function(chars) { return chars.join("").trim(); };
- var peg$f40 = function() { return "and"; };
+ var peg$f40 = function(chars) { return chars.join("").trim(); };
+ var peg$f41 = function() { return "and"; };
var peg$currPos = options.peg$currPos | 0;
var peg$savedPos = peg$currPos;
var peg$posDetailsCache = [{ line: 1, column: 1 }];
@@ -818,6 +823,9 @@ function peg$parse(input, options) {
s1 = peg$parsesortOrder();
if (s1 === peg$FAILED) {
s1 = peg$parsepolicyID();
+ if (s1 === peg$FAILED) {
+ s1 = peg$parsegroupBy();
+ }
}
}
}
@@ -1508,20 +1516,49 @@ function peg$parse(input, options) {
return s0;
}
+ function peg$parsegroupBy() {
+ var s0, s1;
+
+ s0 = input.substr(peg$currPos, 7);
+ if (s0.toLowerCase() === peg$c32) {
+ peg$currPos += 7;
+ } else {
+ s0 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e35); }
+ }
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ s1 = input.substr(peg$currPos, 8);
+ if (s1.toLowerCase() === peg$c33) {
+ peg$currPos += 8;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e36); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f31();
+ }
+ s0 = s1;
+ }
+
+ return s0;
+ }
+
function peg$parsefeed() {
var s0, s1;
s0 = peg$currPos;
s1 = input.substr(peg$currPos, 4);
- if (s1.toLowerCase() === peg$c32) {
+ if (s1.toLowerCase() === peg$c34) {
peg$currPos += 4;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e35); }
+ if (peg$silentFails === 0) { peg$fail(peg$e37); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
- s1 = peg$f31();
+ s1 = peg$f32();
}
s0 = s1;
@@ -1538,81 +1575,81 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e37); }
+ if (peg$silentFails === 0) { peg$fail(peg$e39); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
- s1 = peg$f32();
+ s1 = peg$f33();
}
s0 = s1;
if (s0 === peg$FAILED) {
s0 = peg$currPos;
- if (input.substr(peg$currPos, 2) === peg$c33) {
- s1 = peg$c33;
+ if (input.substr(peg$currPos, 2) === peg$c35) {
+ s1 = peg$c35;
peg$currPos += 2;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e38); }
+ if (peg$silentFails === 0) { peg$fail(peg$e40); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
- s1 = peg$f33();
+ s1 = peg$f34();
}
s0 = s1;
if (s0 === peg$FAILED) {
s0 = peg$currPos;
- if (input.substr(peg$currPos, 2) === peg$c34) {
- s1 = peg$c34;
+ if (input.substr(peg$currPos, 2) === peg$c36) {
+ s1 = peg$c36;
peg$currPos += 2;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e39); }
+ if (peg$silentFails === 0) { peg$fail(peg$e41); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
- s1 = peg$f34();
+ s1 = peg$f35();
}
s0 = s1;
if (s0 === peg$FAILED) {
s0 = peg$currPos;
if (input.charCodeAt(peg$currPos) === 62) {
- s1 = peg$c35;
+ s1 = peg$c37;
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e40); }
+ if (peg$silentFails === 0) { peg$fail(peg$e42); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
- s1 = peg$f35();
+ s1 = peg$f36();
}
s0 = s1;
if (s0 === peg$FAILED) {
s0 = peg$currPos;
- if (input.substr(peg$currPos, 2) === peg$c36) {
- s1 = peg$c36;
+ if (input.substr(peg$currPos, 2) === peg$c38) {
+ s1 = peg$c38;
peg$currPos += 2;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e41); }
+ if (peg$silentFails === 0) { peg$fail(peg$e43); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
- s1 = peg$f36();
+ s1 = peg$f37();
}
s0 = s1;
if (s0 === peg$FAILED) {
s0 = peg$currPos;
if (input.charCodeAt(peg$currPos) === 60) {
- s1 = peg$c37;
+ s1 = peg$c39;
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e42); }
+ if (peg$silentFails === 0) { peg$fail(peg$e44); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
- s1 = peg$f37();
+ s1 = peg$f38();
}
s0 = s1;
}
@@ -1623,7 +1660,7 @@ function peg$parse(input, options) {
peg$silentFails--;
if (s0 === peg$FAILED) {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e36); }
+ if (peg$silentFails === 0) { peg$fail(peg$e38); }
}
return s0;
@@ -1640,7 +1677,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e44); }
+ if (peg$silentFails === 0) { peg$fail(peg$e46); }
}
while (s2 !== peg$FAILED) {
s1.push(s2);
@@ -1649,7 +1686,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e44); }
+ if (peg$silentFails === 0) { peg$fail(peg$e46); }
}
}
s2 = input.charAt(peg$currPos);
@@ -1657,7 +1694,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e45); }
+ if (peg$silentFails === 0) { peg$fail(peg$e47); }
}
if (s2 !== peg$FAILED) {
s3 = [];
@@ -1666,7 +1703,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s4 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e46); }
+ if (peg$silentFails === 0) { peg$fail(peg$e48); }
}
while (s4 !== peg$FAILED) {
s3.push(s4);
@@ -1675,7 +1712,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s4 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e46); }
+ if (peg$silentFails === 0) { peg$fail(peg$e48); }
}
}
s4 = input.charAt(peg$currPos);
@@ -1683,7 +1720,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s4 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e45); }
+ if (peg$silentFails === 0) { peg$fail(peg$e47); }
}
if (s4 !== peg$FAILED) {
s5 = [];
@@ -1692,7 +1729,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s6 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e47); }
+ if (peg$silentFails === 0) { peg$fail(peg$e49); }
}
while (s6 !== peg$FAILED) {
s5.push(s6);
@@ -1701,11 +1738,11 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s6 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e47); }
+ if (peg$silentFails === 0) { peg$fail(peg$e49); }
}
}
peg$savedPos = s0;
- s0 = peg$f38(s1, s3, s5);
+ s0 = peg$f39(s1, s3, s5);
} else {
peg$currPos = s0;
s0 = peg$FAILED;
@@ -1717,7 +1754,7 @@ function peg$parse(input, options) {
peg$silentFails--;
if (s0 === peg$FAILED) {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e43); }
+ if (peg$silentFails === 0) { peg$fail(peg$e45); }
}
return s0;
@@ -1734,7 +1771,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e47); }
+ if (peg$silentFails === 0) { peg$fail(peg$e49); }
}
if (s2 !== peg$FAILED) {
while (s2 !== peg$FAILED) {
@@ -1744,7 +1781,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e47); }
+ if (peg$silentFails === 0) { peg$fail(peg$e49); }
}
}
} else {
@@ -1752,13 +1789,13 @@ function peg$parse(input, options) {
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
- s1 = peg$f39(s1);
+ s1 = peg$f40(s1);
}
s0 = s1;
peg$silentFails--;
if (s0 === peg$FAILED) {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e48); }
+ if (peg$silentFails === 0) { peg$fail(peg$e50); }
}
return s0;
@@ -1770,7 +1807,7 @@ function peg$parse(input, options) {
s0 = peg$currPos;
s1 = peg$parse_();
peg$savedPos = s0;
- s1 = peg$f40();
+ s1 = peg$f41();
s0 = s1;
return s0;
@@ -1786,7 +1823,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e50); }
+ if (peg$silentFails === 0) { peg$fail(peg$e52); }
}
while (s1 !== peg$FAILED) {
s0.push(s1);
@@ -1795,12 +1832,12 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e50); }
+ if (peg$silentFails === 0) { peg$fail(peg$e52); }
}
}
peg$silentFails--;
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e49); }
+ if (peg$silentFails === 0) { peg$fail(peg$e51); }
return s0;
}
diff --git a/src/libs/SearchParser/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy
index 987c31d38372..f8bf86d129e4 100644
--- a/src/libs/SearchParser/searchParser.peggy
+++ b/src/libs/SearchParser/searchParser.peggy
@@ -131,7 +131,7 @@ key "key"
/ feed
)
-defaultKey "default key" = @(type / status / sortBy / sortOrder / policyID)
+defaultKey "default key" = @(type / status / sortBy / sortOrder / policyID / groupBy)
identifier
= (","+)? parts:(quotedString / alphanumeric)|1.., ","+| empty:(","+)? {
diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts
index b0a2e83bcfc9..a7a71d15eebc 100644
--- a/src/libs/SearchQueryUtils.ts
+++ b/src/libs/SearchQueryUtils.ts
@@ -66,6 +66,7 @@ const UserFriendlyKeyMap: Record | nul
type: CONST.SEARCH.DATA_TYPES.EXPENSE,
icon: Expensicons.Document,
getRoute: (policyID?: string) => {
- const query = buildCannedSearchQuery({policyID});
- return ROUTES.SEARCH_ROOT.getRoute({query, groupBy: 'reports'});
+ const query = buildCannedSearchQuery({groupBy: CONST.SEARCH.GROUP_BY.REPORTS, policyID});
+ return ROUTES.SEARCH_ROOT.getRoute({query});
},
},
{
diff --git a/src/libs/SidePaneUtils.ts b/src/libs/SidePaneUtils.ts
index 3c59fc3940d8..1b9cd04cdc4c 100644
--- a/src/libs/SidePaneUtils.ts
+++ b/src/libs/SidePaneUtils.ts
@@ -6,7 +6,7 @@
* params: {workspaceID: '123', ruleID: '456'}
* result: /workspaces/:workspaceID/rules/:ruleID
*/
-function substituteRouteParameters(route: string, params: Record) {
+function substituteRouteParameters(route: string, params: Record): string {
let updatedRoute = route;
function searchAndReplace(obj: Record) {
@@ -19,14 +19,14 @@ function substituteRouteParameters(route: string, params: Record);
- } else if (typeof value === 'string' && route.includes(value)) {
- updatedRoute = updatedRoute.replace(value, `:${key}`);
+ } else if (typeof value === 'string') {
+ const regex = new RegExp(`\\b${value}\\b`, 'g');
+ updatedRoute = updatedRoute.replace(regex, `:${key}`);
}
}
}
searchAndReplace(params);
-
return updatedRoute;
}
diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts
index f8374bfa358e..771309fc4195 100644
--- a/src/libs/TransactionUtils/index.ts
+++ b/src/libs/TransactionUtils/index.ts
@@ -8,7 +8,7 @@ import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import {getPolicyCategoriesData} from '@libs/actions/Policy/Category';
import {getPolicyTagsData} from '@libs/actions/Policy/Tag';
-import type {TransactionMergeParams} from '@libs/API/parameters';
+import type {MergeDuplicatesParams} from '@libs/API/parameters';
import {getCategoryDefaultTaxRate} from '@libs/CategoryUtils';
import {convertToBackendAmount, getCurrencyDecimals} from '@libs/CurrencyUtils';
import DateUtils from '@libs/DateUtils';
@@ -1402,7 +1402,7 @@ function buildNewTransactionAfterReviewingDuplicates(reviewDuplicateTransaction:
};
}
-function buildTransactionsMergeParams(reviewDuplicates: OnyxEntry, originalTransaction: Partial): TransactionMergeParams {
+function buildMergeDuplicatesParams(reviewDuplicates: OnyxEntry, originalTransaction: Partial): MergeDuplicatesParams {
return {
amount: -getAmount(originalTransaction as OnyxEntry, true),
reportID: originalTransaction?.reportID,
@@ -1535,7 +1535,7 @@ export {
compareDuplicateTransactionFields,
getTransactionID,
buildNewTransactionAfterReviewingDuplicates,
- buildTransactionsMergeParams,
+ buildMergeDuplicatesParams,
getReimbursable,
isPayAtEndExpense,
removeSettledAndApprovedTransactions,
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index b05b1b6e786e..bedaebe90564 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -15,6 +15,7 @@ import type {
CreateWorkspaceParams,
DeleteMoneyRequestParams,
DetachReceiptParams,
+ MergeDuplicatesParams,
PayInvoiceParams,
PayMoneyRequestParams,
ReplaceReceiptParams,
@@ -28,7 +29,6 @@ import type {
StartSplitBillParams,
SubmitReportParams,
TrackExpenseParams,
- TransactionMergeParams,
UnapproveExpenseReportParams,
UpdateMoneyRequestParams,
} from '@libs/API/parameters';
@@ -95,6 +95,7 @@ import {
buildOptimisticMoneyRequestEntities,
buildOptimisticMovedTrackedExpenseModifiedReportAction,
buildOptimisticReportPreview,
+ buildOptimisticResolvedDuplicatesReportAction,
buildOptimisticSubmittedReportAction,
buildOptimisticUnapprovedReportAction,
buildOptimisticUnHoldReportAction,
@@ -197,6 +198,20 @@ type IOURequestType = ValueOf;
type OneOnOneIOUReport = OnyxTypes.Report | undefined | null;
+type BaseTransactionParams = {
+ amount: number;
+ currency: string;
+ created: string;
+ merchant: string;
+ comment: string;
+ category?: string;
+ tag?: string;
+ taxCode?: string;
+ taxAmount?: number;
+ billable?: boolean;
+ customUnitRateID?: string;
+};
+
type MoneyRequestInformation = {
payerAccountID: number;
payerEmail: string;
@@ -227,22 +242,15 @@ type TrackExpenseInformation = {
actionableWhisperReportActionIDParam?: string;
onyxData: OnyxData;
};
-type TrackedExpenseTransactionParams = {
+
+type TrackedExpenseTransactionParams = Omit & {
+ waypoints?: string;
transactionID: string | undefined;
- amount: number;
- currency: string;
- comment: string;
- merchant: string;
- created: string;
+ receipt?: Receipt;
taxCode: string;
taxAmount: number;
- category?: string;
- tag?: string;
- billable?: boolean;
- receipt?: Receipt;
- waypoints?: string;
- customUnitRateID?: string;
};
+
type TrackedExpensePolicyParams = {
policyID: string | undefined;
isDraftPolicy?: boolean;
@@ -319,34 +327,19 @@ type GPSPoint = {
long: number;
};
-type RequestMoneyTransactionParams = {
+type RequestMoneyTransactionParams = Omit & {
attendees?: Attendee[];
- amount: number;
- currency: string;
- comment?: string;
- receipt?: Receipt;
- category?: string;
- tag?: string;
- taxCode?: string;
- taxAmount?: number;
- billable?: boolean;
- merchant: string;
- created: string;
actionableWhisperReportActionID?: string;
linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction;
linkedTrackedExpenseReportID?: string;
+ receipt?: Receipt;
waypoints?: WaypointCollection;
- customUnitRateID?: string;
+ comment?: string;
};
-type PerDiemExpenseTransactionParams = {
- currency: string;
- comment?: string;
- category?: string;
- tag?: string;
- created: string;
+type PerDiemExpenseTransactionParams = Omit & {
customUnit: TransactionCustomUnit;
- billable?: boolean;
+ comment?: string;
};
type BasePolicyParams = {
@@ -431,21 +424,11 @@ type BuildOnyxDataForMoneyRequestParams = {
optimisticParams: MoneyRequestOptimisticParams;
};
-type DistanceRequestTransactionParams = {
- comment: string;
- created: string;
- category?: string;
- tag?: string;
- taxCode?: string;
- taxAmount?: number;
- amount: number;
- currency: string;
- merchant: string;
- billable?: boolean;
+type DistanceRequestTransactionParams = BaseTransactionParams & {
validWaypoints: WaypointCollection;
- customUnitRateID?: string;
splitShares?: SplitShares;
};
+
type CreateDistanceRequestInformation = {
report: OnyxEntry;
participants: Participant[];
@@ -457,19 +440,9 @@ type CreateDistanceRequestInformation = {
policyParams?: BasePolicyParams;
};
-type CreateSplitsTransactionParams = {
- amount: number;
- comment: string;
- currency: string;
- merchant: string;
- created: string;
- category: string;
- tag: string;
+type CreateSplitsTransactionParams = Omit & {
splitShares: SplitShares;
- billable?: boolean;
iouRequestType?: IOURequestType;
- taxCode?: string;
- taxAmount?: number;
};
type CreateSplitsAndOnyxDataParams = {
@@ -9766,7 +9739,7 @@ function getIOUActionForTransactions(transactionIDList: Array (allReportDraftComments = value),
});
+let nvpDismissedProductTraining: OnyxEntry;
+Onyx.connect({
+ key: ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING,
+ callback: (value) => (nvpDismissedProductTraining = value),
+});
+
let environmentURL: string;
Environment.getEnvironmentURL().then((url: string) => (environmentURL = url));
@@ -2404,6 +2421,153 @@ function navigateToConciergeChat(shouldDismissModal = false, checkIfCurrentPageA
}
}
+function buildNewReportOptimisticData(policy: OnyxEntry, reportID: string, reportActionID: string, reportName: string, creatorPersonalDetails: PersonalDetails) {
+ const {accountID, login} = creatorPersonalDetails;
+ const parentReport = getPolicyExpenseChat(accountID, policy?.id);
+ const {stateNum, statusNum} = getExpenseReportStateAndStatus(policy);
+ const timeOfCreation = DateUtils.getDBTime();
+ const reportPreviewActionID = rand64();
+
+ const optimisticDataValue: OptimisticNewReport = {
+ reportID,
+ policyID: policy?.id,
+ type: CONST.REPORT.TYPE.EXPENSE,
+ ownerAccountID: accountID,
+ reportName,
+ stateNum,
+ statusNum,
+ total: 0,
+ nonReimbursableTotal: 0,
+ participants: {},
+ lastVisibleActionCreated: timeOfCreation,
+ };
+
+ if (accountID) {
+ optimisticDataValue.participants = {
+ [accountID]: {
+ notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN,
+ },
+ };
+ optimisticDataValue.ownerAccountID = accountID;
+ }
+
+ const optimisticCreateAction = {
+ action: CONST.REPORT.ACTIONS.TYPE.CREATED,
+ accountEmail: login,
+ accountID,
+ created: timeOfCreation,
+ message: {
+ isNewDot: true,
+ lastModified: timeOfCreation,
+ },
+ reportActionID,
+ reportID,
+ sequenceNumber: 0,
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ };
+
+ const createReportActionMessage = [
+ {
+ html: `${policy?.name} owes ${policy?.outputCurrency} 0.00`,
+ text: `${policy?.name} owes ${policy?.outputCurrency} 0.00`,
+ type: CONST.REPORT.MESSAGE.TYPE.COMMENT,
+ },
+ ];
+
+ const optimisticReportPreview = {
+ action: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW,
+ actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW,
+ childReportName: reportName,
+ childReportID: reportID,
+ childType: CONST.REPORT.TYPE.EXPENSE,
+ created: timeOfCreation,
+ shouldShow: true,
+ actorAccountID: accountID,
+ automatic: false,
+ avatar: creatorPersonalDetails.avatar,
+ isAttachmentOnly: false,
+ reportActionID: reportPreviewActionID,
+ message: createReportActionMessage,
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ };
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: optimisticDataValue,
+ },
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
+ value: {[reportActionID]: optimisticCreateAction},
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReport?.reportID}`,
+ value: {[reportActionID]: optimisticReportPreview},
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {errorFields: {create: getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage')}},
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
+ value: {[reportActionID]: {errorFields: {create: getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage')}}},
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {
+ pendingAction: null,
+ errorFields: null,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
+ value: {
+ [reportActionID]: {
+ pendingAction: null,
+ errorFields: null,
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReport?.reportID}`,
+ value: {[reportActionID]: {pendingAction: null}},
+ },
+ ];
+
+ return {optimisticData, successData, failureData};
+}
+
+function createNewReport(creatorPersonalDetails: PersonalDetails, policyID?: string) {
+ const policy = getPolicy(policyID);
+ const optimisticReportID = generateReportID();
+ const reportActionID = rand64();
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const reportName = `${creatorPersonalDetails.firstName || 'User'}'s report`;
+
+ const {optimisticData, successData, failureData} = buildNewReportOptimisticData(policy, optimisticReportID, reportActionID, reportName, creatorPersonalDetails);
+
+ API.write(
+ WRITE_COMMANDS.CREATE_APP_REPORT,
+ {reportName, type: CONST.REPORT.TYPE.EXPENSE, policyID, reportID: optimisticReportID, reportActionID},
+ {optimisticData, successData, failureData},
+ );
+ return optimisticReportID;
+}
+
/** Add a policy report (workspace room) optimistically and navigate to it. */
function addPolicyReport(policyReport: OptimisticChatReport) {
const createdReportAction = buildOptimisticCreatedReportAction(CONST.POLICY.OWNER_EMAIL_FAKE);
@@ -2961,7 +3125,7 @@ function openReportFromDeepLink(url: string) {
// Check if the report exists in the collection
const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
// If the report does not exist, navigate to the last accessed report or Concierge chat
- if (!report) {
+ if (reportID && !report) {
const lastAccessedReportID = findLastAccessedReport(false, shouldOpenOnAdminRoom(), undefined, reportID)?.reportID;
if (lastAccessedReportID) {
const lastAccessedReportRoute = ROUTES.REPORT_WITH_ID.getRoute(lastAccessedReportID);
@@ -4698,6 +4862,228 @@ function clearDeleteTransactionNavigateBackUrl() {
Onyx.merge(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL, null);
}
+/**
+ * Dismisses the change report policy educational modal so that it doesn't show up again.
+ */
+function dismissChangePolicyModal() {
+ const date = new Date();
+ const optimisticData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING,
+ value: {
+ [CONST.CHANGE_POLICY_TRAINING_MODAL]: DateUtils.getDBTime(date.valueOf()),
+ },
+ },
+ ];
+ API.write(WRITE_COMMANDS.DISMISS_PRODUCT_TRAINING, {name: CONST.CHANGE_POLICY_TRAINING_MODAL}, {optimisticData});
+}
+
+/**
+ * @private
+ * Builds a map of parentReportID to child report IDs for efficient traversal.
+ */
+function buildReportIDToThreadsReportIDsMap(): Record {
+ const reportIDToThreadsReportIDsMap: Record = {};
+ Object.values(allReports ?? {}).forEach((report) => {
+ if (!report?.parentReportID) {
+ return;
+ }
+ if (!reportIDToThreadsReportIDsMap[report.parentReportID]) {
+ reportIDToThreadsReportIDsMap[report.parentReportID] = [];
+ }
+ reportIDToThreadsReportIDsMap[report.parentReportID].push(report.reportID);
+ });
+ return reportIDToThreadsReportIDsMap;
+}
+
+/**
+ * @private
+ * Recursively updates the policyID for a report and all its child reports.
+ */
+function updatePolicyIdForReportAndThreads(
+ currentReportID: string,
+ policyID: string,
+ reportIDToThreadsReportIDsMap: Record,
+ optimisticData: OnyxUpdate[],
+ failureData: OnyxUpdate[],
+) {
+ const currentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`];
+ const originalPolicyID = currentReport?.policyID;
+
+ if (originalPolicyID) {
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`,
+ value: {policyID},
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`,
+ value: {policyID: originalPolicyID},
+ });
+ }
+
+ // Recursively process child reports for the current report
+ const childReportIDs = reportIDToThreadsReportIDsMap[currentReportID] || [];
+ childReportIDs.forEach((childReportID) => {
+ updatePolicyIdForReportAndThreads(childReportID, policyID, reportIDToThreadsReportIDsMap, optimisticData, failureData);
+ });
+}
+
+/**
+ * Changes the policy of a report and all its child reports, and moves the report to the new policy's workspace chat.
+ */
+function changeReportPolicy(reportID: string, policyID: string) {
+ if (!reportID || !policyID) {
+ return;
+ }
+ const reportToMove = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
+ if (!reportToMove || reportToMove?.policyID === policyID || !isExpenseReport(reportToMove)) {
+ return;
+ }
+
+ const optimisticData: OnyxUpdate[] = [];
+ const successData: OnyxUpdate[] = [];
+ const failureData: OnyxUpdate[] = [];
+
+ // 1. Optimistically set the policyID on the report (and all its threads)
+
+ // Preprocess reports to create a map of parentReportID to child reports list of reportIDs
+ const reportIDToThreadsReportIDsMap = buildReportIDToThreadsReportIDsMap();
+
+ // Recursively update the policyID of the report and all its child reports
+ updatePolicyIdForReportAndThreads(reportID, policyID, reportIDToThreadsReportIDsMap, optimisticData, failureData);
+
+ // 2. If the old workspace had a workspace chat, mark the report preview action as deleted
+ if (reportToMove?.parentReportID && reportToMove?.parentReportActionID) {
+ const oldReportPreviewAction = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportToMove?.parentReportID}`]?.[reportToMove?.parentReportActionID];
+ const deletedTime = DateUtils.getDBTime();
+ const firstMessage = Array.isArray(oldReportPreviewAction?.message) ? oldReportPreviewAction.message.at(0) : null;
+ const updatedReportPreviewAction = {
+ ...oldReportPreviewAction,
+ originalMessage: {
+ deleted: deletedTime,
+ },
+ ...(firstMessage && {
+ message: [
+ {
+ ...firstMessage,
+ deleted: deletedTime,
+ },
+ ...(Array.isArray(oldReportPreviewAction?.message) ? oldReportPreviewAction.message.slice(1) : []),
+ ],
+ }),
+ ...(!Array.isArray(oldReportPreviewAction?.message) && {
+ message: {
+ deleted: deletedTime,
+ },
+ }),
+ };
+
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportToMove?.parentReportID}`,
+ value: {[reportToMove?.parentReportActionID]: updatedReportPreviewAction},
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportToMove?.parentReportID}`,
+ value: {[reportToMove?.parentReportActionID]: oldReportPreviewAction},
+ });
+ }
+
+ // 3. Optimistically create a new REPORTPREVIEW reportAction with the newReportPreviewActionID
+ // and set it as a parent of the moved report
+ const policyExpenseChat = getPolicyExpenseChat(currentUserAccountID, policyID);
+ const optimisticReportPreviewAction = buildOptimisticReportPreview(policyExpenseChat, reportToMove);
+
+ if (policyExpenseChat) {
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${policyExpenseChat.reportID}`,
+ value: {[optimisticReportPreviewAction.reportActionID]: optimisticReportPreviewAction},
+ });
+ successData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${policyExpenseChat.reportID}`,
+ value: {
+ [optimisticReportPreviewAction.reportActionID]: {
+ pendingAction: null,
+ },
+ },
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${policyExpenseChat.reportID}`,
+ value: {[optimisticReportPreviewAction.reportActionID]: null},
+ });
+
+ // Set the new report preview action as a parent of the moved report,
+ // and set the parentReportID on the moved report as the workspace chat reportID
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {parentReportActionID: optimisticReportPreviewAction.reportActionID, parentReportID: policyExpenseChat.reportID},
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {parentReportActionID: reportToMove.parentReportActionID, parentReportID: reportToMove.parentReportID},
+ });
+
+ // Set lastVisibleActionCreated
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`,
+ value: {lastVisibleActionCreated: optimisticReportPreviewAction?.created},
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`,
+ value: {lastVisibleActionCreated: policyExpenseChat.lastVisibleActionCreated},
+ });
+ }
+
+ // 4. Optimistically create a CHANGEPOLICY reportAction on the report using the reportActionID
+ const optimisticMovedReportAction = buildOptimisticChangePolicyReportAction(reportToMove.policyID, policyID);
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportToMove.reportID}`,
+ value: {[optimisticMovedReportAction.reportActionID]: optimisticMovedReportAction},
+ });
+ successData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportToMove.reportID}`,
+ value: {
+ [optimisticMovedReportAction.reportActionID]: {
+ ...optimisticMovedReportAction,
+ pendingAction: null,
+ },
+ },
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportToMove.reportID}`,
+ value: {[optimisticMovedReportAction.reportActionID]: null},
+ });
+
+ // Call the ChangeReportPolicy API endpoint
+ const params = {
+ reportID: reportToMove.reportID,
+ policyID,
+ reportPreviewReportActionID: optimisticReportPreviewAction.reportActionID,
+ changePolicyReportActionID: optimisticMovedReportAction.reportActionID,
+ };
+ API.write(WRITE_COMMANDS.CHANGE_REPORT_POLICY, params, {optimisticData, successData, failureData});
+
+ // 5. If the dismissedProductTraining.changeReportModal is not set,
+ // navigate to CHANGE_POLICY_EDUCATIONAL and a backTo param for the report page.
+ if (!nvpDismissedProductTraining?.[CONST.CHANGE_POLICY_TRAINING_MODAL]) {
+ Navigation.navigate(ROUTES.CHANGE_POLICY_EDUCATIONAL.getRoute(ROUTES.REPORT_WITH_ID.getRoute(reportToMove.reportID)));
+ }
+}
+
export type {Video};
export {
@@ -4716,6 +5102,7 @@ export {
clearPrivateNotesError,
clearReportFieldKeyErrors,
completeOnboarding,
+ createNewReport,
deleteReport,
deleteReportActionDraft,
deleteReportComment,
@@ -4793,4 +5180,6 @@ export {
updateRoomVisibility,
updateWriteCapability,
prepareOnboardingOnyxData,
+ dismissChangePolicyModal,
+ changeReportPolicy,
};
diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts
index 13a5876b381f..d193d786baee 100644
--- a/src/libs/actions/Session/index.ts
+++ b/src/libs/actions/Session/index.ts
@@ -223,7 +223,7 @@ function isExpiredSession(sessionCreationDate: number): boolean {
return new Date().getTime() - sessionCreationDate >= CONST.SESSION_EXPIRATION_TIME_MS;
}
-function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSession?: boolean, killHybridApp = true) {
+function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSession?: boolean, killHybridApp = true, shouldForceUseStashedSession?: boolean) {
Log.info('Redirecting to Sign In because signOut() was called');
hideContextMenu(false);
@@ -276,9 +276,10 @@ function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSess
[ONYXKEYS.STASHED_SESSION]: stashedSession,
};
}
- // Now if this is a supportal access, we do not want to stash the current session and we have a
+
+ // Now if this is a supportal access or force use stashed session, we do not want to stash the current session and we have a
// stashed session, then we need to restore the stashed session instead of completely logging out
- if (isSupportal && !shouldStashSession && hasStashedSession()) {
+ if ((isSupportal || shouldForceUseStashedSession) && !shouldStashSession && hasStashedSession()) {
onyxSetParams = {
[ONYXKEYS.CREDENTIALS]: stashedCredentials,
[ONYXKEYS.SESSION]: stashedSession,
@@ -291,13 +292,20 @@ function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSess
// Wait for signOut (if called), then redirect and update Onyx.
signOutPromise
.then((response) => {
- Onyx.multiSet(onyxSetParams);
-
if (response?.hasOldDotAuthCookies) {
Log.info('Redirecting to OldDot sign out');
- asyncOpenURL(redirectToSignIn(), `${CONFIG.EXPENSIFY.EXPENSIFY_URL}${CONST.OLDDOT_URLS.SIGN_OUT}`, true, true);
+ asyncOpenURL(
+ redirectToSignIn().then(() => {
+ Onyx.multiSet(onyxSetParams);
+ }),
+ `${CONFIG.EXPENSIFY.EXPENSIFY_URL}${CONST.OLDDOT_URLS.SIGN_OUT}`,
+ true,
+ true,
+ );
} else {
- redirectToSignIn();
+ redirectToSignIn().then(() => {
+ Onyx.multiSet(onyxSetParams);
+ });
}
})
.catch((error: string) => Log.warn('Error during sign out process:', error));
diff --git a/src/libs/actions/SidePane.ts b/src/libs/actions/SidePane.ts
index bda6a27a0b74..21331116b310 100644
--- a/src/libs/actions/SidePane.ts
+++ b/src/libs/actions/SidePane.ts
@@ -3,27 +3,22 @@ import Onyx from 'react-native-onyx';
import ONYXKEYS from '@src/ONYXKEYS';
type Options = {
- /** Whether to update the narrow layout along with the large screen layout */
- shouldUpdateNarrowLayout?: boolean;
+ /** Determines whether the side pane should be open or closed */
+ isOpen?: boolean;
- /** Whether to update only the narrow layout without affecting the large screen layout */
- shouldOnlyUpdateNarrowLayout?: boolean;
+ /** Determines whether the side pane should be open or closed on narrow screens */
+ isOpenNarrowScreen?: boolean;
};
-/**
- * Updates the side pane state in Onyx.
- *
- * @param isOpen - Determines whether the side pane should be open or closed.
- * @param [options] - Additional options for updating the layout.
- */
-function triggerSidePane(isOpen: boolean, {shouldUpdateNarrowLayout = false, shouldOnlyUpdateNarrowLayout = false}: Options = {}) {
+/** Updates the side pane state in Onyx */
+function triggerSidePane({isOpen, isOpenNarrowScreen}: Options) {
const value: OnyxMergeInput = {};
- if (!shouldOnlyUpdateNarrowLayout) {
+ if (isOpen !== undefined) {
value.open = isOpen;
}
- if (shouldUpdateNarrowLayout || shouldOnlyUpdateNarrowLayout) {
- value.openNarrowScreen = isOpen;
+ if (isOpenNarrowScreen !== undefined) {
+ value.openNarrowScreen = isOpenNarrowScreen;
}
Onyx.merge(ONYXKEYS.NVP_SIDE_PANE, value);
diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts
index 0c3fc2b251a2..d3282dc3a923 100644
--- a/src/libs/actions/Task.ts
+++ b/src/libs/actions/Task.ts
@@ -127,6 +127,7 @@ function createTaskAndNavigate(
if (!parentReportID) {
return;
}
+
const optimisticTaskReport = ReportUtils.buildOptimisticTaskReport(currentUserAccountID, parentReportID, assigneeAccountID, title, description, policyID);
const assigneeChatReportID = assigneeChatReport?.reportID;
@@ -931,7 +932,11 @@ function startOutCreateTaskQuickAction(reportID: string, targetAccountID: number
/**
* Get the assignee data
*/
-function getAssignee(assigneeAccountID: number, personalDetails: OnyxEntry): Assignee {
+function getAssignee(assigneeAccountID: number | undefined, personalDetails: OnyxEntry): Assignee | undefined {
+ if (!assigneeAccountID) {
+ return;
+ }
+
const details = personalDetails?.[assigneeAccountID];
if (!details) {
diff --git a/src/libs/actions/connections/NetSuiteCommands.ts b/src/libs/actions/connections/NetSuiteCommands.ts
index bf5b2be4b4a0..f26885945d55 100644
--- a/src/libs/actions/connections/NetSuiteCommands.ts
+++ b/src/libs/actions/connections/NetSuiteCommands.ts
@@ -766,7 +766,10 @@ function updateNetSuiteExportToNextOpenPeriod(policyID: string, value: boolean,
API.write(WRITE_COMMANDS.UPDATE_NETSUITE_EXPORT_TO_NEXT_OPEN_PERIOD, parameters, onyxData);
}
-function updateNetSuiteAutoSync(policyID: string, value: boolean) {
+function updateNetSuiteAutoSync(policyID: string | undefined, value: boolean) {
+ if (!policyID) {
+ return;
+ }
const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -937,10 +940,13 @@ function updateNetSuiteExportReportsTo(
}
function updateNetSuiteAccountingMethod(
- policyID: string,
+ policyID: string | undefined,
accountingMethod: ValueOf,
oldAccountingMethod: ValueOf,
) {
+ if (!policyID) {
+ return;
+ }
const onyxData = updateNetSuiteOnyxData(policyID, CONST.NETSUITE_CONFIG.ACCOUNTING_METHOD, accountingMethod, oldAccountingMethod);
const parameters = {
diff --git a/src/libs/actions/connections/QuickbooksOnline.ts b/src/libs/actions/connections/QuickbooksOnline.ts
index 05b6bb730069..1d32887daf99 100644
--- a/src/libs/actions/connections/QuickbooksOnline.ts
+++ b/src/libs/actions/connections/QuickbooksOnline.ts
@@ -1,7 +1,9 @@
+import type {CONST as COMMON_CONST} from 'expensify-common';
import type {OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
import * as API from '@libs/API';
-import type {ConnectPolicyToAccountingIntegrationParams} from '@libs/API/parameters';
+import type {ConnectPolicyToAccountingIntegrationParams, UpdateQuickbooksOnlineAccountingMethodParams} from '@libs/API/parameters';
import type UpdateQuickbooksOnlineAutoCreateVendorParams from '@libs/API/parameters/UpdateQuickbooksOnlineAutoCreateVendorParams';
import type UpdateQuickbooksOnlineGenericTypeParams from '@libs/API/parameters/UpdateQuickbooksOnlineGenericTypeParams';
import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
@@ -164,7 +166,10 @@ function buildOnyxDataForQuickbooksConfiguration(policyID: string, settingValue: TSettingValue) {
+function updateQuickbooksOnlineAutoSync(policyID: string | undefined, settingValue: TSettingValue) {
+ if (!policyID) {
+ return;
+ }
const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_CONFIG.AUTO_SYNC, {enabled: settingValue}, {enabled: !settingValue});
const parameters: UpdateQuickbooksOnlineGenericTypeParams = {
@@ -187,10 +192,13 @@ function updateQuickbooksOnlineEnableNewCategories>(
- policyID: string,
+ policyID: string | undefined,
configUpdate: TConfigUpdate,
configCurrentData: TConfigUpdate,
) {
+ if (!policyID) {
+ return;
+ }
const onyxData = buildOnyxDataForMultipleQuickbooksConfigurations(policyID, configUpdate, configCurrentData);
const parameters: UpdateQuickbooksOnlineAutoCreateVendorParams = {
@@ -203,7 +211,10 @@ function updateQuickbooksOnlineAutoCreateVendor(policyID: string, settingValue: TSettingValue) {
+function updateQuickbooksOnlineSyncPeople(policyID: string | undefined, settingValue: TSettingValue) {
+ if (!policyID) {
+ return;
+ }
const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_CONFIG.SYNC_PEOPLE, settingValue, !settingValue);
const parameters: UpdateQuickbooksOnlineGenericTypeParams = {
@@ -338,11 +349,11 @@ function updateQuickbooksOnlineNonReimbursableExpensesAccount(
- policyID: string,
+ policyID: string | undefined,
settingValue: TSettingValue,
oldSettingValue?: TSettingValue,
) {
- if (settingValue === oldSettingValue) {
+ if (settingValue === oldSettingValue || !policyID) {
return;
}
@@ -403,6 +414,24 @@ function updateQuickbooksOnlinePreferredExporter,
+ oldAccountingMethod: ValueOf,
+) {
+ if (!policyID) {
+ return;
+ }
+ const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_CONFIG.ACCOUNTING_METHOD, accountingMethod, oldAccountingMethod);
+
+ const parameters: UpdateQuickbooksOnlineAccountingMethodParams = {
+ policyID,
+ accountingMethod,
+ };
+
+ API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_ACCOUNTING_METHOD, parameters, onyxData);
+}
+
export {
getQuickbooksOnlineSetupLink,
updateQuickbooksOnlineEnableNewCategories,
@@ -421,4 +450,5 @@ export {
updateQuickbooksOnlineSyncClasses,
updateQuickbooksOnlineSyncLocations,
updateQuickbooksOnlineSyncCustomers,
+ updateQuickbooksOnlineAccountingMethod,
};
diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts
index 59deb4645ddb..12ca79edb09b 100644
--- a/src/libs/fileDownload/FileUtils.ts
+++ b/src/libs/fileDownload/FileUtils.ts
@@ -1,13 +1,15 @@
import {Str} from 'expensify-common';
import {Alert, Linking, Platform} from 'react-native';
import ImageSize from 'react-native-image-size';
+import type {TupleToUnion} from 'type-fest';
import type {FileObject} from '@components/AttachmentModal';
import DateUtils from '@libs/DateUtils';
import getPlatform from '@libs/getPlatform';
-import * as Localize from '@libs/Localize';
+import {translateLocal} from '@libs/Localize';
import Log from '@libs/Log';
import saveLastRoute from '@libs/saveLastRoute';
import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
import getImageManipulator from './getImageManipulator';
import getImageResolution from './getImageResolution';
import type {ReadFileAsync, SplitExtensionFromFileName} from './types';
@@ -18,13 +20,13 @@ import type {ReadFileAsync, SplitExtensionFromFileName} from './types';
*/
function showSuccessAlert(successMessage?: string) {
Alert.alert(
- Localize.translateLocal('fileDownload.success.title'),
+ translateLocal('fileDownload.success.title'),
// successMessage can be an empty string and we want to default to `Localize.translateLocal('fileDownload.success.message')`
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- successMessage || Localize.translateLocal('fileDownload.success.message'),
+ successMessage || translateLocal('fileDownload.success.message'),
[
{
- text: Localize.translateLocal('common.ok'),
+ text: translateLocal('common.ok'),
style: 'cancel',
},
],
@@ -36,9 +38,9 @@ function showSuccessAlert(successMessage?: string) {
* Show alert on attachment download error
*/
function showGeneralErrorAlert() {
- Alert.alert(Localize.translateLocal('fileDownload.generalError.title'), Localize.translateLocal('fileDownload.generalError.message'), [
+ Alert.alert(translateLocal('fileDownload.generalError.title'), translateLocal('fileDownload.generalError.message'), [
{
- text: Localize.translateLocal('common.cancel'),
+ text: translateLocal('common.cancel'),
style: 'cancel',
},
]);
@@ -48,13 +50,13 @@ function showGeneralErrorAlert() {
* Show alert on attachment download permissions error
*/
function showPermissionErrorAlert() {
- Alert.alert(Localize.translateLocal('fileDownload.permissionError.title'), Localize.translateLocal('fileDownload.permissionError.message'), [
+ Alert.alert(translateLocal('fileDownload.permissionError.title'), translateLocal('fileDownload.permissionError.message'), [
{
- text: Localize.translateLocal('common.cancel'),
+ text: translateLocal('common.cancel'),
style: 'cancel',
},
{
- text: Localize.translateLocal('common.settings'),
+ text: translateLocal('common.settings'),
onPress: () => {
Linking.openSettings();
},
@@ -67,15 +69,15 @@ function showPermissionErrorAlert() {
*/
function showCameraPermissionsAlert() {
Alert.alert(
- Localize.translateLocal('attachmentPicker.cameraPermissionRequired'),
- Localize.translateLocal('attachmentPicker.expensifyDoesntHaveAccessToCamera'),
+ translateLocal('attachmentPicker.cameraPermissionRequired'),
+ translateLocal('attachmentPicker.expensifyDoesntHaveAccessToCamera'),
[
{
- text: Localize.translateLocal('common.cancel'),
+ text: translateLocal('common.cancel'),
style: 'cancel',
},
{
- text: Localize.translateLocal('common.settings'),
+ text: translateLocal('common.settings'),
onPress: () => {
Linking.openSettings();
// In the case of ios, the App reloads when we update camera permission from settings
@@ -346,6 +348,36 @@ const createFile = (file: File): FileObject => {
});
};
+const validateReceipt = (file: FileObject, setUploadReceiptError: (isInvalid: boolean, title: TranslationPaths, reason: TranslationPaths) => void) => {
+ return validateImageForCorruption(file)
+ .then(() => {
+ const {fileExtension} = splitExtensionFromFileName(file?.name ?? '');
+ if (
+ !CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes(
+ fileExtension.toLowerCase() as TupleToUnion,
+ )
+ ) {
+ setUploadReceiptError(true, 'attachmentPicker.wrongFileType', 'attachmentPicker.notAllowedExtension');
+ return false;
+ }
+
+ if (!Str.isImage(file.name ?? '') && (file?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) {
+ setUploadReceiptError(true, 'attachmentPicker.attachmentTooLarge', 'attachmentPicker.sizeExceededWithLimit');
+ return false;
+ }
+
+ if ((file?.size ?? 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) {
+ setUploadReceiptError(true, 'attachmentPicker.attachmentTooSmall', 'attachmentPicker.sizeNotMet');
+ return false;
+ }
+ return true;
+ })
+ .catch(() => {
+ setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.errorWhileSelectingCorruptedAttachment');
+ return false;
+ });
+};
+
export {
showGeneralErrorAlert,
showSuccessAlert,
@@ -367,4 +399,5 @@ export {
getImageDimensionsAfterResize,
resizeImageIfNeeded,
createFile,
+ validateReceipt,
};
diff --git a/src/pages/ChangePolicyEducationalModal.tsx b/src/pages/ChangePolicyEducationalModal.tsx
new file mode 100644
index 000000000000..ecc8d1fd4a76
--- /dev/null
+++ b/src/pages/ChangePolicyEducationalModal.tsx
@@ -0,0 +1,50 @@
+import React, {useCallback} from 'react';
+import ChangeWorkspaceMenuSectionList from '@components/ChangeWorkspaceMenuSectionList';
+import FeatureTrainingModal from '@components/FeatureTrainingModal';
+import * as Illustrations from '@components/Icon/Illustrations';
+import useBeforeRemove from '@hooks/useBeforeRemove';
+import useLocalize from '@hooks/useLocalize';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {dismissChangePolicyModal} from '@libs/actions/Report';
+import colors from '@styles/theme/colors';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+
+function ChangePolicyEducationalModal() {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+
+ const onConfirm = useCallback(() => {
+ dismissChangePolicyModal();
+ }, []);
+
+ useBeforeRemove(onConfirm);
+
+ return (
+
+
+
+ );
+}
+
+ChangePolicyEducationalModal.displayName = 'ChangePolicyEducationalModal';
+
+export default ChangePolicyEducationalModal;
diff --git a/src/pages/Debug/Report/DebugReportActions.tsx b/src/pages/Debug/Report/DebugReportActions.tsx
index e86befd81217..792df8bfd561 100644
--- a/src/pages/Debug/Report/DebugReportActions.tsx
+++ b/src/pages/Debug/Report/DebugReportActions.tsx
@@ -8,6 +8,7 @@ import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
+import {getHeaderMessageForNonUserList} from '@libs/OptionsListUtils';
import Parser from '@libs/Parser';
import {getOriginalMessage, getReportActionMessage, getReportActionMessageText, getSortedReportActionsForDisplay, isCreatedAction} from '@libs/ReportActionsUtils';
import {canUserPerformWriteAction, formatReportLastMessageText} from '@libs/ReportUtils';
@@ -85,6 +86,7 @@ function DebugReportActions({reportID}: DebugReportActionsProps) {
listItemTitleStyles={styles.fontWeightNormal}
textInputValue={searchValue}
textInputLabel={translate('common.search')}
+ headerMessage={getHeaderMessageForNonUserList(searchedReportActions.length > 0, debouncedSearchValue)}
onChangeText={setSearchValue}
onSelectRow={(item) => Navigation.navigate(ROUTES.DEBUG_REPORT_ACTION.getRoute(reportID, item.reportActionID))}
ListItem={RadioListItem}
diff --git a/src/pages/ErrorPage/UpdateRequiredView.tsx b/src/pages/ErrorPage/UpdateRequiredView.tsx
index c2d447da63e7..ac7a7237b0c8 100644
--- a/src/pages/ErrorPage/UpdateRequiredView.tsx
+++ b/src/pages/ErrorPage/UpdateRequiredView.tsx
@@ -26,7 +26,7 @@ function UpdateRequiredView() {
const isStandaloneNewAppProduction = isProduction && !CONFIG.IS_HYBRID_APP;
return (
-
+
diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx
index f49b50da3c6a..6c14fcc0d237 100755
--- a/src/pages/NewChatPage.tsx
+++ b/src/pages/NewChatPage.tsx
@@ -22,8 +22,8 @@ import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
+import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
import useScreenWrapperTranstionStatus from '@hooks/useScreenWrapperTransitionStatus';
-import useStyledSafeAreaInsets from '@hooks/useStyledSafeAreaInsets';
import useThemeStyles from '@hooks/useThemeStyles';
import {navigateToAndOpenReport, searchInServer, setGroupDraft} from '@libs/actions/Report';
import {canUseTouchScreen} from '@libs/DeviceCapabilities';
@@ -148,7 +148,7 @@ function NewChatPage() {
const styles = useThemeStyles();
const personalData = useCurrentUserPersonalDetails();
const {top} = useSafeAreaInsets();
- const {insets, safeAreaPaddingBottomStyle} = useStyledSafeAreaInsets();
+ const {insets, safeAreaPaddingBottomStyle} = useSafeAreaPaddings();
const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false});
const selectionListRef = useRef(null);
diff --git a/src/pages/NewReportWorkspaceSelectionPage.tsx b/src/pages/NewReportWorkspaceSelectionPage.tsx
new file mode 100644
index 000000000000..554a3a378893
--- /dev/null
+++ b/src/pages/NewReportWorkspaceSelectionPage.tsx
@@ -0,0 +1,135 @@
+import React, {useCallback, useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import * as Expensicons from '@components/Icon/Expensicons';
+import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import type {ListItem, SectionListDataType} from '@components/SelectionList/types';
+import UserListItem from '@components/SelectionList/UserListItem';
+import Text from '@components/Text';
+import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
+import useDebouncedState from '@hooks/useDebouncedState';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {createNewReport} from '@libs/actions/Report';
+import Navigation from '@libs/Navigation/Navigation';
+import {getHeaderMessageForNonUserList} from '@libs/OptionsListUtils';
+import {isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils';
+import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+
+type WorkspaceListItem = {
+ text: string;
+ policyID?: string;
+ isPolicyAdmin?: boolean;
+} & ListItem;
+
+function NewReportWorkspaceSelectionPage() {
+ const {isOffline} = useNetwork();
+ const styles = useThemeStyles();
+ const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');
+ const {translate} = useLocalize();
+
+ const [policies, fetchStatus] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
+ const currentUserPersonalDetails = useCurrentUserPersonalDetails();
+ const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP);
+ const shouldShowLoadingIndicator = isLoadingApp && !isOffline;
+
+ const selectPolicy = useCallback(
+ (policyID?: string) => {
+ if (!policyID) {
+ return;
+ }
+ const createdReportID = createNewReport(currentUserPersonalDetails, policyID);
+ Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: createdReportID, backTo: Navigation.getActiveRoute()}), {forceReplace: true});
+ },
+ [currentUserPersonalDetails],
+ );
+
+ const usersWorkspaces = useMemo(() => {
+ if (!policies || isEmptyObject(policies)) {
+ return [];
+ }
+
+ return Object.values(policies)
+ .filter((policy) => shouldShowPolicy(policy, !!isOffline, currentUserPersonalDetails?.login) && !policy?.isJoinRequestPending && policy?.isPolicyExpenseChatEnabled)
+ .map((policy) => ({
+ text: policy?.name ?? '',
+ policyID: policy?.id,
+ icons: [
+ {
+ source: policy?.avatarURL ? policy.avatarURL : getDefaultWorkspaceAvatar(policy?.name),
+ fallbackIcon: Expensicons.FallbackWorkspaceAvatar,
+ name: policy?.name,
+ type: CONST.ICON_TYPE_WORKSPACE,
+ id: policy?.id,
+ },
+ ],
+ keyForList: policy?.id,
+ isPolicyAdmin: isPolicyAdmin(policy),
+ shouldSyncFocus: true,
+ }))
+ .sort((a, b) => a.text.localeCompare(b.text.toLowerCase()));
+ }, [policies, isOffline, currentUserPersonalDetails?.login]);
+
+ const filteredAndSortedUserWorkspaces = useMemo(
+ () => usersWorkspaces.filter((policy) => policy.text?.toLowerCase().includes(debouncedSearchTerm?.toLowerCase() ?? '')),
+ [debouncedSearchTerm, usersWorkspaces],
+ );
+
+ const sections = useMemo(() => {
+ const options: Array> = [
+ {
+ data: filteredAndSortedUserWorkspaces,
+ shouldShow: true,
+ },
+ ];
+ return options;
+ }, [filteredAndSortedUserWorkspaces]);
+
+ const areResultsFound = filteredAndSortedUserWorkspaces.length > 0;
+ const headerMessage = getHeaderMessageForNonUserList(areResultsFound, debouncedSearchTerm);
+
+ return (
+
+ {({didScreenTransitionEnd}) => (
+ <>
+
+ {shouldShowLoadingIndicator ? (
+
+ ) : (
+ <>
+ {translate('report.newReport.chooseWorkspace')}
+
+ ListItem={UserListItem}
+ sections={sections}
+ onSelectRow={(option) => selectPolicy(option.policyID)}
+ textInputLabel={usersWorkspaces.length >= CONST.STANDARD_LIST_ITEM_LIMIT ? translate('common.search') : undefined}
+ textInputValue={searchTerm}
+ onChangeText={setSearchTerm}
+ headerMessage={headerMessage}
+ showLoadingPlaceholder={fetchStatus.status === 'loading' || !didScreenTransitionEnd}
+ />
+ >
+ )}
+ >
+ )}
+
+ );
+}
+
+NewReportWorkspaceSelectionPage.displayName = 'NewReportWorkspaceSelectionPage';
+
+export default NewReportWorkspaceSelectionPage;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Confirmation.tsx
index 2c1467ec5b69..696ebe4fc489 100644
--- a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Confirmation.tsx
+++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Confirmation.tsx
@@ -1,14 +1,8 @@
-import React from 'react';
-import {View} from 'react-native';
+import React, {useMemo} from 'react';
import {useOnyx} from 'react-native-onyx';
-import Button from '@components/Button';
-import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
-import SafeAreaConsumer from '@components/SafeAreaConsumer';
-import ScrollView from '@components/ScrollView';
-import Text from '@components/Text';
+import ConfirmationStep from '@components/SubStepForms/ConfirmationStep';
import useLocalize from '@hooks/useLocalize';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
import getNeededDocumentsStatusForBeneficialOwner from '@pages/ReimbursementAccount/NonUSD/utils/getNeededDocumentsStatusForBeneficialOwner';
import getValuesForBeneficialOwner from '@pages/ReimbursementAccount/NonUSD/utils/getValuesForBeneficialOwner';
import CONST from '@src/CONST';
@@ -19,12 +13,11 @@ type ConfirmationProps = SubStepProps & {ownerBeingModifiedID: string};
const {PREFIX, COUNTRY} = CONST.NON_USD_BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA;
-function Confirmation({onNext, onMove, ownerBeingModifiedID}: ConfirmationProps) {
+function Confirmation({onNext, onMove, isEditing, ownerBeingModifiedID}: ConfirmationProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
- const values = getValuesForBeneficialOwner(ownerBeingModifiedID, reimbursementAccountDraft);
+ const values = useMemo(() => getValuesForBeneficialOwner(ownerBeingModifiedID, reimbursementAccountDraft), [ownerBeingModifiedID, reimbursementAccountDraft]);
const beneficialOwnerCountryInputID = `${PREFIX}_${ownerBeingModifiedID}_${COUNTRY}` as const;
const beneficialOwnerCountry = String(reimbursementAccountDraft?.[beneficialOwnerCountryInputID] ?? '');
const policyID = reimbursementAccount?.achData?.policyID;
@@ -33,108 +26,134 @@ function Confirmation({onNext, onMove, ownerBeingModifiedID}: ConfirmationProps)
const countryStepCountryValue = reimbursementAccountDraft?.[INPUT_IDS.ADDITIONAL_DATA.COUNTRY] ?? '';
const isDocumentNeededStatus = getNeededDocumentsStatusForBeneficialOwner(currency, countryStepCountryValue, beneficialOwnerCountry);
+ const summaryItems = useMemo(
+ () => [
+ {
+ title: `${values.firstName} ${values.lastName}`,
+ description: translate('ownershipInfoStep.legalName'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(0);
+ },
+ },
+ {
+ title: values.ownershipPercentage,
+ description: translate('ownershipInfoStep.ownershipPercentage'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(1);
+ },
+ },
+ {
+ title: values.dob,
+ description: translate('common.dob'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(2);
+ },
+ },
+ ...(beneficialOwnerCountry === CONST.COUNTRY.US
+ ? [
+ {
+ title: values.ssnLast4,
+ description: translate('ownershipInfoStep.last4'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(4);
+ },
+ },
+ ]
+ : []),
+ {
+ title: `${values.street}, ${values.city}, ${values.state} ${values.zipCode}`,
+ description: translate('ownershipInfoStep.address'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(3);
+ },
+ },
+ ...(isDocumentNeededStatus.isProofOfOwnershipNeeded
+ ? [
+ {
+ title: values.proofOfOwnership.map((file) => file.name).join(', '),
+ description: translate('ownershipInfoStep.proofOfBeneficialOwner'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(5);
+ },
+ },
+ ]
+ : []),
+ ...(isDocumentNeededStatus.isCopyOfIDNeeded
+ ? [
+ {
+ title: values.copyOfID.map((file) => file.name).join(', '),
+ description: translate('ownershipInfoStep.copyOfID'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(5);
+ },
+ },
+ ]
+ : []),
+ ...(isDocumentNeededStatus.isProofOfAddressNeeded
+ ? [
+ {
+ title: values.addressProof.map((file) => file.name).join(', '),
+ description: translate('ownershipInfoStep.proofOfAddress'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(5);
+ },
+ },
+ ]
+ : []),
+ ...(isDocumentNeededStatus.isCodiceFiscaleNeeded
+ ? [
+ {
+ title: values.codiceFisacle.map((file) => file.name).join(', '),
+ description: translate('ownershipInfoStep.codiceFiscale'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(5);
+ },
+ },
+ ]
+ : []),
+ ],
+ [
+ beneficialOwnerCountry,
+ isDocumentNeededStatus.isCodiceFiscaleNeeded,
+ isDocumentNeededStatus.isCopyOfIDNeeded,
+ isDocumentNeededStatus.isProofOfAddressNeeded,
+ isDocumentNeededStatus.isProofOfOwnershipNeeded,
+ onMove,
+ translate,
+ values.addressProof,
+ values.city,
+ values.codiceFisacle,
+ values.copyOfID,
+ values.dob,
+ values.firstName,
+ values.lastName,
+ values.ownershipPercentage,
+ values.proofOfOwnership,
+ values.ssnLast4,
+ values.state,
+ values.street,
+ values.zipCode,
+ ],
+ );
+
return (
-
- {({safeAreaPaddingBottomStyle}) => (
-
- {translate('ownershipInfoStep.letsDoubleCheck')}
- {
- onMove(0);
- }}
- />
- {
- onMove(1);
- }}
- />
- {
- onMove(2);
- }}
- />
- {beneficialOwnerCountry === CONST.COUNTRY.US && (
- {
- onMove(4);
- }}
- />
- )}
- {
- onMove(3);
- }}
- />
- {isDocumentNeededStatus.isProofOfOwnershipNeeded && values.proofOfOwnership.length > 0 && (
- file.name).join(', ')}
- shouldShowRightIcon
- onPress={() => {
- onMove(5);
- }}
- />
- )}
- {isDocumentNeededStatus.isCopyOfIDNeeded && values.copyOfID.length > 0 && (
- file.name).join(', ')}
- shouldShowRightIcon
- onPress={() => {
- onMove(5);
- }}
- />
- )}
- {isDocumentNeededStatus.isProofOfAddressNeeded && values.addressProof.length > 0 && (
- file.name).join(', ')}
- shouldShowRightIcon
- onPress={() => {
- onMove(5);
- }}
- />
- )}
- {isDocumentNeededStatus.isCodiceFiscaleNeeded && values.codiceFisacle.length > 0 && (
- file.name).join(', ')}
- shouldShowRightIcon
- onPress={() => {
- onMove(5);
- }}
- />
- )}
-
-
-
-
- )}
-
+
);
}
diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Confirmation.tsx
index 7a54d5ffca2b..e377f12240e4 100644
--- a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Confirmation.tsx
+++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Confirmation.tsx
@@ -1,15 +1,9 @@
+import {CONST as COMMON_CONST} from 'expensify-common/dist/CONST';
import React, {useMemo} from 'react';
-import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
-import Button from '@components/Button';
-import DotIndicatorMessage from '@components/DotIndicatorMessage';
-import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
-import SafeAreaConsumer from '@components/SafeAreaConsumer';
-import ScrollView from '@components/ScrollView';
-import Text from '@components/Text';
+import ConfirmationStep from '@components/SubStepForms/ConfirmationStep';
import useLocalize from '@hooks/useLocalize';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
import {getLatestErrorMessage} from '@libs/ErrorUtils';
import getSubStepValues from '@pages/ReimbursementAccount/utils/getSubStepValues';
import CONST from '@src/CONST';
@@ -29,6 +23,7 @@ const {
BUSINESS_CONTACT_NUMBER,
BUSINESS_CONFIRMATION_EMAIL,
FORMATION_INCORPORATION_COUNTRY_CODE,
+ FORMATION_INCORPORATION_STATE,
ANNUAL_VOLUME,
APPLICANT_TYPE_ID,
TRADE_VOLUME,
@@ -43,9 +38,15 @@ const displayAddress = (street: string, city: string, state: string, zipCode: st
return country === CONST.COUNTRY.US || country === CONST.COUNTRY.CA ? `${street}, ${city}, ${state}, ${zipCode}, ${country}` : `${street}, ${city}, ${zipCode}, ${country}`;
};
-function Confirmation({onNext, onMove}: SubStepProps) {
+const displayIncorporationLocation = (country: string, state: string) => {
+ const countryFullName = CONST.ALL_COUNTRIES[country as keyof typeof CONST.COUNTRY];
+ const stateFullName = COMMON_CONST.STATES[state as keyof typeof COMMON_CONST.STATES]?.stateName ?? COMMON_CONST.PROVINCES[state as keyof typeof COMMON_CONST.PROVINCES]?.provinceName;
+
+ return country === CONST.COUNTRY.US || country === CONST.COUNTRY.CA ? `${stateFullName}, ${countryFullName}` : `${countryFullName}`;
+};
+
+function Confirmation({onNext, onMove, isEditing}: SubStepProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
@@ -71,122 +72,111 @@ function Confirmation({onNext, onMove}: SubStepProps) {
[corpayOnboardingFields?.picklists.TradeVolumeRange, values],
);
+ const summaryItems = useMemo(
+ () => [
+ {
+ title: values[COMPANY_NAME],
+ description: translate('businessInfoStep.legalBusinessName'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(0);
+ },
+ },
+ {
+ title: values[BUSINESS_REGISTRATION_INCORPORATION_NUMBER],
+ description: translate('businessInfoStep.registrationNumber'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(3);
+ },
+ },
+ {
+ title: values[TAX_ID_EIN_NUMBER],
+ description: translate('businessInfoStep.taxIDEIN'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(4);
+ },
+ },
+ {
+ title: displayAddress(values[COMPANY_STREET], values[COMPANY_CITY], values[COMPANY_STATE], values[COMPANY_POSTAL_CODE], values[COMPANY_COUNTRY_CODE]),
+ description: translate('businessInfoStep.businessAddress'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(1);
+ },
+ },
+ {
+ title: values[BUSINESS_CONTACT_NUMBER],
+ description: translate('common.phoneNumber'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(2);
+ },
+ },
+ {
+ title: values[BUSINESS_CONFIRMATION_EMAIL],
+ description: translate('common.email'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(2);
+ },
+ },
+ {
+ title: businessType,
+ description: translate('businessInfoStep.businessType'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(6);
+ },
+ },
+ {
+ title: displayIncorporationLocation(values[FORMATION_INCORPORATION_COUNTRY_CODE], values[FORMATION_INCORPORATION_STATE]),
+ description: translate('businessInfoStep.incorporation'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(5);
+ },
+ },
+ {
+ title: businessCategory,
+ description: translate('businessInfoStep.businessCategory'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(6);
+ },
+ },
+ {
+ title: paymentVolume,
+ description: translate('businessInfoStep.annualPaymentVolume'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(7);
+ },
+ },
+ {
+ title: tradeVolumeRange,
+ description: translate('businessInfoStep.averageReimbursementAmount'),
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(8);
+ },
+ },
+ ],
+ [businessCategory, businessType, onMove, paymentVolume, tradeVolumeRange, translate, values],
+ );
+
return (
-
- {({safeAreaPaddingBottomStyle}) => (
-
- {translate('businessInfoStep.letsDoubleCheck')}
- {
- onMove(0);
- }}
- />
- {
- onMove(3);
- }}
- />
- {
- onMove(4);
- }}
- />
- {
- onMove(1);
- }}
- />
- {
- onMove(2);
- }}
- />
- {
- onMove(2);
- }}
- />
- {
- onMove(6);
- }}
- />
- {
- onMove(5);
- }}
- />
- {
- onMove(6);
- }}
- />
- {
- onMove(7);
- }}
- />
- {
- onMove(8);
- }}
- />
-
- {!!error && error.length > 0 && (
-
- )}
-
-
-
- )}
-
+
);
}
diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/AddressBusiness.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/AddressBusiness.tsx
index d7e1d8df4de8..64e957a76d5c 100644
--- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/AddressBusiness.tsx
+++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/AddressBusiness.tsx
@@ -1,14 +1,9 @@
-import React, {useCallback} from 'react';
+import React from 'react';
import {useOnyx} from 'react-native-onyx';
-import FormProvider from '@components/Form/FormProvider';
-import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import Text from '@components/Text';
+import AddressStep from '@components/SubStepForms/AddressStep';
import useLocalize from '@hooks/useLocalize';
import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as ValidationUtils from '@libs/ValidationUtils';
-import AddressFormFields from '@pages/ReimbursementAccount/AddressFormFields';
import ONYXKEYS from '@src/ONYXKEYS';
import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
@@ -23,33 +18,11 @@ const INPUT_KEYS = {
const STEP_FIELDS = [COMPANY_BUSINESS_INFO_KEY.STREET, COMPANY_BUSINESS_INFO_KEY.CITY, COMPANY_BUSINESS_INFO_KEY.STATE, COMPANY_BUSINESS_INFO_KEY.ZIP_CODE];
-function AddressBusiness({onNext, isEditing}: SubStepProps) {
+function AddressBusiness({onNext, onMove, isEditing}: SubStepProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
- const validate = useCallback(
- (values: FormOnyxValues): FormInputErrors => {
- const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS);
-
- if (values.addressStreet && !ValidationUtils.isValidAddress(values.addressStreet)) {
- errors.addressStreet = translate('bankAccount.error.addressStreet');
- }
-
- if (values.addressCity && !ValidationUtils.isValidAddress(values.addressCity)) {
- errors.addressCity = translate('bankAccount.error.addressCity');
- }
-
- if (values.addressZipCode && !ValidationUtils.isValidZipCode(values.addressZipCode)) {
- errors.addressZipCode = translate('bankAccount.error.zipCode');
- }
-
- return errors;
- },
- [translate],
- );
-
const defaultValues = {
street: reimbursementAccount?.achData?.addressStreet ?? '',
city: reimbursementAccount?.achData?.addressCity ?? '',
@@ -64,23 +37,19 @@ function AddressBusiness({onNext, isEditing}: SubStepProps) {
});
return (
-
+ isEditing={isEditing}
+ onNext={onNext}
+ onMove={onMove}
formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM}
- submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')}
- validate={validate}
+ formTitle={translate('businessInfoStep.enterYourCompanysAddress')}
+ formPOBoxDisclaimer={translate('common.noPO')}
onSubmit={handleSubmit}
- submitButtonStyles={[styles.mb0]}
- style={[styles.mh5, styles.flexGrow1]}
- >
- {translate('businessInfoStep.enterYourCompanysAddress')}
- {translate('common.noPO')}
-
-
+ stepFields={STEP_FIELDS}
+ inputFieldsIDs={INPUT_KEYS}
+ defaultValues={defaultValues}
+ streetTranslationKey="common.companyAddress"
+ />
);
}
diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/NameBusiness.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/NameBusiness.tsx
index b523381e4457..50444cb569af 100644
--- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/NameBusiness.tsx
+++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/NameBusiness.tsx
@@ -1,38 +1,32 @@
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 {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import Text from '@components/Text';
-import TextInput from '@components/TextInput';
+import SingleFieldStep from '@components/SubStepForms/SingleFieldStep';
import useLocalize from '@hooks/useLocalize';
import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as ValidationUtils from '@libs/ValidationUtils';
-import CONST from '@src/CONST';
+import {getFieldRequiredErrors, isValidCompanyName} from '@libs/ValidationUtils';
import ONYXKEYS from '@src/ONYXKEYS';
import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
const COMPANY_NAME_KEY = INPUT_IDS.BUSINESS_INFO_STEP.COMPANY_NAME;
const STEP_FIELDS = [COMPANY_NAME_KEY];
-function NameBusiness({onNext, isEditing}: SubStepProps) {
+function NameBusiness({onNext, onMove, isEditing}: SubStepProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
const defaultCompanyName = reimbursementAccount?.achData?.companyName ?? '';
- const bankAccountID = reimbursementAccount?.achData?.bankAccountID ?? -1;
+ const bankAccountID = reimbursementAccount?.achData?.bankAccountID;
const shouldDisableCompanyName = !!(bankAccountID && defaultCompanyName && reimbursementAccount?.achData?.state !== 'SETUP');
const validate = useCallback(
(values: FormOnyxValues): FormInputErrors => {
- const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS);
+ const errors = getFieldRequiredErrors(values, STEP_FIELDS);
- if (values.companyName && !ValidationUtils.isValidCompanyName(values.companyName)) {
+ if (values.companyName && !isValidCompanyName(values.companyName)) {
errors.companyName = translate('bankAccount.error.companyName');
}
@@ -48,28 +42,21 @@ function NameBusiness({onNext, isEditing}: SubStepProps) {
});
return (
-
+ isEditing={isEditing}
+ onNext={onNext}
+ onMove={onMove}
formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM}
- submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')}
+ formTitle={translate('businessInfoStep.enterTheNameOfYourBusiness')}
validate={validate}
onSubmit={handleSubmit}
- style={[styles.mh5, styles.flexGrow1]}
- submitButtonStyles={[styles.mb0]}
- >
- {translate('businessInfoStep.enterTheNameOfYourBusiness')}
-
-
+ inputId={COMPANY_NAME_KEY}
+ inputLabel={translate('businessInfoStep.businessName')}
+ defaultValue={defaultCompanyName}
+ shouldUseDefaultValue={shouldDisableCompanyName}
+ disabled={shouldDisableCompanyName}
+ shouldShowHelpLinks={false}
+ />
);
}
diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/PhoneNumberBusiness.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/PhoneNumberBusiness.tsx
index 9e82accfe2f3..610206768844 100644
--- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/PhoneNumberBusiness.tsx
+++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/PhoneNumberBusiness.tsx
@@ -1,15 +1,11 @@
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 {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import Text from '@components/Text';
-import TextInput from '@components/TextInput';
+import SingleFieldStep from '@components/SubStepForms/SingleFieldStep';
import useLocalize from '@hooks/useLocalize';
import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as ValidationUtils from '@libs/ValidationUtils';
+import {getFieldRequiredErrors, isValidUSPhone} from '@libs/ValidationUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
@@ -17,9 +13,8 @@ import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
const COMPANY_PHONE_NUMBER_KEY = INPUT_IDS.BUSINESS_INFO_STEP.COMPANY_PHONE;
const STEP_FIELDS = [COMPANY_PHONE_NUMBER_KEY];
-function PhoneNumberBusiness({onNext, isEditing}: SubStepProps) {
+function PhoneNumberBusiness({onNext, onMove, isEditing}: SubStepProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
@@ -27,9 +22,9 @@ function PhoneNumberBusiness({onNext, isEditing}: SubStepProps) {
const validate = useCallback(
(values: FormOnyxValues): FormInputErrors => {
- const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS);
+ const errors = getFieldRequiredErrors(values, STEP_FIELDS);
- if (values.companyPhone && !ValidationUtils.isValidUSPhone(values.companyPhone, true)) {
+ if (values.companyPhone && !isValidUSPhone(values.companyPhone, true)) {
errors.companyPhone = translate('bankAccount.error.phoneNumber');
}
@@ -46,28 +41,21 @@ function PhoneNumberBusiness({onNext, isEditing}: SubStepProps) {
});
return (
-
+ isEditing={isEditing}
+ onNext={onNext}
+ onMove={onMove}
formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM}
- submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')}
+ formTitle={translate('businessInfoStep.enterYourCompanysPhoneNumber')}
validate={validate}
onSubmit={handleSubmit}
- style={[styles.mh5, styles.flexGrow1]}
- submitButtonStyles={[styles.mb0]}
- >
- {translate('businessInfoStep.enterYourCompanysPhoneNumber')}
-
-
+ inputId={COMPANY_PHONE_NUMBER_KEY}
+ inputMode={CONST.INPUT_MODE.TEL}
+ inputLabel={translate('common.phoneNumber')}
+ defaultValue={defaultCompanyPhoneNumber}
+ shouldShowHelpLinks={false}
+ placeholder={translate('common.phoneNumberPlaceholder')}
+ />
);
}
diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TaxIdBusiness.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TaxIdBusiness.tsx
index 48f1b7f0665f..4a249541bb82 100644
--- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TaxIdBusiness.tsx
+++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TaxIdBusiness.tsx
@@ -1,36 +1,32 @@
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 {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import Text from '@components/Text';
-import TextInput from '@components/TextInput';
+import SingleFieldStep from '@components/SubStepForms/SingleFieldStep';
import useLocalize from '@hooks/useLocalize';
import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as ValidationUtils from '@libs/ValidationUtils';
-import CONST from '@src/CONST';
+import {getFieldRequiredErrors, isValidTaxID} from '@libs/ValidationUtils';
import ONYXKEYS from '@src/ONYXKEYS';
import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
const COMPANY_TAX_ID_KEY = INPUT_IDS.BUSINESS_INFO_STEP.COMPANY_TAX_ID;
const STEP_FIELDS = [COMPANY_TAX_ID_KEY];
-function TaxIdBusiness({onNext, isEditing}: SubStepProps) {
+function TaxIdBusiness({onNext, onMove, isEditing}: SubStepProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
- const defaultCompanyTaxId = reimbursementAccount?.achData?.companyTaxID ?? '';
- const bankAccountID = reimbursementAccount?.achData?.bankAccountID ?? 0;
- const shouldDisableCompanyTaxID = !!(bankAccountID && defaultCompanyTaxId && reimbursementAccount?.achData?.state !== 'SETUP');
+ // This is default value for the input to be display
+ /* eslint-disable-next-line rulesdir/no-default-id-values */
+ const defaultCompanyTaxID = reimbursementAccount?.achData?.companyTaxID ?? '';
+ const bankAccountID = reimbursementAccount?.achData?.bankAccountID;
+ const shouldDisableCompanyTaxID = !!(bankAccountID && defaultCompanyTaxID && reimbursementAccount?.achData?.state !== 'SETUP');
const validate = useCallback(
(values: FormOnyxValues): FormInputErrors => {
- const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS);
+ const errors = getFieldRequiredErrors(values, STEP_FIELDS);
- if (values.companyTaxID && !ValidationUtils.isValidTaxID(values.companyTaxID)) {
+ if (values.companyTaxID && !isValidTaxID(values.companyTaxID)) {
errors.companyTaxID = translate('bankAccount.error.taxID');
}
@@ -46,30 +42,22 @@ function TaxIdBusiness({onNext, isEditing}: SubStepProps) {
});
return (
-
+ isEditing={isEditing}
+ onNext={onNext}
+ onMove={onMove}
formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM}
- submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')}
+ formTitle={translate('businessInfoStep.enterYourCompanysTaxIdNumber')}
validate={validate}
onSubmit={handleSubmit}
- style={[styles.mh5, styles.flexGrow1]}
- submitButtonStyles={[styles.mb0]}
- >
- {translate('businessInfoStep.enterYourCompanysTaxIdNumber')}
-
-
+ inputId={COMPANY_TAX_ID_KEY}
+ inputLabel={translate('businessInfoStep.taxIDNumber')}
+ defaultValue={defaultCompanyTaxID}
+ shouldUseDefaultValue={shouldDisableCompanyTaxID}
+ disabled={shouldDisableCompanyTaxID}
+ shouldShowHelpLinks={false}
+ placeholder={translate('businessInfoStep.taxIDNumberPlaceholder')}
+ />
);
}
diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/WebsiteBusiness.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/WebsiteBusiness.tsx
index 5ef5e7c75c8d..15a74fe0a22f 100644
--- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/WebsiteBusiness.tsx
+++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/WebsiteBusiness.tsx
@@ -1,18 +1,14 @@
import {Str} from 'expensify-common';
import React, {useCallback, useMemo} from 'react';
import {useOnyx} from 'react-native-onyx';
-import FormProvider from '@components/Form/FormProvider';
-import InputWrapper from '@components/Form/InputWrapper';
import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import Text from '@components/Text';
-import TextInput from '@components/TextInput';
+import SingleFieldStep from '@components/SubStepForms/SingleFieldStep';
import useLocalize from '@hooks/useLocalize';
import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
import {getDefaultCompanyWebsite} from '@libs/BankAccountUtils';
-import * as ValidationUtils from '@libs/ValidationUtils';
-import * as BankAccounts from '@userActions/BankAccounts';
+import {getFieldRequiredErrors, isValidWebsite} from '@libs/ValidationUtils';
+import {addBusinessWebsiteForDraft} from '@userActions/BankAccounts';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
@@ -20,9 +16,8 @@ import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
const COMPANY_WEBSITE_KEY = INPUT_IDS.BUSINESS_INFO_STEP.COMPANY_WEBSITE;
const STEP_FIELDS = [COMPANY_WEBSITE_KEY];
-function WebsiteBusiness({onNext, isEditing}: SubStepProps) {
+function WebsiteBusiness({onNext, onMove, isEditing}: SubStepProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
const [session] = useOnyx(ONYXKEYS.SESSION);
const [user] = useOnyx(ONYXKEYS.USER);
@@ -32,9 +27,9 @@ function WebsiteBusiness({onNext, isEditing}: SubStepProps) {
const validate = useCallback(
(values: FormOnyxValues): FormInputErrors => {
- const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS);
+ const errors = getFieldRequiredErrors(values, STEP_FIELDS);
- if (values.website && !ValidationUtils.isValidWebsite(Str.sanitizeURL(values.website, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME))) {
+ if (values.website && !isValidWebsite(Str.sanitizeURL(values.website, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME))) {
errors.website = translate('bankAccount.error.website');
}
@@ -46,35 +41,28 @@ function WebsiteBusiness({onNext, isEditing}: SubStepProps) {
fieldIds: STEP_FIELDS,
onNext: (values) => {
const website = Str.sanitizeURL((values as {website: string})?.website, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME);
- BankAccounts.addBusinessWebsiteForDraft(website);
+ addBusinessWebsiteForDraft(website);
onNext();
},
shouldSaveDraft: true,
});
return (
-
+ isEditing={isEditing}
+ onNext={onNext}
+ onMove={onMove}
formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM}
- submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')}
+ formTitle={translate('businessInfoStep.enterYourCompanysWebsite')}
+ formDisclaimer={translate('common.websiteExample')}
validate={validate}
onSubmit={handleSubmit}
- style={[styles.mh5, styles.flexGrow1]}
- submitButtonStyles={[styles.mb0]}
- >
- {translate('businessInfoStep.enterYourCompanysWebsite')}
- {translate('common.websiteExample')}
-
-
+ inputId={COMPANY_WEBSITE_KEY}
+ inputLabel={translate('businessInfoStep.companyWebsite')}
+ defaultValue={defaultCompanyWebsite}
+ inputMode={CONST.INPUT_MODE.URL}
+ shouldShowHelpLinks={false}
+ />
);
}
diff --git a/src/pages/ReportChangeWorkspacePage.tsx b/src/pages/ReportChangeWorkspacePage.tsx
new file mode 100644
index 000000000000..d07ed20513a4
--- /dev/null
+++ b/src/pages/ReportChangeWorkspacePage.tsx
@@ -0,0 +1,95 @@
+import React, {useCallback} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import UserListItem from '@components/SelectionList/UserListItem';
+import useDebouncedState from '@hooks/useDebouncedState';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useThemeStyles from '@hooks/useThemeStyles';
+import type {WorkspaceListItem} from '@hooks/useWorkspaceList';
+import useWorkspaceList from '@hooks/useWorkspaceList';
+import {changeReportPolicy} from '@libs/actions/Report';
+import Navigation from '@libs/Navigation/Navigation';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {ReportChangeWorkspaceNavigatorParamList} from '@libs/Navigation/types';
+import {isWorkspaceEligibleForReportChange} from '@libs/PolicyUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import type {WithReportOrNotFoundProps} from './home/report/withReportOrNotFound';
+import withReportOrNotFound from './home/report/withReportOrNotFound';
+
+type ReportChangeWorkspacePageProps = WithReportOrNotFoundProps & PlatformStackScreenProps;
+
+function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) {
+ const reportID = report?.reportID;
+ const {isOffline} = useNetwork();
+ const styles = useThemeStyles();
+ const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');
+ const {translate} = useLocalize();
+
+ const [policies, fetchStatus] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
+ const oldPolicy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`];
+ const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email});
+ const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP);
+ const shouldShowLoadingIndicator = isLoadingApp && !isOffline;
+
+ const selectPolicy = useCallback(
+ (policyID?: string) => {
+ if (!policyID) {
+ return;
+ }
+ Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(reportID));
+ changeReportPolicy(reportID, policyID);
+ },
+ [reportID],
+ );
+
+ const {sections, shouldShowNoResultsFoundMessage, shouldShowSearchInput} = useWorkspaceList({
+ policies,
+ currentUserLogin,
+ isOffline,
+ selectedPolicyID: report.policyID,
+ searchTerm: debouncedSearchTerm,
+ additionalFilter: (newPolicy) => isWorkspaceEligibleForReportChange(newPolicy, report, oldPolicy, currentUserLogin),
+ });
+
+ return (
+
+ {({didScreenTransitionEnd}) => (
+ <>
+
+ {shouldShowLoadingIndicator ? (
+
+ ) : (
+
+ ListItem={UserListItem}
+ sections={sections}
+ onSelectRow={(option) => selectPolicy(option.policyID)}
+ textInputLabel={shouldShowSearchInput ? translate('common.search') : undefined}
+ textInputValue={searchTerm}
+ onChangeText={setSearchTerm}
+ headerMessage={shouldShowNoResultsFoundMessage ? translate('common.noResultsFound') : ''}
+ initiallyFocusedOptionKey={report.policyID}
+ showLoadingPlaceholder={fetchStatus.status === 'loading' || !didScreenTransitionEnd}
+ />
+ )}
+ >
+ )}
+
+ );
+}
+
+ReportChangeWorkspacePage.displayName = 'ReportChangeWorkspacePage';
+
+export default withReportOrNotFound()(ReportChangeWorkspacePage);
diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx
index 2ec71a04a965..fc7f0beadb82 100644
--- a/src/pages/Search/AdvancedSearchFilters.tsx
+++ b/src/pages/Search/AdvancedSearchFilters.tsx
@@ -293,7 +293,7 @@ function getFilterDisplayTitle(filters: Partial, filt
return dateValue;
}
- const nonDateFilterKey = filterKey as Exclude;
+ const nonDateFilterKey = filterKey as Exclude;
if (nonDateFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) {
const {lessThan, greaterThan} = filters;
diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx
index 922ee515b6c3..a2af16a67a6e 100644
--- a/src/pages/Search/SearchMoneyRequestReportPage.tsx
+++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx
@@ -12,6 +12,7 @@ import type {SearchQueryJSON} from '@components/Search/types';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types';
import {buildSearchQueryJSON} from '@libs/SearchQueryUtils';
@@ -52,7 +53,9 @@ function TemporaryMoneyRequestReportView({report, policy}: TemporaryMoneyRequest
policy={policy}
reportActions={[]}
transactionThreadReportID={undefined}
- onBackButtonPress={() => {}}
+ onBackButtonPress={() => {
+ Navigation.goBack();
+ }}
/>
);
diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx
index 226f83d99006..1732980aabf4 100644
--- a/src/pages/Search/SearchPage.tsx
+++ b/src/pages/Search/SearchPage.tsx
@@ -19,7 +19,6 @@ import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types';
import {buildCannedSearchQuery, buildSearchQueryJSON, getPolicyIDFromSearchQuery} from '@libs/SearchQueryUtils';
-import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import SearchPageNarrow from './SearchPageNarrow';
@@ -32,7 +31,7 @@ function SearchPage({route}: SearchPageProps) {
const {shouldUseNarrowLayout} = useResponsiveLayout();
const styles = useThemeStyles();
- const {q, name, groupBy} = route.params;
+ const {q, name} = route.params;
const {queryJSON, policyID} = useMemo(() => {
const parsedQuery = buildSearchQueryJSON(q);
@@ -44,8 +43,6 @@ function SearchPage({route}: SearchPageProps) {
const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery()}));
const {clearSelectedTransactions} = useSearchContext();
- const shouldGroupByReports = groupBy === CONST.SEARCH.GROUP_BY.REPORTS;
-
const isSearchNameModified = name === q;
const searchName = isSearchNameModified ? undefined : name;
@@ -54,7 +51,6 @@ function SearchPage({route}: SearchPageProps) {
);
@@ -83,10 +79,7 @@ function SearchPage({route}: SearchPageProps) {
breadcrumbLabel={translate('common.reports')}
shouldDisplaySearch={false}
/>
-
+
) : (
-
+
diff --git a/src/pages/Search/SearchPageNarrow.tsx b/src/pages/Search/SearchPageNarrow.tsx
index 668fb1291365..8a747b5d3871 100644
--- a/src/pages/Search/SearchPageNarrow.tsx
+++ b/src/pages/Search/SearchPageNarrow.tsx
@@ -35,10 +35,9 @@ type SearchPageNarrowProps = {
queryJSON?: SearchQueryJSON;
policyID?: string;
searchName?: string;
- shouldGroupByReports?: boolean;
};
-function SearchPageNarrow({queryJSON, policyID, searchName, shouldGroupByReports}: SearchPageNarrowProps) {
+function SearchPageNarrow({queryJSON, policyID, searchName}: SearchPageNarrowProps) {
const {translate} = useLocalize();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const {windowHeight} = useWindowDimensions();
@@ -157,7 +156,6 @@ function SearchPageNarrow({queryJSON, policyID, searchName, shouldGroupByReports
topBarOffset.set(StyleUtils.searchHeaderDefaultOffset);
setSearchRouterListVisible(true);
}}
- shouldGroupByReports={shouldGroupByReports}
/>
@@ -185,7 +183,6 @@ function SearchPageNarrow({queryJSON, policyID, searchName, shouldGroupByReports
>
)}
@@ -197,7 +194,6 @@ function SearchPageNarrow({queryJSON, policyID, searchName, shouldGroupByReports
onSearchListScroll={scrollHandler}
onContentSizeChange={onContentSizeChange}
contentContainerStyle={!selectionMode?.isEnabled ? [styles.searchListContentContainerStyles] : undefined}
- shouldGroupByReports={shouldGroupByReports}
/>
)}
diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx
index 110ae4290195..4c5fb2522220 100644
--- a/src/pages/Search/SearchTypeMenu.tsx
+++ b/src/pages/Search/SearchTypeMenu.tsx
@@ -37,10 +37,9 @@ import SavedSearchItemThreeDotMenu from './SavedSearchItemThreeDotMenu';
type SearchTypeMenuProps = {
queryJSON: SearchQueryJSON;
- shouldGroupByReports?: boolean;
};
-function SearchTypeMenu({queryJSON, shouldGroupByReports}: SearchTypeMenuProps) {
+function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) {
const {type, hash} = queryJSON;
const styles = useThemeStyles();
const {singleExecution} = useSingleExecution();
@@ -171,8 +170,8 @@ function SearchTypeMenu({queryJSON, shouldGroupByReports}: SearchTypeMenuProps)
const isCannedQuery = isCannedSearchQuery(queryJSON);
const activeItemIndex = isCannedQuery
? typeMenuItems.findIndex((item) => {
- if (shouldGroupByReports) {
- return item.translationPath === 'common.expenseReports';
+ if (queryJSON.groupBy === CONST.SEARCH.GROUP_BY.REPORTS) {
+ return item.translationPath === 'common.expenseReports' && item.type === type;
}
return item.type === type;
})
diff --git a/src/pages/TransactionDuplicate/Confirmation.tsx b/src/pages/TransactionDuplicate/Confirmation.tsx
index 805fda5c298a..10f4fec06539 100644
--- a/src/pages/TransactionDuplicate/Confirmation.tsx
+++ b/src/pages/TransactionDuplicate/Confirmation.tsx
@@ -50,7 +50,7 @@ function Confirmation() {
(action) => ReportActionsUtils.isMoneyRequestAction(action) && ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID === reviewDuplicates?.transactionID,
);
- const transactionsMergeParams = useMemo(() => TransactionUtils.buildTransactionsMergeParams(reviewDuplicates, transaction), [reviewDuplicates, transaction]);
+ const transactionsMergeParams = useMemo(() => TransactionUtils.buildMergeDuplicatesParams(reviewDuplicates, transaction), [reviewDuplicates, transaction]);
const isReportOwner = iouReport?.ownerAccountID === currentUserPersonalDetails?.accountID;
const mergeDuplicates = useCallback(() => {
diff --git a/src/pages/WorkspaceSwitcherPage/index.tsx b/src/pages/WorkspaceSwitcherPage/index.tsx
index e9dc78b56c1f..a18a50073ceb 100644
--- a/src/pages/WorkspaceSwitcherPage/index.tsx
+++ b/src/pages/WorkspaceSwitcherPage/index.tsx
@@ -2,34 +2,23 @@ import React, {useCallback, useMemo} from 'react';
import {useOnyx} from 'react-native-onyx';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import * as Expensicons from '@components/Icon/Expensicons';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
-import type {ListItem, SectionListDataType} from '@components/SelectionList/types';
import UserListItem from '@components/SelectionList/UserListItem';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
+import type {WorkspaceListItem} from '@hooks/useWorkspaceList';
+import useWorkspaceList from '@hooks/useWorkspaceList';
import Navigation from '@libs/Navigation/Navigation';
-import {isPolicyAdmin, shouldShowPolicy, sortWorkspacesBySelected} from '@libs/PolicyUtils';
-import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils';
import {getWorkspacesBrickRoads, getWorkspacesUnreadStatuses} from '@libs/WorkspacesSettingsUtils';
-import type {BrickRoad} from '@libs/WorkspacesSettingsUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import {isEmptyObject} from '@src/types/utils/EmptyObject';
import switchPolicyAfterInteractions from './switchPolicyAfterInteractions';
import WorkspaceCardCreateAWorkspace from './WorkspaceCardCreateAWorkspace';
-type WorkspaceListItem = {
- text: string;
- policyID?: string;
- isPolicyAdmin?: boolean;
- brickRoadIndicator?: BrickRoad;
-} & ListItem;
-
const WorkspaceCardCreateAWorkspaceInstance = ;
function WorkspaceSwitcherPage() {
@@ -79,6 +68,17 @@ function WorkspaceSwitcherPage() {
[unreadStatusesForPolicies],
);
+ const {sections, shouldShowNoResultsFoundMessage, shouldShowSearchInput, shouldShowCreateWorkspace} = useWorkspaceList({
+ policies,
+ isOffline,
+ currentUserLogin,
+ selectedPolicyID: activeWorkspaceID,
+ searchTerm: debouncedSearchTerm,
+ isWorkspaceSwitcher: true,
+ hasUnreadData,
+ getIndicatorTypeForPolicy,
+ });
+
const selectPolicy = useCallback(
(policyID?: string) => {
const newPolicyID = policyID === activeWorkspaceID ? undefined : policyID;
@@ -91,55 +91,6 @@ function WorkspaceSwitcherPage() {
[activeWorkspaceID],
);
- const usersWorkspaces = useMemo(() => {
- if (!policies || isEmptyObject(policies)) {
- return [];
- }
-
- return Object.values(policies)
- .filter((policy) => shouldShowPolicy(policy, !!isOffline, currentUserLogin) && !policy?.isJoinRequestPending)
- .map((policy) => ({
- text: policy?.name ?? '',
- policyID: policy?.id,
- brickRoadIndicator: getIndicatorTypeForPolicy(policy?.id),
- icons: [
- {
- source: policy?.avatarURL ? policy.avatarURL : getDefaultWorkspaceAvatar(policy?.name),
- fallbackIcon: Expensicons.FallbackWorkspaceAvatar,
- name: policy?.name,
- type: CONST.ICON_TYPE_WORKSPACE,
- id: policy?.id,
- },
- ],
- isBold: hasUnreadData(policy?.id),
- keyForList: policy?.id,
- isPolicyAdmin: isPolicyAdmin(policy),
- isSelected: activeWorkspaceID === policy?.id,
- }));
- }, [policies, isOffline, currentUserLogin, getIndicatorTypeForPolicy, hasUnreadData, activeWorkspaceID]);
-
- const filteredAndSortedUserWorkspaces = useMemo(
- () =>
- usersWorkspaces
- .filter((policy) => policy.text?.toLowerCase().includes(debouncedSearchTerm?.toLowerCase() ?? ''))
- .sort((policy1, policy2) => sortWorkspacesBySelected({policyID: policy1.policyID, name: policy1.text}, {policyID: policy2.policyID, name: policy2.text}, activeWorkspaceID)),
- [debouncedSearchTerm, usersWorkspaces, activeWorkspaceID],
- );
-
- const sections = useMemo(() => {
- const options: Array> = [
- {
- data: filteredAndSortedUserWorkspaces,
- shouldShow: true,
- indexOffset: 1,
- },
- ];
- return options;
- }, [filteredAndSortedUserWorkspaces]);
-
- const headerMessage = filteredAndSortedUserWorkspaces.length === 0 && usersWorkspaces.length ? translate('common.noResultsFound') : '';
- const shouldShowCreateWorkspace = usersWorkspaces.length === 0;
-
return (
selectPolicy(option.policyID)}
- textInputLabel={usersWorkspaces.length >= CONST.STANDARD_LIST_ITEM_LIMIT ? translate('common.search') : undefined}
+ textInputLabel={shouldShowSearchInput ? translate('common.search') : undefined}
textInputValue={searchTerm}
onChangeText={setSearchTerm}
- headerMessage={headerMessage}
+ headerMessage={shouldShowNoResultsFoundMessage ? translate('common.noResultsFound') : ''}
listEmptyContent={WorkspaceCardCreateAWorkspaceInstance}
shouldShowListEmptyContent={shouldShowCreateWorkspace}
initiallyFocusedOptionKey={activeWorkspaceID ?? CONST.WORKSPACE_SWITCHER.NAME}
diff --git a/src/pages/home/HeaderView.tsx b/src/pages/home/HeaderView.tsx
index f6874093ebd0..faf0aeb2e963 100644
--- a/src/pages/home/HeaderView.tsx
+++ b/src/pages/home/HeaderView.tsx
@@ -112,7 +112,6 @@ function HeaderView({report, parentReportAction, onNavigationMenuButtonClicked,
const [lastDayFreeTrial] = useOnyx(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL);
const [account] = useOnyx(ONYXKEYS.ACCOUNT);
const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`);
- const [sidePane] = useOnyx(ONYXKEYS.NVP_SIDE_PANE);
const [isDismissedDiscountBanner, setIsDismissedDiscountBanner] = useState(false);
const {translate} = useLocalize();
@@ -205,7 +204,6 @@ function HeaderView({report, parentReportAction, onNavigationMenuButtonClicked,
const isParentReportLoading = !!report?.parentReportID && !parentReport;
const isReportInRHP = route.name === SCREENS.SEARCH.REPORT_RHP;
- const shouldDisplaySidePane = !!sidePane;
const shouldDisplaySearchRouter = !isReportInRHP || isSmallScreenWidth;
const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED);
const isChatUsedForOnboarding = isChatUsedForOnboardingReportUtils(report, onboardingPurposeSelected);
@@ -355,8 +353,8 @@ function HeaderView({report, parentReportAction, onNavigationMenuButtonClicked,
{!shouldUseNarrowLayout && isOpenTaskReport(report, parentReportAction) && }
{!isParentReportLoading && canJoin && !shouldUseNarrowLayout && joinButton}
- {shouldDisplaySidePane && }
- {shouldDisplaySearchRouter && }
+
+ {shouldDisplaySearchRouter && }
;
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) {
children = ;
+ } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY) {
+ children = ;
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION) {
children = ;
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION) {
children = ;
} else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION)) {
children = ;
+ } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.RESOLVED_DUPLICATES)) {
+ children = ;
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_NAME) {
children = ;
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DESCRIPTION) {
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
index 69373cb78ff4..92bed91f2930 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
+++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
@@ -15,13 +15,13 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'
import useDebounce from '@hooks/useDebounce';
import useLocalize from '@hooks/useLocalize';
import localeCompare from '@libs/LocaleCompare';
-import * as LoginUtils from '@libs/LoginUtils';
-import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
+import {areEmailsFromSamePrivateDomain} from '@libs/LoginUtils';
+import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils';
import getPolicyEmployeeAccountIDs from '@libs/PolicyEmployeeListUtils';
-import * as ReportUtils from '@libs/ReportUtils';
-import * as SuggestionsUtils from '@libs/SuggestionUtils';
+import {canReportBeMentionedWithinPolicy, doesReportBelongToWorkspace, getDisplayNameForParticipant, isGroupChat, isReportParticipant} from '@libs/ReportUtils';
+import {trimLeadingSpace} from '@libs/SuggestionUtils';
import {isValidRoomName} from '@libs/ValidationUtils';
-import * as ReportUserActions from '@userActions/Report';
+import {searchInServer} from '@userActions/Report';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PersonalDetails, PersonalDetailsList, Report} from '@src/types/onyx';
@@ -58,7 +58,7 @@ type SuggestionPersonalDetailsList = Record<
>;
function getDisplayName(details: PersonalDetails) {
- const displayNameFromAccountID = ReportUtils.getDisplayNameForParticipant({accountID: details.accountID});
+ const displayNameFromAccountID = getDisplayNameForParticipant({accountID: details.accountID});
if (!displayNameFromAccountID) {
return details.login?.length ? details.login : '';
}
@@ -102,7 +102,7 @@ function SuggestionMention(
// Smaller weight means higher order in suggestion list
const getPersonalDetailsWeight = useCallback(
(detail: PersonalDetails, policyEmployeeAccountIDs: number[]): number => {
- if (ReportUtils.isReportParticipant(detail.accountID, currentReport)) {
+ if (isReportParticipant(detail.accountID, currentReport)) {
return 0;
}
if (policyEmployeeAccountIDs.includes(detail.accountID)) {
@@ -114,7 +114,7 @@ function SuggestionMention(
);
const weightedPersonalDetails: PersonalDetailsList | SuggestionPersonalDetailsList | undefined = useMemo(() => {
const policyEmployeeAccountIDs = getPolicyEmployeeAccountIDs(policyID);
- if (!ReportUtils.isGroupChat(currentReport) && !ReportUtils.doesReportBelongToWorkspace(currentReport, policyEmployeeAccountIDs, policyID)) {
+ if (!isGroupChat(currentReport) && !doesReportBelongToWorkspace(currentReport, policyEmployeeAccountIDs, policyID)) {
return personalDetails;
}
return lodashMapValues(personalDetails, (detail) =>
@@ -157,7 +157,7 @@ function SuggestionMention(
useCallback(() => {
const foundSuggestionsCount = suggestionValues.suggestedMentions.length;
if (suggestionValues.prefixType === '#' && foundSuggestionsCount < 5 && isGroupPolicyReport) {
- ReportUserActions.searchInServer(suggestionValues.mentionPrefix, policyID);
+ searchInServer(suggestionValues.mentionPrefix, policyID);
}
}, [suggestionValues.suggestedMentions.length, suggestionValues.prefixType, suggestionValues.mentionPrefix, policyID, isGroupPolicyReport]),
CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME,
@@ -169,7 +169,7 @@ function SuggestionMention(
return displayText;
}
// If the emails are not in the same private domain, we also return the displayText
- if (!LoginUtils.areEmailsFromSamePrivateDomain(displayText, currentUserPersonalDetails.login ?? '')) {
+ if (!areEmailsFromSamePrivateDomain(displayText, currentUserPersonalDetails.login ?? '')) {
return Str.removeSMSDomain(displayText);
}
@@ -203,7 +203,7 @@ function SuggestionMention(
const mentionCode = getMentionCode(mentionObject, suggestionValues.prefixType);
const commentAfterMention = value.slice(suggestionValues.atSignIndex + suggestionValues.mentionPrefix.length + 1);
- updateComment(`${commentBeforeAtSign}${mentionCode} ${SuggestionsUtils.trimLeadingSpace(commentAfterMention)}`, true);
+ updateComment(`${commentBeforeAtSign}${mentionCode} ${trimLeadingSpace(commentAfterMention)}`, true);
const selectionPosition = suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH;
setSelection({
start: selectionPosition,
@@ -280,7 +280,9 @@ function SuggestionMention(
});
}
- const filteredPersonalDetails = Object.values(personalDetailsParam ?? {}).filter((detail, index, array) => {
+ // Create a set to track logins that have already been seen
+ const seenLogins = new Set();
+ const filteredPersonalDetails = Object.values(personalDetailsParam ?? {}).filter((detail) => {
// If we don't have user's primary login, that member is not known to the current user and hence we do not allow them to be mentioned
if (!detail?.login || detail.isOptimisticPersonalDetail) {
return false;
@@ -289,7 +291,7 @@ function SuggestionMention(
if (CONST.RESTRICTED_EMAILS.includes(detail.login) || CONST.RESTRICTED_ACCOUNT_IDS.includes(detail.accountID)) {
return false;
}
- const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(detail);
+ const displayName = getDisplayNameOrDefault(detail);
const displayText = displayName === formatPhoneNumber(detail.login) ? displayName : `${displayName} ${detail.login}`;
if (searchValue && !displayText.toLowerCase().includes(searchValue.toLowerCase())) {
return false;
@@ -304,7 +306,11 @@ function SuggestionMention(
// on staging server, in specific cases (see issue) BE returns duplicated personalDetails
// entries with the same `login` which we need to filter out
- return array.findIndex((arrayDetail) => arrayDetail?.login === detail?.login) === index;
+ if (seenLogins.has(detail.login)) {
+ return false;
+ }
+ seenLogins.add(detail.login);
+ return true;
}) as Array;
// At this point we are sure that the details are not null, since empty user details have been filtered in the previous step
@@ -312,7 +318,7 @@ function SuggestionMention(
sortedPersonalDetails.slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length).forEach((detail) => {
suggestions.push({
- text: formatLoginPrivateDomain(PersonalDetailsUtils.getDisplayNameOrDefault(detail), detail?.login),
+ text: formatLoginPrivateDomain(getDisplayNameOrDefault(detail), detail?.login),
alternateText: `@${formatLoginPrivateDomain(detail?.login, detail?.login)}`,
handle: detail?.login,
icons: [
@@ -336,7 +342,7 @@ function SuggestionMention(
(searchTerm: string, reportBatch: OnyxCollection): Mention[] => {
const filteredRoomMentions: Mention[] = [];
Object.values(reportBatch ?? {}).forEach((report) => {
- if (!ReportUtils.canReportBeMentionedWithinPolicy(report, policyID)) {
+ if (!canReportBeMentionedWithinPolicy(report, policyID)) {
return;
}
if (report?.reportName?.toLowerCase().includes(searchTerm.toLowerCase())) {
diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx
index 6924725eb99b..5c140589e4f3 100644
--- a/src/pages/home/report/ReportActionsList.tsx
+++ b/src/pages/home/report/ReportActionsList.tsx
@@ -177,6 +177,8 @@ function ReportActionsList({
const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID});
const participantsContext = useContext(PersonalDetailsContext);
+ const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(false);
+
useEffect(() => {
const unsubscriber = Visibility.onVisibilityChange(() => {
setIsVisible(Visibility.isVisible());
@@ -458,10 +460,11 @@ function ReportActionsList({
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report.reportID));
return;
}
- reportScrollManager.scrollToBottom();
+
+ setIsScrollToBottomEnabled(true);
});
},
- [reportScrollManager, report.reportID],
+ [report.reportID],
);
useEffect(() => {
// Why are we doing this, when in the cleanup of the useEffect we are already calling the unsubscribe function?
@@ -667,8 +670,12 @@ function ReportActionsList({
const onLayoutInner = useCallback(
(event: LayoutChangeEvent) => {
onLayout(event);
+ if (isScrollToBottomEnabled) {
+ reportScrollManager.scrollToBottom();
+ setIsScrollToBottomEnabled(false);
+ }
},
- [onLayout],
+ [isScrollToBottomEnabled, onLayout, reportScrollManager],
);
const onContentSizeChangeInner = useCallback(
(w: number, h: number) => {
diff --git a/src/pages/home/report/withReportOrNotFound.tsx b/src/pages/home/report/withReportOrNotFound.tsx
index 55fc168d3d7c..9c94d2c3f501 100644
--- a/src/pages/home/report/withReportOrNotFound.tsx
+++ b/src/pages/home/report/withReportOrNotFound.tsx
@@ -5,19 +5,20 @@ import React, {useEffect} from 'react';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import {openReport} from '@libs/actions/Report';
import getComponentDisplayName from '@libs/getComponentDisplayName';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
-import * as ReportUtils from '@libs/ReportUtils';
+import {canAccessReport} from '@libs/ReportUtils';
import type {
ParticipantsNavigatorParamList,
PrivateNotesNavigatorParamList,
+ ReportChangeWorkspaceNavigatorParamList,
ReportDescriptionNavigatorParamList,
ReportDetailsNavigatorParamList,
ReportSettingsNavigatorParamList,
RoomMembersNavigatorParamList,
} from '@navigation/types';
import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
-import * as Report from '@userActions/Report';
import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import type * as OnyxTypes from '@src/types/onyx';
@@ -49,7 +50,8 @@ type ScreenProps =
| PlatformStackScreenProps
| PlatformStackScreenProps
| PlatformStackScreenProps
- | PlatformStackScreenProps;
+ | PlatformStackScreenProps
+ | PlatformStackScreenProps;
type WithReportOrNotFoundProps = WithReportOrNotFoundOnyxProps & {
route: ScreenProps['route'];
@@ -82,13 +84,13 @@ export default function (
return;
}
- Report.openReport(props.route.params.reportID);
+ openReport(props.route.params.reportID);
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [shouldFetchReport, isReportLoaded, props.route.params.reportID]);
if (shouldRequireReportID || isReportIdInRoute) {
const shouldShowFullScreenLoadingIndicator = !isReportLoaded && (isLoadingReportData !== false || shouldFetchReport);
- const shouldShowNotFoundPage = !isReportLoaded || !ReportUtils.canAccessReport(report, policies, betas);
+ const shouldShowNotFoundPage = !isReportLoaded || !canAccessReport(report, policies, betas);
// If the content was shown, but it's not anymore, that means the report was deleted, and we are probably navigating out of this screen.
// Return null for this case to avoid rendering FullScreenLoadingIndicator or NotFoundPage when animating transition.
diff --git a/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx
index 6f064d136182..e04a0a6ed83b 100644
--- a/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx
+++ b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx
@@ -27,7 +27,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions';
import {startMoneyRequest} from '@libs/actions/IOU';
import {openExternalLink, openOldDotLink} from '@libs/actions/Link';
import {navigateToQuickAction} from '@libs/actions/QuickActionNavigation';
-import {startNewChat} from '@libs/actions/Report';
+import {createNewReport, startNewChat} from '@libs/actions/Report';
import {isAnonymousUser} from '@libs/actions/Session';
import {canActionTask as canActionTaskUtils, canModifyTask as canModifyTaskUtils, completeTask} from '@libs/actions/Task';
import {setSelfTourViewed} from '@libs/actions/Welcome';
@@ -36,7 +36,13 @@ import interceptAnonymousUser from '@libs/interceptAnonymousUser';
import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction';
import Navigation from '@libs/Navigation/Navigation';
import {hasSeenTourSelector} from '@libs/onboardingSelectors';
-import {areAllGroupPoliciesExpenseChatDisabled, canSendInvoice as canSendInvoicePolicyUtils, shouldShowPolicy} from '@libs/PolicyUtils';
+import {
+ areAllGroupPoliciesExpenseChatDisabled,
+ canSendInvoice as canSendInvoicePolicyUtils,
+ getGroupPaidPoliciesWithExpenseChatEnabled,
+ isPaidGroupPolicy,
+ shouldShowPolicy,
+} from '@libs/PolicyUtils';
import {canCreateRequest, generateReportID, getDisplayNameForParticipant, getIcons, getReportName, getWorkspaceChats, isArchivedReport, isPolicyExpenseChat} from '@libs/ReportUtils';
import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils';
import {getNavatticURL} from '@libs/TourUtils';
@@ -195,7 +201,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, isT
const prevIsFocused = usePrevious(isFocused);
const {isOffline} = useNetwork();
- const {canUseSpotnanaTravel} = usePermissions();
+ const {canUseSpotnanaTravel, canUseTableReportView} = usePermissions();
const canSendInvoice = useMemo(() => canSendInvoicePolicyUtils(allPolicies as OnyxCollection, session?.email), [allPolicies, session?.email]);
const isValidReport = !(isEmptyObject(quickActionReport) || isArchivedReport(reportNameValuePairs));
const {environment} = useEnvironment();
@@ -212,6 +218,8 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, isT
isCreateMenuActive && (!shouldUseNarrowLayout || isFocused),
);
+ const groupPoliciesWithChatEnabled = useMemo(() => getGroupPaidPoliciesWithExpenseChatEnabled(allPolicies as OnyxCollection), [allPolicies]);
+
/**
* There are scenarios where users who have not yet had their group workspace-chats in NewDot (isPolicyExpenseChatEnabled). In those scenarios, things can get confusing if they try to submit/track expenses. To address this, we block them from Creating, Tracking, Submitting expenses from NewDot if they are:
* 1. on at least one group policy
@@ -470,6 +478,38 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, isT
const menuItems = [
...expenseMenuItems,
+ ...(canUseTableReportView
+ ? [
+ {
+ icon: Expensicons.Document,
+ text: translate('report.newReport.createReport'),
+ onSelected: () => {
+ interceptAnonymousUser(() => {
+ if (groupPoliciesWithChatEnabled.length === 0) {
+ setModalVisible(true);
+ return;
+ }
+
+ // If the user's default workspace is a paid group workspace with chat enabled, we create a report with it by default
+ if (activePolicy && activePolicy.isPolicyExpenseChatEnabled && isPaidGroupPolicy(activePolicy)) {
+ const createdReportID = createNewReport(currentUserPersonalDetails, activePolicyID);
+ Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: createdReportID, backTo: Navigation.getActiveRoute()}));
+ return;
+ }
+
+ if (groupPoliciesWithChatEnabled.length === 1) {
+ const createdReportID = createNewReport(currentUserPersonalDetails, groupPoliciesWithChatEnabled.at(0)?.id);
+ Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: createdReportID, backTo: Navigation.getActiveRoute()}));
+ return;
+ }
+
+ // If the user's default workspace is personal and the user has more than one group workspace which is paid and has chat enabled, we need to redirect them to the workspace selection screen
+ Navigation.navigate(ROUTES.NEW_REPORT_WORKSPACE_SELECTION);
+ });
+ },
+ },
+ ]
+ : []),
{
icon: Expensicons.ChatBubble,
text: translate('sidebarScreen.fabNewChat'),
diff --git a/src/pages/home/sidebar/SidebarLinks.tsx b/src/pages/home/sidebar/SidebarLinks.tsx
index 2e84f9805f6a..1985c6500d52 100644
--- a/src/pages/home/sidebar/SidebarLinks.tsx
+++ b/src/pages/home/sidebar/SidebarLinks.tsx
@@ -68,7 +68,16 @@ function SidebarLinks({insets, optionListItems, isLoading, priorityMode = CONST.
// or when continuously clicking different LHNs, only apply to small screen
// since getTopmostReportId always returns on other devices
const reportActionID = Navigation.getTopmostReportActionId();
- if ((option.reportID === Navigation.getTopmostReportId() && !reportActionID) || (shouldUseNarrowLayout && isActiveReport(option.reportID) && !reportActionID)) {
+
+ // Prevent opening a new Report page if the user quickly taps on another conversation
+ // before the first one is displayed.
+ const shouldBlockReportNavigation = Navigation.getActiveRoute() !== '/home' && shouldUseNarrowLayout;
+
+ if (
+ (option.reportID === Navigation.getTopmostReportId() && !reportActionID) ||
+ (shouldUseNarrowLayout && isActiveReport(option.reportID) && !reportActionID) ||
+ shouldBlockReportNavigation
+ ) {
return;
}
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(option.reportID));
diff --git a/src/pages/iou/ReceiptDropUI.tsx b/src/pages/iou/ReceiptDropUI.tsx
index db7ff0c6c57a..8b57718a1f41 100644
--- a/src/pages/iou/ReceiptDropUI.tsx
+++ b/src/pages/iou/ReceiptDropUI.tsx
@@ -16,13 +16,13 @@ type ReceiptDropUIProps = {
receiptImageTopPosition?: number;
};
-function ReceiptDropUI({onDrop, receiptImageTopPosition = 0}: ReceiptDropUIProps) {
+function ReceiptDropUI({onDrop, receiptImageTopPosition}: ReceiptDropUIProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
return (
-
+ ([]);
+ const [isDraggingOver, setIsDraggingOver] = useState(false);
+
+ const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false);
+ const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState();
+ const [attachmentInvalidReason, setAttachmentValidReason] = useState();
+ const [pdfFile, setPdfFile] = useState(null);
+ const [isLoadingReceipt, setIsLoadingReceipt] = useState(false);
const [receiptFile, setReceiptFile] = useState>();
const requestType = getRequestType(transaction);
@@ -156,6 +171,16 @@ function IOURequestStepConfirmation({
const isPolicyExpenseChat = useMemo(() => participants?.some((participant) => participant.isPolicyExpenseChat), [participants]);
const formHasBeenSubmitted = useRef(false);
+ const confirmModalPrompt = useMemo(() => {
+ if (!attachmentInvalidReason) {
+ return '';
+ }
+ if (attachmentInvalidReason === 'attachmentPicker.sizeExceededWithLimit') {
+ return translate(attachmentInvalidReason, {maxUploadSizeInMB: CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / (1024 * 1024)});
+ }
+ return translate(attachmentInvalidReason);
+ }, [attachmentInvalidReason, translate]);
+
useFetchRoute(transaction, transaction?.comment?.waypoints, action, shouldUseTransactionDraft(action) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT);
useEffect(() => {
@@ -208,6 +233,51 @@ function IOURequestStepConfirmation({
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [transactionID, requestType, defaultCategory, policy?.id]);
+ /**
+ * Sets the upload receipt error modal content when an invalid receipt is uploaded
+ */
+ const setUploadReceiptError = (isInvalid: boolean, title: TranslationPaths, reason: TranslationPaths) => {
+ setIsAttachmentInvalid(isInvalid);
+ setAttachmentInvalidReasonTitle(title);
+ setAttachmentValidReason(reason);
+ setPdfFile(null);
+ };
+
+ const hideReceiptModal = () => {
+ setIsAttachmentInvalid(false);
+ };
+
+ /**
+ * Sets the Receipt object when dragging and dropping a file
+ */
+ const setReceiptOnDrop = (originalFile: FileObject, isPdfValidated?: boolean) => {
+ validateReceipt(originalFile, setUploadReceiptError).then((isFileValid) => {
+ if (!isFileValid) {
+ return;
+ }
+
+ // If we have a pdf file and if it is not validated then set the pdf file for validation and return
+ if (Str.isPDF(originalFile.name ?? '') && !isPdfValidated) {
+ setPdfFile(originalFile);
+ setIsLoadingReceipt(true);
+ return;
+ }
+
+ // With the image size > 24MB, we use manipulateAsync to resize the image.
+ // It takes a long time so we should display a loading indicator while the resize image progresses.
+ if (Str.isImage(originalFile.name ?? '') && (originalFile?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
+ setIsLoadingReceipt(true);
+ }
+ resizeImageIfNeeded(originalFile).then((file) => {
+ setIsLoadingReceipt(false);
+ // Store the receipt on the transaction object in Onyx
+ const source = URL.createObjectURL(file as Blob);
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ setMoneyRequestReceipt(transactionID, source, file.name || '', true);
+ });
+ });
+ };
+
const navigateBack = useCallback(() => {
// If the action is categorize and there's no policies other than personal one, we simply call goBack(), i.e: dismiss the whole flow together
// We don't need to subscribe to policy_ collection as we only need to check on the latest collection value
@@ -746,79 +816,125 @@ function IOURequestStepConfirmation({
return ;
}
+ const PDFThumbnailView = pdfFile ? (
+ {
+ setPdfFile(null);
+ setIsLoadingReceipt(false);
+ setReceiptOnDrop(pdfFile, true);
+ }}
+ onPassword={() => {
+ setIsLoadingReceipt(false);
+ setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.protectedPDFNotSupported');
+ }}
+ onLoadError={() => {
+ setIsLoadingReceipt(false);
+ setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.errorWhileSelectingCorruptedAttachment');
+ }}
+ />
+ ) : null;
+
+ const shouldShowThreeDotsButton =
+ requestType === CONST.IOU.REQUEST_TYPE.MANUAL && (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.TRACK) && !isMovingTransactionFromTrackExpense;
+
return (
-
-
- {isLoading && }
- {!!gpsRequired && (
- setStartLocationPermissionFlow(false)}
- onGrant={() => {
- navigateAfterInteraction(() => {
- createTransaction(selectedParticipantList, true);
- });
- }}
- onDeny={() => {
- updateLastLocationPermissionPrompt();
- navigateAfterInteraction(() => {
- createTransaction(selectedParticipantList, false);
- });
- }}
- onInitialGetLocationCompleted={() => {
- setIsConfirming(false);
+
+
+
+ {(isLoading || isLoadingReceipt) && }
+ {PDFThumbnailView}
+ {
+ const file = e?.dataTransfer?.files[0];
+ if (file) {
+ file.uri = URL.createObjectURL(file);
+ setReceiptOnDrop(file);
+ }
}}
/>
- )}
-
-
+
+ {!!gpsRequired && (
+ setStartLocationPermissionFlow(false)}
+ onGrant={() => {
+ navigateAfterInteraction(() => {
+ createTransaction(selectedParticipantList, true);
+ });
+ }}
+ onDeny={() => {
+ updateLastLocationPermissionPrompt();
+ navigateAfterInteraction(() => {
+ createTransaction(selectedParticipantList, false);
+ });
+ }}
+ onInitialGetLocationCompleted={() => {
+ setIsConfirming(false);
+ }}
+ />
+ )}
+
+
+
);
}
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx
index 3cdb62e0d42a..5817ab06e111 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx
@@ -5,7 +5,6 @@ import {ActivityIndicator, PanResponder, PixelRatio, View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import {RESULTS} from 'react-native-permissions';
import type Webcam from 'react-webcam';
-import type {TupleToUnion} from 'type-fest';
import Hand from '@assets/images/hand.svg';
import ReceiptUpload from '@assets/images/receipt-upload.svg';
import Shutter from '@assets/images/shutter.svg';
@@ -31,7 +30,7 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {clearUserLocation, setUserLocation} from '@libs/actions/UserLocation';
import {isMobile, isMobileWebKit} from '@libs/Browser';
-import {base64ToFile, resizeImageIfNeeded, splitExtensionFromFileName, validateImageForCorruption} from '@libs/fileDownload/FileUtils';
+import {base64ToFile, resizeImageIfNeeded, validateReceipt} from '@libs/fileDownload/FileUtils';
import getCurrentPosition from '@libs/getCurrentPosition';
import {shouldStartLocationPermissionFlow} from '@libs/IOUUtils';
import Log from '@libs/Log';
@@ -232,7 +231,7 @@ function IOURequestStepScan({
});
}, [transaction?.amount, iouType]);
- const hideRecieptModal = () => {
+ const hideReceiptModal = () => {
setIsAttachmentInvalid(false);
};
@@ -246,36 +245,6 @@ function IOURequestStepScan({
setPdfFile(null);
};
- function validateReceipt(file: FileObject) {
- return validateImageForCorruption(file)
- .then(() => {
- const {fileExtension} = splitExtensionFromFileName(file?.name ?? '');
- if (
- !CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes(
- fileExtension.toLowerCase() as TupleToUnion,
- )
- ) {
- setUploadReceiptError(true, 'attachmentPicker.wrongFileType', 'attachmentPicker.notAllowedExtension');
- return false;
- }
-
- if (!Str.isImage(file.name ?? '') && (file?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) {
- setUploadReceiptError(true, 'attachmentPicker.attachmentTooLarge', 'attachmentPicker.sizeExceededWithLimit');
- return false;
- }
-
- if ((file?.size ?? 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) {
- setUploadReceiptError(true, 'attachmentPicker.attachmentTooSmall', 'attachmentPicker.sizeNotMet');
- return false;
- }
- return true;
- })
- .catch(() => {
- setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.errorWhileSelectingCorruptedAttachment');
- return false;
- });
- }
-
const navigateBack = useCallback(() => {
Navigation.goBack(backTo);
}, [backTo]);
@@ -518,7 +487,7 @@ function IOURequestStepScan({
* Sets the Receipt objects and navigates the user to the next page
*/
const setReceiptAndNavigate = (originalFile: FileObject, isPdfValidated?: boolean) => {
- validateReceipt(originalFile).then((isFileValid) => {
+ validateReceipt(originalFile, setUploadReceiptError).then((isFileValid) => {
if (!isFileValid) {
return;
}
@@ -854,8 +823,8 @@ function IOURequestStepScan({
/>
() => CloseAccount.clearError(), []);
+ useEffect(() => () => clearError(), []);
+
+ /**
+ * Extracts values from the non-scraped attribute WEB_PROP_ATTR at build time
+ * to ensure necessary properties are available for further processing.
+ * Reevaluates "fs-class" to dynamically apply styles or behavior based on
+ * updated attribute values.
+ */
+ useLayoutEffect(parseFSAttributes, []);
const hideConfirmModal = () => {
setConfirmModalVisibility(false);
};
const onConfirm = () => {
- User.closeAccount(reasonForLeaving);
+ closeAccount(reasonForLeaving);
hideConfirmModal();
};
@@ -60,7 +69,7 @@ function CloseAccountPage() {
const validate = (values: FormOnyxValues): FormInputErrors => {
const userEmailOrPhone = session?.email ? formatPhoneNumber(session.email) : null;
- const errors = ValidationUtils.getFieldRequiredErrors(values, ['phoneOrEmail']);
+ const errors = getFieldRequiredErrors(values, ['phoneOrEmail']);
if (values.phoneOrEmail && userEmailOrPhone && sanitizePhoneOrEmail(userEmailOrPhone) !== sanitizePhoneOrEmail(values.phoneOrEmail)) {
errors.phoneOrEmail = translate('closeAccountPage.enterYourDefaultContactMethod');
@@ -88,7 +97,11 @@ function CloseAccountPage() {
style={[styles.flexGrow1, styles.mh5]}
isSubmitActionDangerous
>
-
+ {translate('closeAccountPage.reasonForLeavingPrompt')}
+
+
+
);
}
diff --git a/src/pages/settings/Wallet/ReportCardLostPage.tsx b/src/pages/settings/Wallet/ReportCardLostPage.tsx
index 52e5024209d4..64cb1c0422a8 100644
--- a/src/pages/settings/Wallet/ReportCardLostPage.tsx
+++ b/src/pages/settings/Wallet/ReportCardLostPage.tsx
@@ -11,18 +11,18 @@ import ValidateCodeActionModal from '@components/ValidateCodeActionModal';
import useBeforeRemove from '@hooks/useBeforeRemove';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
-import useStyledSafeAreaInsets from '@hooks/useStyledSafeAreaInsets';
+import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
import useThemeStyles from '@hooks/useThemeStyles';
+import {setErrors} from '@libs/actions/FormActions';
import {requestValidateCodeAction} from '@libs/actions/User';
-import * as ErrorUtils from '@libs/ErrorUtils';
+import {getLatestErrorMessageField} from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
-import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
+import {getFormattedAddress} from '@libs/PersonalDetailsUtils';
import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
+import {clearCardListErrors, requestReplacementExpensifyCard} from '@userActions/Card';
import type {ReplacementReason} from '@userActions/Card';
-import * as CardActions from '@userActions/Card';
-import * as FormActions from '@userActions/FormActions';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
@@ -74,14 +74,14 @@ function ReportCardLostPage({
const [shouldShowReasonError, setShouldShowReasonError] = useState(false);
const physicalCard = cardList?.[cardID];
- const validateError = ErrorUtils.getLatestErrorMessageField(physicalCard);
+ const validateError = getLatestErrorMessageField(physicalCard);
const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(false);
const prevIsLoading = usePrevious(formData?.isLoading);
- const {paddingBottom} = useStyledSafeAreaInsets();
+ const {paddingBottom} = useSafeAreaPaddings();
- const formattedAddress = PersonalDetailsUtils.getFormattedAddress(privatePersonalDetails ?? {});
+ const formattedAddress = getFormattedAddress(privatePersonalDetails ?? {});
const primaryLogin = account?.primaryLogin ?? '';
useBeforeRemove(() => setIsValidateCodeActionModalVisible(false));
@@ -99,7 +99,7 @@ function ReportCardLostPage({
return;
}
- FormActions.setErrors(ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, physicalCard?.errors ?? {});
+ setErrors(ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, physicalCard?.errors ?? {});
}, [formData?.isLoading, physicalCard?.errors]);
const handleValidateCodeEntered = useCallback(
@@ -107,7 +107,7 @@ function ReportCardLostPage({
if (!physicalCard) {
return;
}
- CardActions.requestReplacementExpensifyCard(physicalCard.cardID, reason?.key as ReplacementReason, validateCode);
+ requestReplacementExpensifyCard(physicalCard.cardID, reason?.key as ReplacementReason, validateCode);
},
[physicalCard, reason?.key],
);
@@ -198,7 +198,7 @@ function ReportCardLostPage({
sendValidateCode={sendValidateCode}
validateError={validateError}
clearError={() => {
- CardActions.clearCardListErrors(physicalCard.cardID);
+ clearCardListErrors(physicalCard.cardID);
}}
onClose={() => setIsValidateCodeActionModalVisible(false)}
isVisible={isValidateCodeActionModalVisible}
diff --git a/src/pages/settings/Wallet/TransferBalancePage.tsx b/src/pages/settings/Wallet/TransferBalancePage.tsx
index 3d1a21d9cec1..9c573cacb40e 100644
--- a/src/pages/settings/Wallet/TransferBalancePage.tsx
+++ b/src/pages/settings/Wallet/TransferBalancePage.tsx
@@ -1,7 +1,6 @@
import React, {useEffect} from 'react';
import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import ConfirmationPage from '@components/ConfirmationPage';
import CurrentWalletBalance from '@components/CurrentWalletBalance';
@@ -14,45 +13,39 @@ import ScrollView from '@components/ScrollView';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
-import useStyledSafeAreaInsets from '@hooks/useStyledSafeAreaInsets';
+import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
import useThemeStyles from '@hooks/useThemeStyles';
-import * as CurrencyUtils from '@libs/CurrencyUtils';
-import * as ErrorUtils from '@libs/ErrorUtils';
+import {
+ dismissSuccessfulTransferBalancePage,
+ resetWalletTransferData,
+ saveWalletTransferAccountTypeAndID,
+ saveWalletTransferMethodType,
+ transferWalletBalance,
+} from '@libs/actions/PaymentMethods';
+import {convertToDisplayString} from '@libs/CurrencyUtils';
+import {getLatestErrorMessage} from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
-import * as PaymentUtils from '@libs/PaymentUtils';
+import {calculateWalletTransferBalanceFee, formatPaymentMethods, hasExpensifyPaymentMethod} from '@libs/PaymentUtils';
import variables from '@styles/variables';
-import * as PaymentMethods from '@userActions/PaymentMethods';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {BankAccountList, FundList, UserWallet, WalletTransfer} from '@src/types/onyx';
import type PaymentMethod from '@src/types/onyx/PaymentMethod';
import type {FilterMethodPaymentType} from '@src/types/onyx/WalletTransfer';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
-type TransferBalancePageOnyxProps = {
- /** User's wallet information */
- userWallet: OnyxEntry;
-
- /** List of bank accounts */
- bankAccountList: OnyxEntry;
-
- /** List of user's card objects */
- fundList: OnyxEntry;
-
- /** Wallet balance transfer props */
- walletTransfer: OnyxEntry;
-};
-
-type TransferBalancePageProps = TransferBalancePageOnyxProps;
-
const TRANSFER_TIER_NAMES: string[] = [CONST.WALLET.TIER_NAME.GOLD, CONST.WALLET.TIER_NAME.PLATINUM];
-function TransferBalancePage({bankAccountList, fundList, userWallet, walletTransfer}: TransferBalancePageProps) {
+function TransferBalancePage() {
const styles = useThemeStyles();
const {numberFormat, translate} = useLocalize();
const {isOffline} = useNetwork();
- const {paddingBottom} = useStyledSafeAreaInsets();
+ const {paddingBottom} = useSafeAreaPaddings();
+
+ const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET);
+ const [walletTransfer] = useOnyx(ONYXKEYS.WALLET_TRANSFER);
+ const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST);
+ const [fundList] = useOnyx(ONYXKEYS.FUND_LIST);
const paymentCardList = fundList ?? {};
const paymentTypes = [
@@ -61,7 +54,7 @@ function TransferBalancePage({bankAccountList, fundList, userWallet, walletTrans
title: translate('transferAmountPage.instant'),
description: translate('transferAmountPage.instantSummary', {
rate: numberFormat(CONST.WALLET.TRANSFER_METHOD_TYPE_FEE.INSTANT.RATE),
- minAmount: CurrencyUtils.convertToDisplayString(CONST.WALLET.TRANSFER_METHOD_TYPE_FEE.INSTANT.MINIMUM_FEE),
+ minAmount: convertToDisplayString(CONST.WALLET.TRANSFER_METHOD_TYPE_FEE.INSTANT.MINIMUM_FEE),
}),
icon: Expensicons.Bolt,
type: CONST.PAYMENT_METHODS.DEBIT_CARD,
@@ -79,7 +72,7 @@ function TransferBalancePage({bankAccountList, fundList, userWallet, walletTrans
* Get the selected/default payment method account for wallet transfer
*/
function getSelectedPaymentMethodAccount(): PaymentMethod | undefined {
- const paymentMethods = PaymentUtils.formatPaymentMethods(bankAccountList ?? {}, paymentCardList, styles);
+ const paymentMethods = formatPaymentMethods(bankAccountList ?? {}, paymentCardList, styles);
const defaultAccount = paymentMethods.find((method) => method.isDefault);
const selectedAccount = paymentMethods.find(
@@ -89,15 +82,15 @@ function TransferBalancePage({bankAccountList, fundList, userWallet, walletTrans
}
function navigateToChooseTransferAccount(filterPaymentMethodType: FilterMethodPaymentType) {
- PaymentMethods.saveWalletTransferMethodType(filterPaymentMethodType);
+ saveWalletTransferMethodType(filterPaymentMethodType);
// If we only have a single option for the given paymentMethodType do not force the user to make a selection
- const combinedPaymentMethods = PaymentUtils.formatPaymentMethods(bankAccountList ?? {}, paymentCardList, styles);
+ const combinedPaymentMethods = formatPaymentMethods(bankAccountList ?? {}, paymentCardList, styles);
const filteredMethods = combinedPaymentMethods.filter((paymentMethod) => paymentMethod.accountType === filterPaymentMethodType);
if (filteredMethods.length === 1) {
const account = filteredMethods.at(0);
- PaymentMethods.saveWalletTransferAccountTypeAndID(filterPaymentMethodType ?? '', account?.methodID?.toString() ?? '-1');
+ saveWalletTransferAccountTypeAndID(filterPaymentMethodType, account?.methodID?.toString());
return;
}
@@ -106,14 +99,14 @@ function TransferBalancePage({bankAccountList, fundList, userWallet, walletTrans
useEffect(() => {
// Reset to the default account when the page is opened
- PaymentMethods.resetWalletTransferData();
+ resetWalletTransferData();
const selectedAccount = getSelectedPaymentMethodAccount();
if (!selectedAccount) {
return;
}
- PaymentMethods.saveWalletTransferAccountTypeAndID(selectedAccount?.accountType ?? '', selectedAccount?.methodID?.toString() ?? '-1');
+ saveWalletTransferAccountTypeAndID(selectedAccount?.accountType, selectedAccount?.methodID?.toString());
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we only want this effect to run on initial render
}, []);
@@ -122,7 +115,7 @@ function TransferBalancePage({bankAccountList, fundList, userWallet, walletTrans
);
@@ -143,13 +136,13 @@ function TransferBalancePage({bankAccountList, fundList, userWallet, walletTrans
const selectedPaymentType =
selectedAccount && selectedAccount.accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT ? CONST.WALLET.TRANSFER_METHOD_TYPE.ACH : CONST.WALLET.TRANSFER_METHOD_TYPE.INSTANT;
- const calculatedFee = PaymentUtils.calculateWalletTransferBalanceFee(userWallet?.currentBalance ?? 0, selectedPaymentType);
+ const calculatedFee = calculateWalletTransferBalanceFee(userWallet?.currentBalance ?? 0, selectedPaymentType);
const transferAmount = userWallet?.currentBalance ?? 0 - calculatedFee;
const isTransferable = transferAmount > 0;
const isButtonDisabled = !isTransferable || !selectedAccount;
- const errorMessage = ErrorUtils.getLatestErrorMessage(walletTransfer);
+ const errorMessage = getLatestErrorMessage(walletTransfer);
- const shouldShowTransferView = PaymentUtils.hasExpensifyPaymentMethod(paymentCardList, bankAccountList ?? {}) && TRANSFER_TIER_NAMES.includes(userWallet?.tierName ?? '');
+ const shouldShowTransferView = hasExpensifyPaymentMethod(paymentCardList, bankAccountList ?? {}) && TRANSFER_TIER_NAMES.includes(userWallet?.tierName ?? '');
return (
@@ -206,16 +199,16 @@ function TransferBalancePage({bankAccountList, fundList, userWallet, walletTrans
)}
{translate('transferAmountPage.fee')}
- {CurrencyUtils.convertToDisplayString(calculatedFee)}
+ {convertToDisplayString(calculatedFee)} selectedAccount && PaymentMethods.transferWalletBalance(selectedAccount)}
+ onSubmit={() => selectedAccount && transferWalletBalance(selectedAccount)}
isDisabled={isButtonDisabled || isOffline}
message={errorMessage}
isAlertVisible={!isEmptyObject(errorMessage)}
@@ -229,17 +222,4 @@ function TransferBalancePage({bankAccountList, fundList, userWallet, walletTrans
TransferBalancePage.displayName = 'TransferBalancePage';
-export default withOnyx({
- userWallet: {
- key: ONYXKEYS.USER_WALLET,
- },
- walletTransfer: {
- key: ONYXKEYS.WALLET_TRANSFER,
- },
- bankAccountList: {
- key: ONYXKEYS.BANK_ACCOUNT_LIST,
- },
- fundList: {
- key: ONYXKEYS.FUND_LIST,
- },
-})(TransferBalancePage);
+export default TransferBalancePage;
diff --git a/src/pages/signin/SMSDeliveryFailurePage.tsx b/src/pages/signin/SMSDeliveryFailurePage.tsx
index ac8d04f556fa..953a0dfc0a8b 100644
--- a/src/pages/signin/SMSDeliveryFailurePage.tsx
+++ b/src/pages/signin/SMSDeliveryFailurePage.tsx
@@ -29,7 +29,22 @@ function SMSDeliveryFailurePage() {
}, [credentials?.login]);
const SMSDeliveryFailureMessage = account?.smsDeliveryFailureStatus?.message;
+
+ type TimeData = {
+ days?: number;
+ hours?: number;
+ minutes?: number;
+ };
+
+ const timeData = useMemo(() => {
+ if (!SMSDeliveryFailureMessage) {
+ return null;
+ }
+ return JSON.parse(SMSDeliveryFailureMessage) as TimeData;
+ }, [SMSDeliveryFailureMessage]);
+
const hasSMSDeliveryFailure = account?.smsDeliveryFailureStatus?.hasSMSDeliveryFailure;
+
const isReset = account?.smsDeliveryFailureStatus?.isReset;
const errorText = useMemo(() => (account ? getLatestErrorMessage(account) : ''), [account]);
@@ -48,7 +63,7 @@ function SMSDeliveryFailurePage() {
- {translate('smsDeliveryFailurePage.validationFailed')} {SMSDeliveryFailureMessage}
+ {translate('smsDeliveryFailurePage.validationFailed')} {timeData && translate('smsDeliveryFailurePage.pleaseWaitBeforeTryingAgain', {timeData})}
diff --git a/src/pages/signin/SignInPage.tsx b/src/pages/signin/SignInPage.tsx
index 221ca93701be..44fd8776b43f 100644
--- a/src/pages/signin/SignInPage.tsx
+++ b/src/pages/signin/SignInPage.tsx
@@ -302,7 +302,7 @@ function SignInPage({shouldEnableMaxHeight = true}: SignInPageInnerProps, ref: F
isEmptyObject(parentReport) || isAllowedToComment(parentReport), [parentReport]);
- const {paddingBottom} = useStyledSafeAreaInsets();
+ const {paddingBottom} = useSafeAreaPaddings();
const backTo = route.params?.backTo;
const confirmButtonRef = useRef(null);
diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx
index 6cf2a73d3ece..e156aac0c465 100644
--- a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx
+++ b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx
@@ -11,7 +11,7 @@ import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
-import useStyledSafeAreaInsets from '@hooks/useStyledSafeAreaInsets';
+import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import {getCompanyFeeds} from '@libs/CardUtils';
@@ -75,7 +75,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro
const styles = useThemeStyles();
const stylesutils = useStyleUtils();
const {shouldUseNarrowLayout} = useResponsiveLayout();
- const {safeAreaPaddingBottomStyle} = useStyledSafeAreaInsets();
+ const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings();
const {translate} = useLocalize();
const hasAccountingConnection = !isEmptyObject(policy?.connections);
const isAccountingEnabled = !!policy?.areConnectionsEnabled || !isEmptyObject(policy?.connections);
diff --git a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAccountingMethodPage.tsx b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAccountingMethodPage.tsx
index a5c1872158e9..f08f79895cbb 100644
--- a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAccountingMethodPage.tsx
+++ b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAccountingMethodPage.tsx
@@ -9,7 +9,7 @@ import type {SelectorType} from '@components/SelectionScreen';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import * as Connections from '@libs/actions/connections/NetSuiteCommands';
+import {updateNetSuiteAccountingMethod} from '@libs/actions/connections/NetSuiteCommands';
import {settingsPendingAction} from '@libs/PolicyUtils';
import Navigation from '@navigation/Navigation';
import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections';
@@ -24,15 +24,15 @@ type MenuListItem = ListItem & {
function NetSuiteAccountingMethodPage({policy}: WithPolicyConnectionsProps) {
const {translate} = useLocalize();
- const policyID = policy?.id ?? '-1';
+ const policyID = policy?.id;
const styles = useThemeStyles();
const config = policy?.connections?.netsuite?.options?.config;
const autoSyncConfig = policy?.connections?.netsuite?.config;
const accountingMethod = config?.accountingMethod ?? COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH;
const data: MenuListItem[] = Object.values(COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD).map((accountingMethodType) => ({
value: accountingMethodType,
- text: translate(`workspace.netsuite.advancedConfig.accountingMethods.values.${accountingMethodType}` as TranslationPaths),
- alternateText: translate(`workspace.netsuite.advancedConfig.accountingMethods.alternateText.${accountingMethodType}` as TranslationPaths),
+ text: translate(`workspace.accountingMethods.values.${accountingMethodType}` as TranslationPaths),
+ alternateText: translate(`workspace.accountingMethods.alternateText.${accountingMethodType}` as TranslationPaths),
keyForList: accountingMethodType,
isSelected: accountingMethod === accountingMethodType,
}));
@@ -43,7 +43,7 @@ function NetSuiteAccountingMethodPage({policy}: WithPolicyConnectionsProps) {
const headerContent = useMemo(
() => (
- {translate('workspace.netsuite.advancedConfig.accountingMethods.description')}
+ {translate('workspace.accountingMethods.description')}
),
[translate, styles.pb5, styles.ph5],
@@ -52,7 +52,7 @@ function NetSuiteAccountingMethodPage({policy}: WithPolicyConnectionsProps) {
const selectExpenseReportApprovalLevel = useCallback(
(row: MenuListItem) => {
if (row.value !== config?.accountingMethod) {
- Connections.updateNetSuiteAccountingMethod(policyID, row.value, config?.accountingMethod ?? COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH);
+ updateNetSuiteAccountingMethod(policyID, row.value, config?.accountingMethod ?? COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH);
}
Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_AUTO_SYNC.getRoute(policyID));
},
@@ -62,7 +62,7 @@ function NetSuiteAccountingMethodPage({policy}: WithPolicyConnectionsProps) {
return (
Policy.clearNetSuiteAutoSyncErrorField(policyID)}
- onToggle={(isEnabled) => Connections.updateNetSuiteAutoSync(policyID, isEnabled)}
+ onCloseError={() => clearNetSuiteAutoSyncErrorField(policyID)}
+ onToggle={(isEnabled) => updateNetSuiteAutoSync(policyID, isEnabled)}
pendingAction={pendingAction}
- errors={ErrorUtils.getLatestErrorField(autoSyncConfig, CONST.NETSUITE_CONFIG.AUTO_SYNC)}
+ errors={getLatestErrorField(autoSyncConfig, CONST.NETSUITE_CONFIG.AUTO_SYNC)}
/>
Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_ACCOUNTING_METHOD.getRoute(policyID))}
/>
diff --git a/src/pages/workspace/accounting/qbo/advanced/QuickbooksAccountingMethodPage.tsx b/src/pages/workspace/accounting/qbo/advanced/QuickbooksAccountingMethodPage.tsx
new file mode 100644
index 000000000000..4023ae1b0192
--- /dev/null
+++ b/src/pages/workspace/accounting/qbo/advanced/QuickbooksAccountingMethodPage.tsx
@@ -0,0 +1,82 @@
+import {CONST as COMMON_CONST} from 'expensify-common';
+import React, {useCallback, useMemo} from 'react';
+import {View} from 'react-native';
+import type {ValueOf} from 'type-fest';
+import RadioListItem from '@components/SelectionList/RadioListItem';
+import type {ListItem} from '@components/SelectionList/types';
+import SelectionScreen from '@components/SelectionScreen';
+import type {SelectorType} from '@components/SelectionScreen';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {updateQuickbooksOnlineAccountingMethod} from '@libs/actions/connections/QuickbooksOnline';
+import {settingsPendingAction} from '@libs/PolicyUtils';
+import Navigation from '@navigation/Navigation';
+import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections';
+import withPolicyConnections from '@pages/workspace/withPolicyConnections';
+import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
+import ROUTES from '@src/ROUTES';
+
+type MenuListItem = ListItem & {
+ value: ValueOf;
+};
+
+function QuickbooksAccountingMethodPage({policy}: WithPolicyConnectionsProps) {
+ const {translate} = useLocalize();
+ const policyID = policy?.id;
+ const styles = useThemeStyles();
+ const config = policy?.connections?.quickbooksOnline?.config;
+ const accountingMethod = config?.accountingMethod ?? COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH;
+ const data: MenuListItem[] = Object.values(COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD).map((accountingMethodType) => ({
+ value: accountingMethodType,
+ text: translate(`workspace.accountingMethods.values.${accountingMethodType}` as TranslationPaths),
+ alternateText: translate(`workspace.accountingMethods.alternateText.${accountingMethodType}` as TranslationPaths),
+ keyForList: accountingMethodType,
+ isSelected: accountingMethod === accountingMethodType,
+ }));
+
+ const pendingAction =
+ settingsPendingAction([CONST.QUICKBOOKS_CONFIG.AUTO_SYNC], config?.pendingFields) ?? settingsPendingAction([CONST.QUICKBOOKS_CONFIG.ACCOUNTING_METHOD], config?.pendingFields);
+
+ const headerContent = useMemo(
+ () => (
+
+ {translate('workspace.accountingMethods.description')}
+
+ ),
+ [translate, styles.pb5, styles.ph5],
+ );
+
+ const selectExpenseReportApprovalLevel = useCallback(
+ (row: MenuListItem) => {
+ if (row.value !== config?.accountingMethod) {
+ updateQuickbooksOnlineAccountingMethod(policyID, row.value, config?.accountingMethod ?? COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH);
+ }
+ Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_AUTO_SYNC.getRoute(policyID));
+ },
+ [config?.accountingMethod, policyID],
+ );
+
+ return (
+ selectExpenseReportApprovalLevel(selection as MenuListItem)}
+ initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList}
+ policyID={policyID}
+ accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]}
+ featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED}
+ onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_AUTO_SYNC.getRoute(policyID))}
+ connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO}
+ pendingAction={pendingAction}
+ />
+ );
+}
+
+QuickbooksAccountingMethodPage.displayName = 'QuickbooksAccountingMethodPage';
+
+export default withPolicyConnections(QuickbooksAccountingMethodPage);
diff --git a/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx b/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx
index 14be50461112..d2ecb144f76f 100644
--- a/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx
+++ b/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx
@@ -1,3 +1,4 @@
+import {CONST as COMMON_CONST} from 'expensify-common';
import React, {useMemo} from 'react';
import {View} from 'react-native';
import Accordion from '@components/Accordion';
@@ -8,16 +9,16 @@ import useAccordionAnimation from '@hooks/useAccordionAnimation';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import useWaitForNavigation from '@hooks/useWaitForNavigation';
-import * as QuickbooksOnline from '@libs/actions/connections/QuickbooksOnline';
-import * as ErrorUtils from '@libs/ErrorUtils';
+import {updateQuickbooksOnlineAutoCreateVendor, updateQuickbooksOnlineCollectionAccountID, updateQuickbooksOnlineSyncPeople} from '@libs/actions/connections/QuickbooksOnline';
+import {getLatestErrorField} from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
-import * as PolicyUtils from '@libs/PolicyUtils';
-import {settingsPendingAction} from '@libs/PolicyUtils';
+import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils';
import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections';
import withPolicyConnections from '@pages/workspace/withPolicyConnections';
import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow';
import {clearQBOErrorField} from '@userActions/Policy/Policy';
import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
import ROUTES from '@src/ROUTES';
const reimbursementOrCollectionAccountIDs = [CONST.QUICKBOOKS_CONFIG.REIMBURSEMENT_ACCOUNT_ID, CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID];
@@ -28,8 +29,9 @@ function QuickbooksAdvancedPage({policy}: WithPolicyConnectionsProps) {
const waitForNavigate = useWaitForNavigation();
const {translate} = useLocalize();
- const policyID = policy?.id ?? '-1';
+ const policyID = policy?.id;
const qboConfig = policy?.connections?.quickbooksOnline?.config;
+ const accountingMethod = policy?.connections?.quickbooksOnline?.config?.accountingMethod;
const {bankAccounts, creditCards, otherCurrentAssetAccounts, vendors} = policy?.connections?.quickbooksOnline?.data ?? {};
const nonReimbursableBillDefaultVendorObject = vendors?.find((vendor) => vendor.id === qboConfig?.nonReimbursableBillDefaultVendor);
@@ -57,16 +59,16 @@ function QuickbooksAdvancedPage({policy}: WithPolicyConnectionsProps) {
description: translate('workspace.qbo.advancedConfig.qboBillPaymentAccount'),
onPress: waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ACCOUNT_SELECTOR.getRoute(policyID))),
subscribedSettings: reimbursementOrCollectionAccountIDs,
- brickRoadIndicator: PolicyUtils.areSettingsInErrorFields(reimbursementOrCollectionAccountIDs, qboConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined,
- pendingAction: PolicyUtils.settingsPendingAction(reimbursementOrCollectionAccountIDs, qboConfig?.pendingFields),
+ brickRoadIndicator: areSettingsInErrorFields(reimbursementOrCollectionAccountIDs, qboConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined,
+ pendingAction: settingsPendingAction(reimbursementOrCollectionAccountIDs, qboConfig?.pendingFields),
},
{
title: selectedInvoiceCollectionAccountName,
description: translate('workspace.qbo.advancedConfig.qboInvoiceCollectionAccount'),
onPress: waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR.getRoute(policyID))),
subscribedSettings: collectionAccountIDs,
- brickRoadIndicator: PolicyUtils.areSettingsInErrorFields(collectionAccountIDs, qboConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined,
- pendingAction: PolicyUtils.settingsPendingAction(collectionAccountIDs, qboConfig?.pendingFields),
+ brickRoadIndicator: areSettingsInErrorFields(collectionAccountIDs, qboConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined,
+ pendingAction: settingsPendingAction(collectionAccountIDs, qboConfig?.pendingFields),
},
];
@@ -88,24 +90,14 @@ function QuickbooksAdvancedPage({policy}: WithPolicyConnectionsProps) {
);
const qboToggleSettingItems = [
- {
- title: translate('workspace.accounting.autoSync'),
- subtitle: translate('workspace.qbo.advancedConfig.autoSyncDescription'),
- switchAccessibilityLabel: translate('workspace.qbo.advancedConfig.autoSyncDescription'),
- isActive: !!qboConfig?.autoSync?.enabled,
- onToggle: () => QuickbooksOnline.updateQuickbooksOnlineAutoSync(policyID, !qboConfig?.autoSync?.enabled),
- subscribedSetting: CONST.QUICKBOOKS_CONFIG.AUTO_SYNC,
- errors: ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.AUTO_SYNC),
- pendingAction: settingsPendingAction([CONST.QUICKBOOKS_CONFIG.AUTO_SYNC], qboConfig?.pendingFields),
- },
{
title: translate('workspace.qbo.advancedConfig.inviteEmployees'),
subtitle: translate('workspace.qbo.advancedConfig.inviteEmployeesDescription'),
switchAccessibilityLabel: translate('workspace.qbo.advancedConfig.inviteEmployeesDescription'),
isActive: !!qboConfig?.syncPeople,
- onToggle: () => QuickbooksOnline.updateQuickbooksOnlineSyncPeople(policyID, !qboConfig?.syncPeople),
+ onToggle: () => updateQuickbooksOnlineSyncPeople(policyID, !qboConfig?.syncPeople),
subscribedSetting: CONST.QUICKBOOKS_CONFIG.SYNC_PEOPLE,
- errors: ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.SYNC_PEOPLE),
+ errors: getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.SYNC_PEOPLE),
pendingAction: settingsPendingAction([CONST.QUICKBOOKS_CONFIG.SYNC_PEOPLE], qboConfig?.pendingFields),
},
{
@@ -119,7 +111,7 @@ function QuickbooksAdvancedPage({policy}: WithPolicyConnectionsProps) {
: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE;
const nonReimbursableVendorCurrentValue = nonReimbursableBillDefaultVendorObject?.id ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE;
- QuickbooksOnline.updateQuickbooksOnlineAutoCreateVendor(
+ updateQuickbooksOnlineAutoCreateVendor(
policyID,
{
[autoCreateVendorConst]: isOn,
@@ -132,7 +124,7 @@ function QuickbooksAdvancedPage({policy}: WithPolicyConnectionsProps) {
);
},
subscribedSetting: CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR,
- errors: ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR),
+ errors: getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR),
pendingAction: settingsPendingAction([CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR], qboConfig?.pendingFields),
},
{
@@ -141,13 +133,13 @@ function QuickbooksAdvancedPage({policy}: WithPolicyConnectionsProps) {
switchAccessibilityLabel: translate('workspace.qbo.advancedConfig.reimbursedReportsDescription'),
isActive: isSyncReimbursedSwitchOn,
onToggle: () =>
- QuickbooksOnline.updateQuickbooksOnlineCollectionAccountID(
+ updateQuickbooksOnlineCollectionAccountID(
policyID,
isSyncReimbursedSwitchOn ? '' : [...qboAccountOptions, ...invoiceAccountCollectionOptions].at(0)?.id,
qboConfig?.collectionAccountID,
),
subscribedSetting: CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID,
- errors: ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID),
+ errors: getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID),
pendingAction: settingsPendingAction([CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID], qboConfig?.pendingFields),
},
];
@@ -162,6 +154,26 @@ function QuickbooksAdvancedPage({policy}: WithPolicyConnectionsProps) {
contentContainerStyle={[styles.pb2, styles.ph5]}
connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO}
>
+
+ Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_AUTO_SYNC.getRoute(policyID))}
+ brickRoadIndicator={
+ areSettingsInErrorFields([CONST.QUICKBOOKS_CONFIG.AUTO_SYNC, CONST.QUICKBOOKS_CONFIG.ACCOUNTING_METHOD], qboConfig?.errorFields)
+ ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR
+ : undefined
+ }
+ hintText={(() => {
+ if (!qboConfig?.autoSync?.enabled) {
+ return undefined;
+ }
+ return translate(`workspace.accountingMethods.alternateText.${accountingMethod ?? COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH}` as TranslationPaths);
+ })()}
+ />
+
{qboToggleSettingItems.map((item) => (
+
+ Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED.getRoute(policyID))}
+ />
+ clearQuickbooksOnlineAutoSyncErrorField(policyID)}
+ onToggle={(isEnabled) => updateQuickbooksOnlineAutoSync(policyID, isEnabled)}
+ pendingAction={pendingAction}
+ errors={getLatestErrorField(config, CONST.QUICKBOOKS_CONFIG.AUTO_SYNC)}
+ />
+ {!!config?.autoSync?.enabled && (
+
+ Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ACCOUNTING_METHOD.getRoute(policyID))}
+ />
+
+ )}
+
+
+ );
+}
+
+QuickbooksAutoSyncPage.displayName = 'QuickbooksAutoSyncPage';
+
+export default withPolicyConnections(QuickbooksAutoSyncPage);
diff --git a/src/stories/SelectionList.stories.tsx b/src/stories/SelectionList.stories.tsx
index b3dc4c5ae2d2..e993d23dc0ba 100644
--- a/src/stories/SelectionList.stories.tsx
+++ b/src/stories/SelectionList.stories.tsx
@@ -3,7 +3,7 @@ import React, {useMemo, useState} from 'react';
import Badge from '@components/Badge';
import SelectionList from '@components/SelectionList';
import RadioListItem from '@components/SelectionList/RadioListItem';
-import type {BaseSelectionListProps, ListItem} from '@components/SelectionList/types';
+import type {ListItem, SelectionListProps} from '@components/SelectionList/types';
import withNavigationFallback from '@components/withNavigationFallback';
// eslint-disable-next-line no-restricted-imports
import {defaultStyles} from '@styles/index';
@@ -71,7 +71,7 @@ const SECTIONS = [
},
];
-function Default(props: BaseSelectionListProps) {
+function Default(props: SelectionListProps) {
const [selectedIndex, setSelectedIndex] = useState(1);
const sections = props.sections.map((section) => {
@@ -110,7 +110,7 @@ Default.args = {
initiallyFocusedOptionKey: 'option-2',
};
-function WithTextInput(props: BaseSelectionListProps) {
+function WithTextInput(props: SelectionListProps) {
const [searchText, setSearchText] = useState('');
const [selectedIndex, setSelectedIndex] = useState(1);
@@ -162,7 +162,7 @@ WithTextInput.args = {
onChangeText: () => {},
};
-function WithHeaderMessage(props: BaseSelectionListProps) {
+function WithHeaderMessage(props: SelectionListProps) {
return (
) {
+function WithAlternateText(props: SelectionListProps) {
const [selectedIndex, setSelectedIndex] = useState(1);
const sections = props.sections.map((section) => {
@@ -218,7 +218,7 @@ WithAlternateText.args = {
...Default.args,
};
-function MultipleSelection(props: BaseSelectionListProps) {
+function MultipleSelection(props: SelectionListProps) {
const [selectedIds, setSelectedIds] = useState(['option-1', 'option-2']);
const memo = useMemo(() => {
@@ -288,7 +288,7 @@ MultipleSelection.args = {
onSelectAll: () => {},
};
-function WithSectionHeader(props: BaseSelectionListProps) {
+function WithSectionHeader(props: SelectionListProps) {
const [selectedIds, setSelectedIds] = useState(['option-1', 'option-2']);
const memo = useMemo(() => {
@@ -356,7 +356,7 @@ WithSectionHeader.args = {
...MultipleSelection.args,
};
-function WithConfirmButton(props: BaseSelectionListProps) {
+function WithConfirmButton(props: SelectionListProps) {
const [selectedIds, setSelectedIds] = useState(['option-1', 'option-2']);
const memo = useMemo(() => {
diff --git a/src/stories/TransactionItemRow.stories.tsx b/src/stories/TransactionItemRow.stories.tsx
new file mode 100644
index 000000000000..7afe1e0ae3d7
--- /dev/null
+++ b/src/stories/TransactionItemRow.stories.tsx
@@ -0,0 +1,76 @@
+import type {Meta, StoryFn} from '@storybook/react';
+import React from 'react';
+import ThemeProvider from '@components/ThemeProvider';
+import ThemeStylesProvider from '@components/ThemeStylesProvider';
+import TransactionItemRow from '@components/TransactionItemRow';
+import CONST from '@src/CONST';
+import type Transaction from '@src/types/onyx/Transaction';
+import transaction from './objects/Transaction';
+
+type TransactionItemRowStory = StoryFn;
+
+type TransactionItemRowProps = {
+ transactionItem: Transaction;
+ shouldUseNarrowLayout: boolean;
+ isSelected: boolean;
+ shouldShowTooltip: boolean;
+};
+
+const story: Meta = {
+ title: 'Components/TransactionItemRow',
+ component: TransactionItemRow,
+ args: {
+ transactionItem: transaction,
+ shouldUseNarrowLayout: false,
+ isSelected: false,
+ shouldShowTooltip: true,
+ },
+ argTypes: {
+ transactionItem: {
+ control: 'object',
+ },
+ shouldUseNarrowLayout: {
+ control: 'boolean',
+ },
+ isSelected: {
+ control: 'boolean',
+ },
+ shouldShowTooltip: {
+ control: 'boolean',
+ },
+ },
+ parameters: {
+ useLightTheme: true,
+ },
+};
+
+function Template({transactionItem, shouldUseNarrowLayout, isSelected, shouldShowTooltip}: TransactionItemRowProps, {parameters}: {parameters: {useLightTheme?: boolean}}) {
+ const theme = parameters.useLightTheme ? CONST.THEME.LIGHT : CONST.THEME.DARK;
+
+ return (
+
+
+
+
+
+ );
+}
+
+const LightTheme: TransactionItemRowStory = Template.bind({});
+const DarkTheme: TransactionItemRowStory = Template.bind({});
+
+LightTheme.parameters = {
+ useLightTheme: true,
+};
+
+DarkTheme.parameters = {
+ useLightTheme: false,
+};
+
+export default story;
+export {LightTheme, DarkTheme};
diff --git a/src/stories/objects/Transaction.ts b/src/stories/objects/Transaction.ts
new file mode 100644
index 000000000000..177b97eed1bc
--- /dev/null
+++ b/src/stories/objects/Transaction.ts
@@ -0,0 +1,39 @@
+import type Transaction from '@src/types/onyx/Transaction';
+
+const transaction: Transaction & {mcc: string; modifiedMCC: string} = {
+ amount: -769900,
+ bank: '',
+ billable: false,
+ cardID: 0,
+ cardName: 'Cash Expense',
+ cardNumber: '',
+ category: 'CARS',
+ comment: {
+ comment: '',
+ },
+ created: '2025-02-18',
+ currency: 'PLN',
+ filename: '',
+ hasEReceipt: false,
+ inserted: '2025-02-18 14:23:29',
+ managedCard: false,
+ mcc: '',
+ merchant: "Mario's",
+ modifiedAmount: 0,
+ modifiedCreated: '',
+ modifiedCurrency: '',
+ modifiedMCC: '',
+ modifiedMerchant: '',
+ originalAmount: 0,
+ originalCurrency: '',
+ parentTransactionID: '',
+ posted: '',
+ receipt: {},
+ reimbursable: false,
+ reportID: '0',
+ status: 'Posted',
+ tag: 'private',
+ transactionID: '1564303948126109676',
+};
+
+export default transaction;
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 2213b95497dd..6650743c5659 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -521,6 +521,9 @@ const styles = (theme: ThemeColors) =>
textBold: {
fontWeight: FontUtils.fontWeight.bold,
},
+ textItalic: {
+ ...FontUtils.fontFamily.platform.MONOSPACE_ITALIC,
+ },
textVersion: {
color: theme.iconColorfulBackground,
@@ -792,6 +795,13 @@ const styles = (theme: ThemeColors) =>
color: theme.textLight,
},
+ buttonBlendContainer: {
+ backgroundColor: theme.appBG,
+ opacity: 1,
+ position: 'relative',
+ overflow: 'hidden',
+ },
+
hoveredComponentBG: {
backgroundColor: theme.hoverComponentBG,
},
@@ -5484,6 +5494,10 @@ const styles = (theme: ThemeColors) =>
borderLeftWidth: isExtraLargeScreenWidth ? 1 : 0,
borderLeftColor: theme.border,
}),
+
+ expenseWidgetRadius: {
+ borderRadius: variables.componentBorderRadiusNormal,
+ },
} satisfies Styles);
type ThemeStyles = ReturnType;
diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts
index 4d5894812195..725966d487a8 100644
--- a/src/styles/theme/themes/dark.ts
+++ b/src/styles/theme/themes/dark.ts
@@ -154,6 +154,7 @@ const darkTheme = {
statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT,
navigationBarButtonsStyle: CONST.NAVIGATION_BAR_BUTTONS_STYLE.LIGHT,
+ navigationBarBackgroundColor: `${colors.productDark100}CD`, // CD is 80% opacity (80% of 0xFF)
colorScheme: CONST.COLOR_SCHEME.DARK,
} satisfies ThemeColors;
diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts
index 1a187e85b0a2..add3bf183a42 100644
--- a/src/styles/theme/themes/light.ts
+++ b/src/styles/theme/themes/light.ts
@@ -154,6 +154,7 @@ const lightTheme = {
statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT,
navigationBarButtonsStyle: CONST.NAVIGATION_BAR_BUTTONS_STYLE.DARK,
+ navigationBarBackgroundColor: `${colors.productLight100}CD`, // CD is 80% opacity (80% of 0xFF)
colorScheme: CONST.COLOR_SCHEME.LIGHT,
} satisfies ThemeColors;
diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts
index 9dfb26e611d9..51de2a662f4a 100644
--- a/src/styles/theme/types.ts
+++ b/src/styles/theme/types.ts
@@ -109,6 +109,7 @@ type ThemeColors = {
// e.g. the StatusBar displays either "light-content" or "dark-content" based on the theme
statusBarStyle: StatusBarStyle;
navigationBarButtonsStyle: NavBarButtonStyle;
+ navigationBarBackgroundColor: Color;
colorScheme: ColorScheme;
};
diff --git a/src/styles/utils/flex.ts b/src/styles/utils/flex.ts
index f5df6eef8739..662050f21102 100644
--- a/src/styles/utils/flex.ts
+++ b/src/styles/utils/flex.ts
@@ -65,6 +65,10 @@ export default {
justifyContent: 'space-around',
},
+ justifyContentEvenly: {
+ justifyContent: 'space-evenly',
+ },
+
alignSelfStretch: {
alignSelf: 'stretch',
},
diff --git a/src/styles/utils/getNavigationBarType/index.android.ts b/src/styles/utils/getNavigationBarType/index.android.ts
new file mode 100644
index 000000000000..71a081010ddd
--- /dev/null
+++ b/src/styles/utils/getNavigationBarType/index.android.ts
@@ -0,0 +1,17 @@
+import NavBarManager from '@libs/NavBarManager';
+import CONST from '@src/CONST';
+import type GetNavigationBarType from './types';
+
+const getNavigationBarType: GetNavigationBarType = (insets) => {
+ const bottomInset = insets?.bottom ?? 0;
+
+ // If the bottom safe area inset is 0, we consider the device to have no navigation bar (or it being hidden by default).
+ // This could be mean either hidden soft keys, gesture navigation without a gesture bar or physical buttons.
+ if (bottomInset === 0) {
+ return CONST.NAVIGATION_BAR_TYPE.NONE;
+ }
+
+ return NavBarManager.getType();
+};
+
+export default getNavigationBarType;
diff --git a/src/styles/utils/getNavigationBarType/index.ios.ts b/src/styles/utils/getNavigationBarType/index.ios.ts
new file mode 100644
index 000000000000..1931ab0f85bb
--- /dev/null
+++ b/src/styles/utils/getNavigationBarType/index.ios.ts
@@ -0,0 +1,16 @@
+import CONST from '@src/CONST';
+import type GetNavigationBarType from './types';
+
+const getNavigationBarType: GetNavigationBarType = (insets) => {
+ const bottomInset = insets?.bottom ?? 0;
+
+ // If there is no bottom safe area inset, the device uses a physical navigation button.
+ if (bottomInset === 0) {
+ return CONST.NAVIGATION_BAR_TYPE.NONE;
+ }
+
+ // On iOS, if there is a bottom safe area inset, it means the device uses a gesture bar.
+ return CONST.NAVIGATION_BAR_TYPE.GESTURE_BAR;
+};
+
+export default getNavigationBarType;
diff --git a/src/styles/utils/getNavigationBarType/index.ts b/src/styles/utils/getNavigationBarType/index.ts
new file mode 100644
index 000000000000..32ea2065e938
--- /dev/null
+++ b/src/styles/utils/getNavigationBarType/index.ts
@@ -0,0 +1,9 @@
+import CONST from '@src/CONST';
+import type GetNavigationBarType from './types';
+
+const getNavigationBarType: GetNavigationBarType = () => {
+ // On web, there is no navigation bar.
+ return CONST.NAVIGATION_BAR_TYPE.NONE;
+};
+
+export default getNavigationBarType;
diff --git a/src/styles/utils/getNavigationBarType/types.ts b/src/styles/utils/getNavigationBarType/types.ts
new file mode 100644
index 000000000000..f0b30f4a2a72
--- /dev/null
+++ b/src/styles/utils/getNavigationBarType/types.ts
@@ -0,0 +1,6 @@
+import type {EdgeInsets} from 'react-native-safe-area-context';
+import type {NavigationBarType} from '@libs/NavBarManager/types';
+
+type GetNavigationBarType = (insets?: EdgeInsets) => NavigationBarType;
+
+export default GetNavigationBarType;
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index 39d5e5ca959a..b6ee3085d981 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -24,6 +24,7 @@ import createReportActionContextMenuStyleUtils from './generators/ReportActionCo
import createTooltipStyleUtils from './generators/TooltipStyleUtils';
import getContextMenuItemStyles from './getContextMenuItemStyles';
import getHighResolutionInfoWrapperStyle from './getHighResolutionInfoWrapperStyle';
+import getNavigationBarType from './getNavigationBarType/index';
import getNavigationModalCardStyle from './getNavigationModalCardStyles';
import getSafeAreaInsets from './getSafeAreaInsets';
import getSignInBgStyles from './getSignInBgStyles';
@@ -331,10 +332,11 @@ type SafeAreaPadding = {
};
/**
- * Takes safe area insets and returns padding to use for a View
+ * Takes safe area insets and returns platform specific padding to use for a View
*/
-function getSafeAreaPadding(insets?: EdgeInsets, insetsPercentageProp?: number): SafeAreaPadding {
+function getPlatformSafeAreaPadding(insets?: EdgeInsets, insetsPercentageProp?: number): SafeAreaPadding {
const platform = getPlatform();
+
let insetsPercentage = insetsPercentageProp;
if (insetsPercentage == null) {
switch (platform) {
@@ -1231,7 +1233,7 @@ const staticStyleUtils = {
getPaymentMethodMenuWidth,
getSafeAreaInsets,
getSafeAreaMargins,
- getSafeAreaPadding,
+ getPlatformSafeAreaPadding,
getSignInWordmarkWidthStyle,
getTextColorStyle,
getTransparentColor,
@@ -1257,6 +1259,7 @@ const staticStyleUtils = {
getBorderRadiusStyle,
getHighResolutionInfoWrapperStyle,
getItemBackgroundColorStyle,
+ getNavigationBarType,
};
const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts
index 2cdb95d4595b..526ae3a9b18d 100644
--- a/src/styles/utils/spacing.ts
+++ b/src/styles/utils/spacing.ts
@@ -732,4 +732,8 @@ export default {
minHeight65: {
minHeight: 260,
},
+
+ maxHeight5: {
+ maxHeight: 20,
+ },
} satisfies Record;
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 22b60bd51d2e..2b39f2320b91 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -213,6 +213,10 @@ export default {
photoUploadPopoverWidth: 335,
onboardingModalWidth: 500,
holdEducationModalWidth: 400,
+ changePolicyEducationModalWidth: 400,
+ changePolicyEducationModalIconWidth: 147.69,
+ changePolicyEducationModalIconHeight: 180,
+
fontSizeToWidthRatio: getValueUsingPixelRatio(0.8, 1),
// Emoji related variables
diff --git a/src/types/modules/react-native.d.ts b/src/types/modules/react-native.d.ts
index b5abe4f587a3..0b09824509b5 100644
--- a/src/types/modules/react-native.d.ts
+++ b/src/types/modules/react-native.d.ts
@@ -2,6 +2,7 @@
import type {TargetedEvent} from 'react-native';
import type {BootSplashModule} from '@libs/BootSplash/types';
import type {EnvironmentCheckerModule} from '@libs/Environment/betaChecker/types';
+import type {NavBarButtonStyle, NavigationBarType} from '@libs/NavBarManager/types';
import type {ShortcutManagerModule} from '@libs/ShortcutManager';
import type StartupTimer from '@libs/StartupTimer/types';
@@ -10,7 +11,8 @@ type RNTextInputResetModule = {
};
type RNNavBarManagerModule = {
- setButtonStyle: (style: 'light' | 'dark') => void;
+ setButtonStyle: (style: NavBarButtonStyle) => void;
+ getType(): NavigationBarType;
};
declare module 'react-native' {
diff --git a/src/types/onyx/DismissedProductTraining.ts b/src/types/onyx/DismissedProductTraining.ts
index ae6079b4b632..283c660be2e1 100644
--- a/src/types/onyx/DismissedProductTraining.ts
+++ b/src/types/onyx/DismissedProductTraining.ts
@@ -76,6 +76,11 @@ type DismissedProductTraining = {
* When user dismisses the test manager on confirmantion page product training tooltip, we store the timestamp here.
*/
[SCAN_TEST_CONFIRMATION]: string;
+
+ /**
+ * When user dismisses the ChangeReportPolicy feature training modal, we store the timestamp here.
+ */
+ [CONST.CHANGE_POLICY_TRAINING_MODAL]: string;
};
export default DismissedProductTraining;
diff --git a/src/types/onyx/OldDotAction.ts b/src/types/onyx/OldDotAction.ts
index 88c3126d0c52..11cee93ae0a0 100644
--- a/src/types/onyx/OldDotAction.ts
+++ b/src/types/onyx/OldDotAction.ts
@@ -2,7 +2,6 @@ import type {ValueOf} from 'type-fest';
import type CONST from '@src/CONST';
import type {
ChangeFieldParams,
- ChangePolicyParams,
DelegateSubmitParams,
ExportedToIntegrationParams,
IntegrationsMessageParams,
@@ -52,20 +51,6 @@ type OriginalMessageChangeField = {
originalMessage: Record & ChangeFieldParams;
};
-/**
- *
- */
-type OriginalMessageChangePolicy = {
- /**
- *
- */
- actionName: typeof CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY;
- /**
- *
- */
- originalMessage: ChangePolicyParams & Record;
-};
-
// Currently lacking Params
// type OriginalMessageChangeType = {
// /**
@@ -346,10 +331,6 @@ type OldDotOriginalMessageMap = {
*
*/
[CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD]: OriginalMessageChangeField;
- /**
- *
- */
- [CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY]: OriginalMessageChangePolicy;
/**
*
*/
@@ -449,7 +430,6 @@ export default OldDotAction;
export type {
OriginalMessageChangeField,
OldDotOriginalMessageActionName,
- OriginalMessageChangePolicy,
OriginalMessageDelegateSubmit,
OriginalMessageExportedToCSV,
OriginalMessageExportedToIntegration,
diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts
index 35fad9b57c11..e8f970448060 100644
--- a/src/types/onyx/OriginalMessage.ts
+++ b/src/types/onyx/OriginalMessage.ts
@@ -544,6 +544,15 @@ type OriginalMessageReimbursementDequeued = {
currency: string;
};
+/** Model of `CHANGEPOLICY` report action */
+type OriginalMessageChangePolicy = {
+ /** ID of the old policy */
+ fromPolicyID: string | undefined;
+
+ /** ID of the new policy */
+ toPolicyID: string;
+};
+
/** Model of `moved` report action */
type OriginalMessageMoved = {
/** ID of the old policy */
@@ -729,7 +738,7 @@ type OriginalMessageMap = {
[CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT]: OriginalMessageAddComment;
[CONST.REPORT.ACTIONS.TYPE.APPROVED]: OriginalMessageApproved;
[CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD]: never;
- [CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY]: never;
+ [CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY]: OriginalMessageChangePolicy;
[CONST.REPORT.ACTIONS.TYPE.CHANGE_TYPE]: never;
[CONST.REPORT.ACTIONS.TYPE.CHRONOS_OOO_LIST]: OriginalMessageChronosOOOList;
[CONST.REPORT.ACTIONS.TYPE.CLOSED]: OriginalMessageClosed;
@@ -763,6 +772,7 @@ type OriginalMessageMap = {
[CONST.REPORT.ACTIONS.TYPE.DEMOTED_FROM_WORKSPACE]: OriginalMessageDemotedFromWorkspace;
[CONST.REPORT.ACTIONS.TYPE.RENAMED]: OriginalMessageRenamed;
[CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW]: OriginalMessageReportPreview;
+ [CONST.REPORT.ACTIONS.TYPE.RESOLVED_DUPLICATES]: never;
[CONST.REPORT.ACTIONS.TYPE.SELECTED_FOR_RANDOM_AUDIT]: never;
[CONST.REPORT.ACTIONS.TYPE.SHARE]: never;
[CONST.REPORT.ACTIONS.TYPE.STRIPE_PAID]: never;
@@ -814,4 +824,5 @@ export type {
OriginalMessageModifiedExpense,
OriginalMessageExportIntegration,
IssueNewCardOriginalMessage,
+ OriginalMessageChangePolicy,
};
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index 31a4bd39b721..e3170a208bc5 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -476,6 +476,9 @@ type QBOConnectionConfig = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** Credentials of the current QBO connection */
credentials: QBOCredentials;
+
+ /** The accounting Method for NetSuite conenction config */
+ accountingMethod?: ValueOf;
}>;
/**
diff --git a/tests/perf-test/SidebarLinks.perf-test.tsx b/tests/perf-test/SidebarLinks.perf-test.tsx
index f6b9a6758b25..58663217c92a 100644
--- a/tests/perf-test/SidebarLinks.perf-test.tsx
+++ b/tests/perf-test/SidebarLinks.perf-test.tsx
@@ -15,6 +15,7 @@ jest.mock('../../src/libs/Navigation/Navigation', () => ({
navigate: jest.fn(),
isActiveRoute: jest.fn(),
getTopmostReportId: jest.fn(),
+ getActiveRoute: jest.fn(),
getTopmostReportActionId: jest.fn(),
isNavigationReady: jest.fn(() => Promise.resolve()),
isDisplayedInModal: jest.fn(() => false),
diff --git a/tests/ui/DebugReportActionsTest.tsx b/tests/ui/DebugReportActionsTest.tsx
new file mode 100644
index 000000000000..82dcab972bb2
--- /dev/null
+++ b/tests/ui/DebugReportActionsTest.tsx
@@ -0,0 +1,76 @@
+import {fireEvent, render, screen} from '@testing-library/react-native';
+import Onyx from 'react-native-onyx';
+import {LocaleContextProvider} from '@components/LocaleContextProvider';
+import OnyxProvider from '@components/OnyxProvider';
+import type Navigation from '@libs/Navigation/Navigation';
+import DebugReportActions from '@pages/Debug/Report/DebugReportActions';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Policy, Report, ReportAction} from '@src/types/onyx';
+import createRandomPolicy from '../utils/collections/policies';
+import createRandomReportAction from '../utils/collections/reportActions';
+import createRandomReport from '../utils/collections/reports';
+import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';
+
+jest.mock('@react-navigation/native', () => {
+ const actualNav = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualNav,
+ useIsFocused: () => true,
+ useFocusEffect: jest.fn(),
+ };
+});
+
+jest.mock('@src/libs/Navigation/Navigation', () => ({
+ navigate: jest.fn(),
+}));
+
+describe('DebugReportActions', () => {
+ beforeAll(() => {
+ Onyx.init({
+ keys: ONYXKEYS,
+ safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
+ });
+ });
+
+ afterEach(async () => {
+ await Onyx.clear();
+ });
+
+ it('should show no results message when search is empty', async () => {
+ const policyID = '12';
+ const reportID = '1';
+ const reportActionID = '123';
+ const policy: Policy = createRandomPolicy(Number(policyID));
+ const report: Report = {...createRandomReport(Number(reportID)), chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, policyID};
+ const reportActionL: ReportAction = {
+ ...createRandomReportAction(Number(reportActionID)),
+ reportID,
+ message: {
+ html: '',
+ text: '',
+ type: '',
+ },
+ };
+ await Onyx.merge(`${ONYXKEYS.NVP_PREFERRED_LOCALE}`, 'en');
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {
+ [reportActionID]: reportActionL,
+ });
+
+ render(
+
+
+
+
+ ,
+ );
+
+ await waitForBatchedUpdatesWithAct();
+
+ const input = screen.getByTestId('selection-list-text-input');
+ fireEvent.changeText(input, 'testtesttesttest');
+ expect(await screen.findByText('No results found')).toBeOnTheScreen();
+ });
+});
diff --git a/tests/unit/BaseSelectionListTest.tsx b/tests/unit/BaseSelectionListTest.tsx
index 6589eec7db80..ba74a5fb6e08 100644
--- a/tests/unit/BaseSelectionListTest.tsx
+++ b/tests/unit/BaseSelectionListTest.tsx
@@ -3,12 +3,12 @@ import {fireEvent, render, screen} from '@testing-library/react-native';
import {SectionList} from 'react-native';
import BaseSelectionList from '@components/SelectionList/BaseSelectionList';
import RadioListItem from '@components/SelectionList/RadioListItem';
-import type {BaseSelectionListProps, ListItem} from '@components/SelectionList/types';
+import type {ListItem, SelectionListProps} from '@components/SelectionList/types';
import type Navigation from '@libs/Navigation/Navigation';
import CONST from '@src/CONST';
type BaseSelectionListSections = {
- sections: BaseSelectionListProps['sections'];
+ sections: SelectionListProps['sections'];
canSelectMultiple?: boolean;
};
diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts
index ac386c68a2d6..f3b277ca5d7f 100644
--- a/tests/unit/PolicyUtilsTest.ts
+++ b/tests/unit/PolicyUtilsTest.ts
@@ -2,7 +2,16 @@
import Onyx from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import DateUtils from '@libs/DateUtils';
-import {getActivePolicies, getManagerAccountID, getPolicyNameByID, getRateDisplayValue, getSubmitToAccountID, getUnitRateValue, shouldShowPolicy} from '@libs/PolicyUtils';
+import {
+ getActivePolicies,
+ getManagerAccountID,
+ getPolicyNameByID,
+ getRateDisplayValue,
+ getSubmitToAccountID,
+ getUnitRateValue,
+ isWorkspaceEligibleForReportChange,
+ shouldShowPolicy,
+} from '@libs/PolicyUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PersonalDetailsList, Policy, PolicyEmployeeList, Report, Transaction} from '@src/types/onyx';
@@ -73,9 +82,11 @@ const categoryapprover2AccountID = 4;
const tagapprover1AccountID = 5;
const tagapprover2AccountID = 6;
const ownerAccountID = 7;
+const approverAccountID = 8;
const employeeEmail = 'employee@test.com';
const adminEmail = 'admin@test.com';
const categoryApprover1Email = 'categoryapprover1@test.com';
+const approverEmail = 'approver@test.com';
const personalDetails: PersonalDetailsList = {
'1': {
@@ -106,6 +117,10 @@ const personalDetails: PersonalDetailsList = {
accountID: ownerAccountID,
login: 'owner@test.com',
},
+ '8': {
+ accountID: approverAccountID,
+ login: approverEmail,
+ },
};
const rules = {
@@ -589,4 +604,139 @@ describe('PolicyUtils', () => {
expect(result).toBe(categoryapprover1AccountID);
});
});
+
+ describe('isWorkspaceEligibleForReportChange', () => {
+ beforeEach(() => {
+ wrapOnyxWithWaitForBatchedUpdates(Onyx);
+ Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails);
+ });
+ afterEach(async () => {
+ await Onyx.clear();
+ await waitForBatchedUpdatesWithAct();
+ });
+
+ it('returns false if current user is not a member of the new policy', async () => {
+ const newPolicy = {
+ ...createRandomPolicy(1),
+ employeeList: {},
+ };
+ const report = createRandomReport(0);
+ const oldPolicy = createRandomPolicy(0);
+ const currentUserLogin = 'nonmember@tests.com';
+ await Onyx.set(ONYXKEYS.SESSION, {email: currentUserLogin, accountID: 0});
+
+ const result = isWorkspaceEligibleForReportChange(newPolicy, report, oldPolicy, currentUserLogin);
+ expect(result).toBe(false);
+ });
+
+ it('returns true if current user is the submitter', async () => {
+ const currentUserLogin = employeeEmail;
+ const currentUserAccountID = employeeAccountID;
+ await Onyx.set(ONYXKEYS.SESSION, {email: currentUserLogin, accountID: currentUserAccountID});
+
+ const newPolicy = {
+ ...createRandomPolicy(1),
+ employeeList: {
+ [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.USER},
+ },
+ };
+ const oldPolicy = createRandomPolicy(0);
+ const report = {
+ ...createRandomReport(0),
+ ownerAccountID: currentUserAccountID,
+ };
+
+ const result = isWorkspaceEligibleForReportChange(newPolicy, report, oldPolicy, currentUserLogin);
+ expect(result).toBe(true);
+ });
+
+ it('returns true if current user is a policy admin', async () => {
+ const currentUserLogin = adminEmail;
+ const currentUserAccountID = adminAccountID;
+ await Onyx.set(ONYXKEYS.SESSION, {email: currentUserLogin, accountID: currentUserAccountID});
+
+ const newPolicy = {
+ ...createRandomPolicy(1),
+ employeeList: {
+ [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.ADMIN},
+ },
+ };
+ const oldPolicy = createRandomPolicy(0);
+ const report = createRandomReport(0);
+
+ const result = isWorkspaceEligibleForReportChange(newPolicy, report, oldPolicy, currentUserLogin);
+ expect(result).toBe(true);
+ });
+
+ it('returns true if current user is the policy owner', async () => {
+ const currentUserLogin = 'owner@test.com';
+ const currentUserAccountID = ownerAccountID;
+ await Onyx.set(ONYXKEYS.SESSION, {email: currentUserLogin, accountID: currentUserAccountID});
+
+ const newPolicy = {
+ ...createRandomPolicy(1),
+ ownerAccountID: currentUserAccountID,
+ employeeList: {
+ [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.ADMIN},
+ },
+ };
+ const oldPolicy = createRandomPolicy(0);
+ const report = createRandomReport(0);
+
+ const result = isWorkspaceEligibleForReportChange(newPolicy, report, oldPolicy, currentUserLogin);
+ expect(result).toBe(true);
+ });
+
+ it('returns true if current user is the approver and submitter is a member', async () => {
+ const currentUserLogin = approverEmail;
+ const currentUserAccountID = approverAccountID;
+ await Onyx.set(ONYXKEYS.SESSION, {email: currentUserLogin, accountID: currentUserAccountID});
+
+ const submitterLogin = employeeEmail;
+ const submitterAccountID = employeeAccountID;
+
+ const newPolicy = {
+ ...createRandomPolicy(1),
+ employeeList: {
+ [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.USER},
+ [submitterLogin]: {email: submitterLogin, role: CONST.POLICY.ROLE.USER},
+ },
+ };
+ const oldPolicy = {
+ ...createRandomPolicy(0),
+ employeeList: {
+ [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.USER},
+ [submitterLogin]: {email: submitterLogin, role: CONST.POLICY.ROLE.USER, submitsTo: currentUserLogin},
+ },
+ approver: currentUserLogin,
+ };
+ const report = {
+ ...createRandomReport(0),
+ ownerAccountID: submitterAccountID,
+ };
+
+ const result = isWorkspaceEligibleForReportChange(newPolicy, report, oldPolicy, currentUserLogin);
+ expect(result).toBe(true);
+ });
+
+ it('returns false if current user is approver but submitter not member', async () => {
+ const currentUserLogin = approverEmail;
+ const currentUserAccountID = approverAccountID;
+ await Onyx.set(ONYXKEYS.SESSION, {email: currentUserLogin, accountID: currentUserAccountID});
+
+ const newPolicy = {
+ ...createRandomPolicy(1),
+ employeeList: {
+ [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.USER},
+ },
+ };
+ const report = {
+ ...createRandomReport(0),
+ ownerAccountID: employeeAccountID,
+ };
+ const oldPolicy = createRandomPolicy(0);
+
+ expect(isWorkspaceEligibleForReportChange(newPolicy, report, oldPolicy, currentUserLogin)).toBe(false);
+ });
+ });
});
diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts
index 6151ac20ba30..567db5189d14 100644
--- a/tests/unit/ReportUtilsTest.ts
+++ b/tests/unit/ReportUtilsTest.ts
@@ -592,6 +592,7 @@ describe('ReportUtils', () => {
isUnreadWithMention: false,
stateNum: CONST.REPORT.STATE_NUM.OPEN,
statusNum: CONST.REPORT.STATUS_NUM.OPEN,
+ hasParentAccess: false,
};
expect(requiresAttentionFromCurrentUser(report)).toBe(true);
});
diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts
index 92244607a976..0f57d573c4d5 100644
--- a/tests/unit/Search/SearchUIUtilsTest.ts
+++ b/tests/unit/Search/SearchUIUtilsTest.ts
@@ -1367,7 +1367,7 @@ describe('SearchUIUtils', () => {
const expectedRoutes = [
ROUTES.SEARCH_ROOT.getRoute({query: 'type:expense status:all sortBy:date sortOrder:desc'}),
- ROUTES.SEARCH_ROOT.getRoute({query: 'type:expense status:all sortBy:date sortOrder:desc', groupBy: 'reports'}),
+ ROUTES.SEARCH_ROOT.getRoute({query: 'type:expense status:all sortBy:date sortOrder:desc groupBy:reports'}),
ROUTES.SEARCH_ROOT.getRoute({query: 'type:chat status:all sortBy:date sortOrder:desc'}),
ROUTES.SEARCH_ROOT.getRoute({query: 'type:trip status:all sortBy:date sortOrder:desc'}),
];
diff --git a/tests/unit/SidePaneUtilsTest.ts b/tests/unit/SidePaneUtilsTest.ts
new file mode 100644
index 000000000000..c94f42c6585e
--- /dev/null
+++ b/tests/unit/SidePaneUtilsTest.ts
@@ -0,0 +1,75 @@
+import {substituteRouteParameters} from '@libs/SidePaneUtils';
+
+describe('substituteRouteParameters', () => {
+ test('should substitute simple route parameters', () => {
+ const route = '/workspaces/123/rules/456';
+ const params = {workspaceID: '123', ruleID: '456'};
+ expect(substituteRouteParameters(route, params)).toBe('/workspaces/:workspaceID/rules/:ruleID');
+ });
+
+ test('should handle routes with multiple occurrences of a parameter', () => {
+ const route = '/reports/987/items/987';
+ const params = {reportID: '987'};
+ expect(substituteRouteParameters(route, params)).toBe('/reports/:reportID/items/:reportID');
+ });
+
+ test('should ignore non-matching parameters', () => {
+ const route = '/settings/profile';
+ const params = {userID: '123'};
+ expect(substituteRouteParameters(route, params)).toBe('/settings/profile');
+ });
+
+ test('should work with nested objects in params', () => {
+ const route = '/users/789/orders/456';
+ const params = {user: {id: '789'}, order: {id: '456'}};
+ expect(substituteRouteParameters(route, params)).toBe('/users/:id/orders/:id'); // Could be problematic with duplicate param names
+ });
+
+ test('should handle parameters that do not exist in the route', () => {
+ const route = '/dashboard/overview';
+ const params = {dashboardID: '111'};
+ expect(substituteRouteParameters(route, params)).toBe('/dashboard/overview');
+ });
+
+ test('should handle routes with query parameters correctly', () => {
+ const route = '/reports/555/view?filter=active';
+ const params = {reportID: '555'};
+ expect(substituteRouteParameters(route, params)).toBe('/reports/:reportID/view?filter=active');
+ });
+
+ test('should not replace partial parameter values', () => {
+ const route = '/workspaces/123456/activities';
+ const params = {workspaceID: '123'};
+ expect(substituteRouteParameters(route, params)).toBe('/workspaces/123456/activities'); // '123' is a subset of '123456'
+ });
+
+ test('should return the original route if params is an empty object', () => {
+ const route = '/users/42/profile';
+ const params = {};
+ expect(substituteRouteParameters(route, params)).toBe('/users/42/profile');
+ });
+
+ test('should return the original route if params are null or undefined', () => {
+ const route = '/reports/23/details';
+ expect(substituteRouteParameters(route, null as unknown as Record)).toBe('/reports/23/details');
+ expect(substituteRouteParameters(route, undefined as unknown as Record)).toBe('/reports/23/details');
+ });
+
+ test('should properly replace overlapping values', () => {
+ const route = '/reports/123/report/123/details';
+ const params = {id: '123'};
+ expect(substituteRouteParameters(route, params)).toBe('/reports/:id/report/:id/details');
+ });
+
+ test('should handle deeply nested parameters', () => {
+ const route = '/company/789/employees/456/profile';
+ const params = {company: {id: '789'}, employee: {id: '456'}};
+ expect(substituteRouteParameters(route, params)).toBe('/company/:id/employees/:id/profile');
+ });
+
+ test('should prevent accidental replacements inside unrelated words', () => {
+ const route = '/analysis/321/report321/details';
+ const params = {reportID: '321'};
+ expect(substituteRouteParameters(route, params)).toBe('/analysis/:reportID/report321/details');
+ });
+});
diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts
index 2771a2c5f78e..c5697f8385c6 100644
--- a/tests/unit/SidebarUtilsTest.ts
+++ b/tests/unit/SidebarUtilsTest.ts
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/naming-convention */
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
+import DateUtils from '@libs/DateUtils';
import {getReportActionMessageText} from '@libs/ReportActionsUtils';
import SidebarUtils from '@libs/SidebarUtils';
import CONST from '@src/CONST';
@@ -232,6 +233,45 @@ describe('SidebarUtils', () => {
expect(optionDataPinned?.isPinned).toBe(true);
expect(optionDataUnpinned?.isPinned).toBe(false);
});
+
+ it('returns null when report is archived', async () => {
+ const MOCK_REPORT: Report = {
+ reportID: '5',
+ };
+
+ const reportNameValuePairs = {
+ private_isArchived: DateUtils.getDBTime(),
+ };
+
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${MOCK_REPORT.reportID}`, reportNameValuePairs);
+
+ await waitForBatchedUpdates();
+
+ const MOCK_REPORT_ACTION = {
+ reportActionID: '1',
+ actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
+ actorAccountID: 12345,
+ created: '2024-08-08 18:20:44.171',
+ message: [
+ {
+ type: '',
+ text: '',
+ },
+ ],
+ errors: {
+ someError: 'Some error occurred',
+ },
+ };
+ const MOCK_REPORT_ACTIONS: OnyxEntry = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '1': MOCK_REPORT_ACTION,
+ };
+ const MOCK_TRANSACTION_VIOLATIONS: OnyxCollection = {};
+
+ const result = SidebarUtils.getReasonAndReportActionThatHasRedBrickRoad(MOCK_REPORT, MOCK_REPORT_ACTIONS, false, MOCK_TRANSACTION_VIOLATIONS);
+
+ expect(result).toBeNull();
+ });
});
describe('shouldShowRedBrickRoad', () => {
@@ -376,6 +416,25 @@ describe('SidebarUtils', () => {
expect(result).toBe(false);
});
+
+ it('returns false when report is archived', () => {
+ const MOCK_REPORT: Report = {
+ reportID: '5',
+ errorFields: {
+ export: {
+ error: 'Some error occurred',
+ },
+ },
+ };
+ // This report with reportID 5 is already archived from previous tests
+ // where we set reportNameValuePairs with private_isArchived
+ const MOCK_REPORT_ACTIONS: OnyxEntry = {};
+ const MOCK_TRANSACTION_VIOLATIONS: OnyxCollection = {};
+
+ const result = SidebarUtils.shouldShowRedBrickRoad(MOCK_REPORT, MOCK_REPORT_ACTIONS, false, MOCK_TRANSACTION_VIOLATIONS);
+
+ expect(result).toBe(false);
+ });
});
describe('getWelcomeMessage', () => {