diff --git a/android/app/build.gradle b/android/app/build.gradle
index f624c448bf82..d76c2b16f587 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -98,8 +98,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001047100
- versionName "1.4.71-0"
+ versionCode 1001047104
+ versionName "1.4.71-4"
// Supported language variants must be declared here to avoid from being removed during the compilation.
// This also helps us to not include unnecessary language variants in the APK.
resConfigs "en", "es"
diff --git a/docs/_data/_routes.yml b/docs/_data/_routes.yml
index a44d7d11af87..ad738e44ab44 100644
--- a/docs/_data/_routes.yml
+++ b/docs/_data/_routes.yml
@@ -54,6 +54,11 @@ platforms:
icon: /assets/images/hand-card.svg
description: Explore the perks and benefits of the Expensify Card.
+ - href: travel
+ title: Travel
+ icon: /assets/images/plane.svg
+ description: Manage all your corporate travel needs with Expensify Travel.
+
- href: copilots-and-delegates
title: Copilots & Delegates
icon: /assets/images/envelope-receipt.svg
@@ -114,16 +119,16 @@ platforms:
icon: /assets/images/money-into-wallet.svg
description: Learn more about expense tracking and submission.
- - href: bank-accounts-and-payments
- title: Bank Accounts & Payments
- icon: /assets/images/bank-card.svg
- description: Send direct reimbursements, pay invoices, and receive payment.
-
- href: expensify-card
title: Expensify Card
icon: /assets/images/hand-card.svg
description: Explore the perks and benefits of the Expensify Card.
+ - href: travel
+ title: Travel
+ icon: /assets/images/plane.svg
+ description: Manage all your corporate travel needs with Expensify Travel.
+
- href: connections
title: Connections
icon: /assets/images/workflow.svg
diff --git a/docs/articles/expensify-classic/travel/Coming-Soon.md b/docs/articles/expensify-classic/travel/Coming-Soon.md
new file mode 100644
index 000000000000..4d32487a14b5
--- /dev/null
+++ b/docs/articles/expensify-classic/travel/Coming-Soon.md
@@ -0,0 +1,6 @@
+---
+title: Coming soon
+description: Coming soon
+---
+
+# Coming soon
\ No newline at end of file
diff --git a/docs/articles/new-expensify/bank-accounts-and-payments/Connect-a-Bank-Account.md b/docs/articles/new-expensify/bank-accounts-and-payments/Connect-a-Bank-Account.md
deleted file mode 100644
index bc0676231544..000000000000
--- a/docs/articles/new-expensify/bank-accounts-and-payments/Connect-a-Bank-Account.md
+++ /dev/null
@@ -1,151 +0,0 @@
----
-title: Connect a Business Bank Account - US
-description: How to connect a business bank account to Expensify (US)
----
-# Overview
-Adding a verified business bank account unlocks a myriad of features and automation in Expensify.
-Once you connect your business bank account, you can:
-- Reimburse expenses via direct bank transfer
-- Pay bills
-- Collect invoice payments
-- Issue the Expensify Card
-
-# How to add a verified business bank account
-To connect a business bank account to Expensify, follow the below steps:
-1. Go to **Settings > Workspaces > _Workspace Name_ > Bank account > Connect bank account**
-2. Click **Connect online with Plaid**
-3. Click **Continue**
-4. When you reach the **Plaid** screen, you'll be shown a list of compatible banks that offer direct online login access
-5. Login to the business bank account:
-- If the bank is not listed, click the X to go back to the connection type
-- Here you’ll see the option to **Connect Manually**
-- Enter your account and routing numbers
-6. Enter your bank login credentials:
-- If your bank requires additional security measures, you will be directed to obtain and enter a security code
-- If you have more than one account available to choose from, you will be directed to choose the desired account
-
-Next, to verify the bank account, you’ll enter some details about the business as well as some personal information.
-
-## Enter company information
-This is where you’ll add the legal business name as well as several other company details.
-
-- **Company address**: The company address must be located in the US and a physical location (If you input a maildrop address, PO box, or UPS Store, the address will be flagged for review, and adding the bank account to Expensify will be delayed)
-- **Tax Identification Number**: This is the identification number that was assigned to the business by the IRS
-- **Company website**: A company website is required to use most of Expensify’s payment features. When adding the website of the business, format it as, https://www.domain.com
-- **Industry Classification Code**: You can locate a list of Industry Classification Codes [here]([url](https://www.census.gov/naics/?input=software&year=2022))
-
-## Enter personal information
-Whoever is connecting the bank account to Expensify, must enter their details under the Requestor Information section:
-- The address must be a physical address
-- The address must be located in the US
-- The SSN must be US-issued
-
-This does not need to be a signor on the bank account. If someone other than the Expensify account holder enters their personal information in this section, the details will be flagged for review, and adding the bank account to Expensify will be delayed.
-
-## Upload ID
-After entering your personal details, you’ll be prompted to click a link or scan a QR code so that you can do the following:
-1. Upload a photo of the front and back of your ID (this cannot be a photo of an existing image)
-2. Use your device to take a selfie and record a short video of yourself
-
-**Your ID must be:**
-- Issued in the US
-- Current (ie: the expiration date must be in the future)
-
-## Additional Information
-Check the appropriate box under **Additional Information**, accept the agreement terms, and verify that all of the information is true and accurate:
-- A Beneficial Owner refers to an **individual** who owns 25% or more of the business.
-- If you or another **individual** owns 25% or more of the business, please check the appropriate box
-- If someone else owns 25% or more of the business, you will be prompted to provide their personal information
-
-If no individual owns more than 25% of the company you do not need to list any beneficial owners. In that case, be sure to leave both boxes unchecked under the Beneficial Owner Additional Information section.
-
-# How to validate the bank account
-
-The account you set up can be found under **Settings > Workspaces > _Workspace Name_ > Bank account** in either the **Verifying** or **Pending** state.
-
-If it is **Verifying**, then this means we sent you a message and need more information from you. Please review the automated message sent by Concierge. This should include a message with specific details about what's required to move forward.
-
-If it is **Pending**, then in 1-2 business days Expensify will administer 3 test transactions to your bank account. If after two business days you do not see these test transactions, reach out to Concierge for assistance.
-
-After these transactions (2 withdrawals and 1 deposit) have been processed to your account, head to the **Bank accounts** section of your workspace settings. Here you'll see a prompt to input the transaction amounts.
-
-Once you've finished these steps, your business bank account is ready to use in Expensify!
-
-# How to delete a verified bank account
-If you need to delete a bank account from Expensify, run through the following steps:
-1. Go to **Settings > Workspaces > _Workspace Name_ > Bank account**
-2. Click the red **Delete** button under the corresponding bank account
-
-# Deep Dive
-
-## Verified bank account requirements
-
-To add a business bank account to issue reimbursements via ACH (US), to pay invoices (US), or to issue Expensify Cards:
-- You must enter a physical address for yourself, any Beneficial Owner (if one exists), and the business associated with the bank account. We **cannot** accept a PO Box or MailDrop location.
-- If you are adding the bank account to Expensify, you must add it from **your** Expensify account settings.
-- If you are adding a bank account to Expensify, we are required by law to verify your identity. Part of this process requires you to verify a US-issued photo ID. For using features related to US ACH, your ID must be issued by the United States. You and any Beneficial Owner (if one exists), must also have a US address
-- You must have a valid website for your business to utilize the Expensify Card, or to pay invoices with Expensify.
-
-## Locked bank account
-When you reimburse a report, you authorize Expensify to withdraw the funds from your account. If your bank rejects Expensify’s withdrawal request, your verified bank account is locked until the issue is resolved.
-
-Withdrawal requests can be rejected due to insufficient funds, or if the bank account has not been enabled for direct debit.
-If you need to enable direct debits from your verified bank account, your bank will require the following details:
-- The ACH CompanyIDs (1270239450, 4270239450 and 2270239450)
-- The ACH Originator Name (Expensify)
-
-If using Expensify to process Bill payments, you'll also need to whitelist the ACH IDs from our partner [Stripe](https://support.stripe.com/questions/ach-direct-debit-company-ids-for-stripe?):
-- The ACH CompanyIDs (1800948598 and 4270465600)
-- The ACH Originator Name (expensify.com)
-
-If using Expensify to process international reimbursements from your USD bank account, you'll also need to whitelist the ACH IDs from our partner CorPay:
-- The ACH CompanyIDs (1522304924 and 2522304924)
-- The ACH Originator Name (Cambridge Global Payments)
-
-To request to unlock the bank account, go to **Settings > Workspaces > _Workspace Name_ > Bank account** and click **Fix.** This sends a request to our support team to review why the bank account was locked, who will send you a message to confirm that.
-
-Unlocking a bank account can take 4-5 business days to process, to allow for ACH processing time and clawback periods.
-
-## Error adding an ID to Onfido
-
-Expensify is required by both our sponsor bank and federal law to verify the identity of the individual who is initiating the movement of money. We use Onfido to confirm that the person adding a payment method is genuine and not impersonating someone else.
-
-If you get a generic error message that indicates, "Something's gone wrong", please go through the following steps:
-
-1. Ensure you are using either Safari (on iPhone) or Chrome (on Android) as your web browser.
-2. Check your browser's permissions to make sure that the camera and microphone settings are set to "Allow"
-3. Clear your web cache for Safari (on iPhone) or Chrome (on Android).
-4. If using a corporate Wi-Fi network, confirm that your corporate firewall isn't blocking the website.
-5. Make sure no other apps are overlapping your screen, such as the Facebook Messenger bubble, while recording the video.
-6. On iPhone, if using iOS version 15 or later, disable the Hide IP address feature in Safari.
-7. If possible, try these steps on another device
-8. If you have another phone available, try to follow these steps on that device
-If the issue persists, please contact your Account Manager or Concierge for further troubleshooting assistance.
-
-{% include faq-begin.md %}
-## What is a Beneficial Owner?
-
-A Beneficial Owner refers to an **individual** who owns 25% or more of the business. If no individual owns 25% or more of the business, the company does not have a Beneficial Owner.
-
-## What do I do if the Beneficial Owner section only asks for personal details, but my organization is owned by another company?
-
-Please only indicate you have a Beneficial Owner, if it is an individual that owns 25% or more of the business.
-
-## Why can’t I input my address or upload my ID?
-
-Are you entering a US address? When adding a verified business bank account in Expensify, the individual adding the account, and any beneficial owner (if one exists) are required to have a US address, US photo ID, and a US SSN. If you do not meet these requirements, you’ll need to have another admin add the bank account, and then share access with you once verified.
-
-## Why am I asked for documents when adding my bank account?
-
-When a bank account is added to Expensify, we complete a series of checks to verify the information provided to us. We conduct these checks to comply with both our sponsor bank's requirements and federal government regulations, specifically the Bank Secrecy Act / Anti-Money Laundering (BSA / AML) laws. Expensify also has anti-fraud measures in place.
-If automatic verification fails, we may request manual verification, which could involve documents such as address verification for your business, a letter from your bank confirming bank account ownership, etc.
-
-If you have any questions regarding the documentation request you received, please contact Concierge and they will be happy to assist.
-
-## I don’t see all three microtransactions I need to validate my bank account. What should I do?
-
-It's a good idea to wait till the end of that second business day. If you still don’t see them, please reach out to your bank and ask them to whitelist our ACH IDs **1270239450**, **4270239450**, and **2270239450**. Expensify’s ACH Originator Name is "Expensify".
-
-Make sure to reach out to your Account Manager or Concierge once that's all set, and our team will be able to re-trigger those three test transactions!
-
-{% include faq-end.md %}
diff --git a/docs/articles/new-expensify/settings/Enable-Two-Factor-Authentication.md b/docs/articles/new-expensify/settings/Enable-Two-Factor-Authentication.md
new file mode 100644
index 000000000000..5f0a33cc8754
--- /dev/null
+++ b/docs/articles/new-expensify/settings/Enable-Two-Factor-Authentication.md
@@ -0,0 +1,51 @@
+---
+title: Enable Two-Factor Authentication (2FA)
+description: Add an extra layer of security for your Expensify login
+---
+
+
+Add an extra layer of security to help keep your financial data safe and secure by enabling two-factor authentication (2FA). This will require you to enter a code generated by your preferred authenticator app (like Google Authenticator or Microsoft Authenticator) when you log in.
+
+To enable 2FA,
+
+{% include selector.html values="desktop, mobile" %}
+
+{% include option.html value="desktop" %}
+1. Click your profile image or icon in the bottom left menu.
+2. Click **Security** in the left menu.
+3. Under Security Options, click **Two Factor Authentication**.
+4. Save a copy of your backup codes. This step is critical! You will lose access to your account if you cannot use your authenticator app and do not have your recovery codes.
+ - Click **Download** to save a copy of your backup codes to your computer.
+ - Click **Copy** to paste the codes into a document or other secure location.
+5. Click **Next**.
+6. Download or open your authenticator app and connect it to Expensify by either:
+ - Scanning the QR code
+ - Entering the code into your authenticator app
+7. Enter the 6-digit code from your authenticator app into Expensify and click **Verify**.
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+1. Tap your profile image or icon at the bottom of the screen.
+2. Tap **Security**.
+3. Under Security Options, tap **Two Factor Authentication**.
+4. Save a copy of your backup codes. This step is critical! You will lose access to your account if you cannot use your authenticator app and do not have your recovery codes.
+ - Tap **Download** to save a copy of your backup codes to your device.
+ - Tap **Copy** to paste the codes into a document or other secure location.
+5. Tap **Next**.
+6. Download or open your authenticator app and connect it to Expensify by either:
+ - Scanning the QR code
+ - Entering the code into your authenticator app
+7. Enter the 6-digit code from your authenticator app into Expensify and tap **Verify**.
+{% include end-option.html %}
+
+{% include end-selector.html %}
+
+When you log in to Expensify in the future, you’ll be emailed a magic code that you’ll use to log in with. Then you’ll be prompted to open your authenticator app to get the 6-digit code and enter it into Expensify. A new code regenerates every few seconds, so the code is always different. If the code time runs out, you can generate a new code as needed.
+
+{% include faq-begin.md %}
+**How do I use my recovery codes if I lose access to my authenticator app?**
+
+Your recovery codes work the same way as your authenticator codes. Just enter a recovery code as you would the authenticator code.
+{% include faq-end.md %}
+
+
diff --git a/docs/articles/new-expensify/settings/Switch-account-language-to-Spanish.md b/docs/articles/new-expensify/settings/Switch-account-language-to-Spanish.md
new file mode 100644
index 000000000000..a431d34fbc0f
--- /dev/null
+++ b/docs/articles/new-expensify/settings/Switch-account-language-to-Spanish.md
@@ -0,0 +1,23 @@
+---
+title: Switch account language to Spanish
+description: Change your account language
+---
+
+
+{% include selector.html values="desktop, mobile" %}
+
+{% include option.html value="desktop" %}
+1. Click your profile image or icon in the bottom left menu.
+2. Click **Preferences** in the left menu.
+3. Click the Language option and select **Spanish**.
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+1. Tap your profile image or icon in the bottom menu.
+2. Tap **Preferences**.
+3. Tap the Language option and select **Spanish**.
+{% include end-option.html %}
+
+{% include end-selector.html %}
+
+
diff --git a/docs/articles/new-expensify/settings/Update-Notification-Preferences.md b/docs/articles/new-expensify/settings/Update-Notification-Preferences.md
new file mode 100644
index 000000000000..e4111b3d02d3
--- /dev/null
+++ b/docs/articles/new-expensify/settings/Update-Notification-Preferences.md
@@ -0,0 +1,29 @@
+---
+title: Update notification preferences
+description: Determine how you want to receive Expensify notifications
+---
+
+
+To customize the email and in-app notifications you receive from Expensify,
+
+{% include selector.html values="desktop, mobile" %}
+
+{% include option.html value="desktop" %}
+1. Click your profile image or icon in the bottom left menu.
+2. Click **Preferences** in the left menu.
+3. Enable or disable the toggles under Notifications:
+ - **Receive relevant feature updates and Expensify news**: If enabled, you will receive emails and in-app notifications from Expensify about new product and company updates.
+ - **Mute all sounds from Expensify**: If enabled, all in-app notification sounds will be silenced.
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+1. Tap your profile image or icon in the bottom menu.
+2. Tap **Preferences**.
+3. Enable or disable the toggles under Notifications:
+ - **Receive relevant feature updates and Expensify news**: If enabled, you will receive emails and in-app notifications from Expensify about new product and company updates.
+ - **Mute all sounds from Expensify**: If enabled, all in-app notification sounds will be silenced.
+{% include end-option.html %}
+
+{% include end-selector.html %}
+
+
diff --git a/docs/articles/new-expensify/travel/Coming-Soon.md b/docs/articles/new-expensify/travel/Coming-Soon.md
new file mode 100644
index 000000000000..4d32487a14b5
--- /dev/null
+++ b/docs/articles/new-expensify/travel/Coming-Soon.md
@@ -0,0 +1,6 @@
+---
+title: Coming soon
+description: Coming soon
+---
+
+# Coming soon
\ No newline at end of file
diff --git a/docs/assets/images/plane.svg b/docs/assets/images/plane.svg
new file mode 100644
index 000000000000..0295aa3c66c0
--- /dev/null
+++ b/docs/assets/images/plane.svg
@@ -0,0 +1,34 @@
+
diff --git a/docs/expensify-classic/hubs/travel/index.html b/docs/expensify-classic/hubs/travel/index.html
new file mode 100644
index 000000000000..7c8c3d363d5e
--- /dev/null
+++ b/docs/expensify-classic/hubs/travel/index.html
@@ -0,0 +1,6 @@
+---
+layout: default
+title: Travel
+---
+
+{% include hub.html %}
diff --git a/docs/new-expensify/hubs/bank-accounts-and-payments/index.html b/docs/new-expensify/hubs/bank-accounts-and-payments/index.html
deleted file mode 100644
index 94db3c798710..000000000000
--- a/docs/new-expensify/hubs/bank-accounts-and-payments/index.html
+++ /dev/null
@@ -1,6 +0,0 @@
----
-layout: default
-title: Bank Accounts & Payments
----
-
-{% include hub.html %}
\ No newline at end of file
diff --git a/docs/new-expensify/hubs/travel/index.html b/docs/new-expensify/hubs/travel/index.html
new file mode 100644
index 000000000000..7c8c3d363d5e
--- /dev/null
+++ b/docs/new-expensify/hubs/travel/index.html
@@ -0,0 +1,6 @@
+---
+layout: default
+title: Travel
+---
+
+{% include hub.html %}
diff --git a/ios/NewApp_AdHoc.mobileprovision.gpg b/ios/NewApp_AdHoc.mobileprovision.gpg
index 440309f63c6e..29d379151525 100644
Binary files a/ios/NewApp_AdHoc.mobileprovision.gpg and b/ios/NewApp_AdHoc.mobileprovision.gpg differ
diff --git a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg
index 2de81ee85018..cf14d27d7d87 100644
Binary files a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg and b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg differ
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 14bf7ffba924..749785c8698b 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.71.0
+ 1.4.71.4FullStoryOrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 6af8bb6ddc16..c71854dfcae1 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature????CFBundleVersion
- 1.4.71.0
+ 1.4.71.4
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index da2d70d0e859..b06211595ad0 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -13,7 +13,7 @@
CFBundleShortVersionString1.4.71CFBundleVersion
- 1.4.71.0
+ 1.4.71.4NSExtensionNSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index cdc8a6c1b04b..a57d1ad8760e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.71-0",
+ "version": "1.4.71-4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.71-0",
+ "version": "1.4.71-4",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index 1aabe53b5480..dd7207e808ef 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.71-0",
+ "version": "1.4.71-4",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
diff --git a/src/CONST.ts b/src/CONST.ts
index 135156e939cf..bd1c61c907d7 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -1307,6 +1307,17 @@ const CONST = {
DRAFT: 'DRAFT',
AWAITING_APPROVAL: 'AWT_APPROVAL',
},
+ IMPORT_TRACKING_CATEGORIES: 'importTrackingCategories',
+ MAPPINGS: 'mappings',
+ TRACKING_CATEGORY_PREFIX: 'trackingCategory_',
+ TRACKING_CATEGORY_FIELDS: {
+ COST_CENTERS: 'cost centers',
+ REGION: 'region',
+ },
+ TRACKING_CATEGORY_OPTIONS: {
+ DEFAULT: 'DEFAULT',
+ TAG: 'TAG',
+ },
},
QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE: {
@@ -2067,6 +2078,7 @@ const CONST = {
INFO: 'info',
},
REPORT_DETAILS_MENU_ITEM: {
+ SHARE_CODE: 'shareCode',
MEMBERS: 'member',
INVITE: 'invite',
SETTINGS: 'settings',
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index dad97a419f3e..bf50f70d03b4 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -780,6 +780,18 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/accounting/xero/organization/:currentOrganizationID',
getRoute: (policyID: string, currentOrganizationID: string) => `settings/workspaces/${policyID}/accounting/xero/organization/${currentOrganizationID}` as const,
},
+ POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES: {
+ route: 'settings/workspaces/:policyID/accounting/xero/import/tracking-categories',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/import/tracking-categories` as const,
+ },
+ POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES_MAP_COST_CENTERS: {
+ route: 'settings/workspaces/:policyID/accounting/xero/import/tracking-categories/cost-centers',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/import/tracking-categories/cost-centers` as const,
+ },
+ POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES_MAP_REGION: {
+ route: 'settings/workspaces/:policyID/accounting/xero/import/tracking-categories/region',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/import/tracking-categories/region` as const,
+ },
POLICY_ACCOUNTING_XERO_CUSTOMER: {
route: '/settings/workspaces/:policyID/accounting/xero/import/customers',
getRoute: (policyID: string) => `/settings/workspaces/${policyID}/accounting/xero/import/customers` as const,
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 1cb66c882a1d..c99fc0818196 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -244,6 +244,9 @@ const SCREENS = {
XERO_ORGANIZATION: 'Policy_Accounting_Xero_Customers',
XERO_CUSTOMER: 'Policy_Acounting_Xero_Import_Customer',
XERO_TAXES: 'Policy_Accounting_Xero_Taxes',
+ XERO_TRACKING_CATEGORIES: 'Policy_Accounting_Xero_Tracking_Categories',
+ XERO_MAP_COST_CENTERS: 'Policy_Accounting_Xero_Map_Cost_Centers',
+ XERO_MAP_REGION: 'Policy_Accounting_Xero_Map_Region',
XERO_EXPORT: 'Policy_Accounting_Xero_Export',
XERO_EXPORT_PURCHASE_BILL_DATE_SELECT: 'Policy_Accounting_Xero_Export_Purchase_Bill_Date_Select',
XERO_ADVANCED: 'Policy_Accounting_Xero_Advanced',
diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx
index e5980a397d37..52c32ce1f584 100644
--- a/src/components/AmountTextInput.tsx
+++ b/src/components/AmountTextInput.tsx
@@ -36,6 +36,9 @@ type AmountTextInputProps = {
/** Style for the TextInput container */
containerStyle?: StyleProp;
+
+ /** Hide the focus styles on TextInput */
+ hideFocusedState?: boolean;
} & Pick;
function AmountTextInput(
@@ -50,6 +53,7 @@ function AmountTextInput(
onKeyPress,
containerStyle,
disableKeyboard = true,
+ hideFocusedState = true,
...rest
}: AmountTextInputProps,
ref: ForwardedRef,
@@ -57,7 +61,7 @@ function AmountTextInput(
return (
+ {
+ setIsLastMemberLeavingGroupModalVisible(false);
+ Report.leaveGroupChat(report.reportID);
+ }}
+ onCancel={() => setIsLastMemberLeavingGroupModalVisible(false)}
+ prompt={translate('groupChat.lastMemberWarning')}
+ confirmText={translate('common.leave')}
+ cancelText={translate('common.cancel')}
+ />
diff --git a/src/components/ConnectToXeroButton/index.native.tsx b/src/components/ConnectToXeroButton/index.native.tsx
index 36c5af4a0575..f5e819136f00 100644
--- a/src/components/ConnectToXeroButton/index.native.tsx
+++ b/src/components/ConnectToXeroButton/index.native.tsx
@@ -11,7 +11,7 @@ import Modal from '@components/Modal';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import {removePolicyConnection} from '@libs/actions/connections';
-import getXeroSetupLink from '@libs/actions/connections/ConnectToXero';
+import {getXeroSetupLink} from '@libs/actions/connections/ConnectToXero';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Session} from '@src/types/onyx';
diff --git a/src/components/ConnectToXeroButton/index.tsx b/src/components/ConnectToXeroButton/index.tsx
index 8fad63e1a965..318c3bac4e2a 100644
--- a/src/components/ConnectToXeroButton/index.tsx
+++ b/src/components/ConnectToXeroButton/index.tsx
@@ -5,7 +5,7 @@ import useEnvironment from '@hooks/useEnvironment';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import {removePolicyConnection} from '@libs/actions/connections';
-import getXeroSetupLink from '@libs/actions/connections/ConnectToXero';
+import {getXeroSetupLink} from '@libs/actions/connections/ConnectToXero';
import * as Link from '@userActions/Link';
import CONST from '@src/CONST';
import type {ConnectToXeroButtonProps} from './types';
diff --git a/src/components/CurrentUserPersonalDetailsSkeletonView/index.tsx b/src/components/CurrentUserPersonalDetailsSkeletonView/index.tsx
index 367e54e8be64..21e82c26f769 100644
--- a/src/components/CurrentUserPersonalDetailsSkeletonView/index.tsx
+++ b/src/components/CurrentUserPersonalDetailsSkeletonView/index.tsx
@@ -15,15 +15,9 @@ type CurrentUserPersonalDetailsSkeletonViewProps = {
/** The size of the avatar */
avatarSize?: ValueOf;
-
- /** Background color of the skeleton view */
- backgroundColor?: string;
-
- /** Foreground color of the skeleton view */
- foregroundColor?: string;
};
-function CurrentUserPersonalDetailsSkeletonView({shouldAnimate = true, avatarSize = CONST.AVATAR_SIZE.LARGE, backgroundColor, foregroundColor}: CurrentUserPersonalDetailsSkeletonViewProps) {
+function CurrentUserPersonalDetailsSkeletonView({shouldAnimate = true, avatarSize = CONST.AVATAR_SIZE.LARGE}: CurrentUserPersonalDetailsSkeletonViewProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -37,8 +31,8 @@ function CurrentUserPersonalDetailsSkeletonView({shouldAnimate = true, avatarSiz
({renderClone, shouldUsePortal, ListFooterComponent, ...viewProps}: DraggableListProps, ref: React.ForwardedRef>) {
+ const styles = useThemeStyles();
+ return (
+
+
+ {React.isValidElement(ListFooterComponent) && {ListFooterComponent}}
+
+ );
+}
+
+DraggableList.displayName = 'DraggableList';
+
+export default React.forwardRef(DraggableList);
diff --git a/src/components/DraggableList/index.native.tsx b/src/components/DraggableList/index.ios.tsx
similarity index 100%
rename from src/components/DraggableList/index.native.tsx
rename to src/components/DraggableList/index.ios.tsx
diff --git a/src/components/MapView/responder/index.android.ts b/src/components/MapView/responder/index.android.ts
new file mode 100644
index 000000000000..9cbcde11ca7b
--- /dev/null
+++ b/src/components/MapView/responder/index.android.ts
@@ -0,0 +1,11 @@
+import {PanResponder} from 'react-native';
+
+const InterceptPanResponderCapture = PanResponder.create({
+ onStartShouldSetPanResponder: () => true,
+ onStartShouldSetPanResponderCapture: () => true,
+ onMoveShouldSetPanResponder: () => true,
+ onMoveShouldSetPanResponderCapture: () => true,
+ onPanResponderTerminationRequest: () => false,
+});
+
+export default InterceptPanResponderCapture;
diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx
index 26acb179736d..a59b50e5bdb7 100644
--- a/src/components/MoneyRequestAmountInput.tsx
+++ b/src/components/MoneyRequestAmountInput.tsx
@@ -69,7 +69,11 @@ type MoneyRequestAmountInputProps = {
/** Whether we want to format the display amount on blur */
formatAmountOnBlur?: boolean;
+ /** Max length for the amount input */
maxLength?: number;
+
+ /** Hide the focus styles on TextInput */
+ hideFocusedState?: boolean;
};
type Selection = {
@@ -99,6 +103,7 @@ function MoneyRequestAmountInput(
disableKeyboard = true,
formatAmountOnBlur,
maxLength,
+ hideFocusedState = true,
...props
}: MoneyRequestAmountInputProps,
forwardedRef: ForwardedRef,
@@ -279,6 +284,7 @@ function MoneyRequestAmountInput(
prefixContainerStyle={props.prefixContainerStyle}
touchableInputWrapperStyle={props.touchableInputWrapperStyle}
maxLength={maxLength}
+ hideFocusedState={hideFocusedState}
/>
);
}
diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx
index b97578210ad9..2c592c20f4c6 100755
--- a/src/components/MoneyRequestConfirmationList.tsx
+++ b/src/components/MoneyRequestConfirmationList.tsx
@@ -302,10 +302,12 @@ function MoneyRequestConfirmationList({
const canUpdateSenderWorkspace = useMemo(() => PolicyUtils.canSendInvoice(allPolicies) && !!transaction?.isFromGlobalCreate, [allPolicies, transaction?.isFromGlobalCreate]);
// A flag for showing the tags field
- const shouldShowTags = useMemo(() => isPolicyExpenseChat && OptionsListUtils.hasEnabledTags(policyTagLists), [isPolicyExpenseChat, policyTagLists]);
+ // TODO: remove the !isTypeInvoice from this condition after BE supports tags for invoices: https://github.com/Expensify/App/issues/41281
+ const shouldShowTags = useMemo(() => isPolicyExpenseChat && OptionsListUtils.hasEnabledTags(policyTagLists) && !isTypeInvoice, [isPolicyExpenseChat, policyTagLists, isTypeInvoice]);
// A flag for showing tax rate
- const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy);
+ // TODO: remove the !isTypeInvoice from this condition after BE supports tax for invoices: https://github.com/Expensify/App/issues/41281
+ const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy) && !isTypeInvoice;
// A flag for showing the billable field
const shouldShowBillable = policy?.disabledFields?.defaultBillable === false;
@@ -471,9 +473,8 @@ function MoneyRequestConfirmationList({
let amount: number | undefined = 0;
if (iouAmount > 0) {
amount =
- isPolicyExpenseChat || !transaction?.comment?.splits
- ? IOUUtils.calculateAmount(selectedParticipants.length, iouAmount, iouCurrencyCode ?? '', isPayer)
- : transaction.comment.splits.find((split) => split.accountID === participantOption.accountID)?.amount;
+ transaction?.comment?.splits?.find((split) => split.accountID === participantOption.accountID)?.amount ??
+ IOUUtils.calculateAmount(selectedParticipants.length, iouAmount, iouCurrencyCode ?? '', isPayer);
}
return {
...participantOption,
@@ -500,7 +501,7 @@ function MoneyRequestConfirmationList({
onAmountChange: (value: string) => onSplitShareChange(participantOption.accountID ?? 0, Number(value)),
},
}));
- }, [transaction, iouCurrencyCode, isPolicyExpenseChat, onSplitShareChange, payeePersonalDetails, selectedParticipants, currencyList, iouAmount, shouldShowReadOnlySplits, StyleUtils]);
+ }, [transaction, iouCurrencyCode, onSplitShareChange, payeePersonalDetails, selectedParticipants, currencyList, iouAmount, shouldShowReadOnlySplits, StyleUtils]);
const isSplitModified = useMemo(() => {
if (!transaction?.splitShares) {
@@ -1090,6 +1091,7 @@ function MoneyRequestConfirmationList({
onConfirmSelection={confirm}
selectedOptions={selectedOptions}
disableArrowKeysActions
+ disableFocusOptions
boldStyle
showTitleTooltip
shouldTextInputAppearBelowOptions
diff --git a/src/components/OptionRow.tsx b/src/components/OptionRow.tsx
index 376e0113ca64..d00120a594d8 100644
--- a/src/components/OptionRow.tsx
+++ b/src/components/OptionRow.tsx
@@ -260,16 +260,16 @@ function OptionRow({
prefixCharacter={option.amountInputProps.prefixCharacter}
disableKeyboard={false}
isCurrencyPressable={false}
+ hideFocusedState={false}
hideCurrencySymbol
formatAmountOnBlur
- touchableInputWrapperStyle={[styles.optionRowAmountInputWrapper, option.amountInputProps.containerStyle]}
prefixContainerStyle={[styles.pv0]}
+ containerStyle={[styles.textInputContainer]}
inputStyle={[
styles.optionRowAmountInput,
StyleUtils.getPaddingLeft(StyleUtils.getCharacterPadding(option.amountInputProps.prefixCharacter ?? '') + styles.pl1.paddingLeft) as TextStyle,
option.amountInputProps.inputStyle,
]}
- containerStyle={styles.iouAmountTextInputContainer}
onAmountChange={option.amountInputProps.onAmountChange}
maxLength={option.amountInputProps.maxLength}
/>
diff --git a/src/components/ReportHeaderSkeletonView.tsx b/src/components/ReportHeaderSkeletonView.tsx
index bc4eef675170..3a94516b2c29 100644
--- a/src/components/ReportHeaderSkeletonView.tsx
+++ b/src/components/ReportHeaderSkeletonView.tsx
@@ -48,8 +48,8 @@ function ReportHeaderSkeletonView({shouldAnimate = true, onBackButtonPress = ()
animate={shouldAnimate}
width={styles.w100.width}
height={height}
- backgroundColor={theme.highlightBG}
- foregroundColor={theme.border}
+ backgroundColor={theme.skeletonLHNIn}
+ foregroundColor={theme.skeletonLHNOut}
>
({
}
onSelectRow(item);
}}
- disabled={isDisabled}
+ disabled={isDisabled && !item.isSelected}
accessibilityLabel={item.text ?? ''}
role={CONST.ROLE.BUTTON}
hoverDimmingValue={1}
diff --git a/src/components/TagPicker/index.tsx b/src/components/TagPicker/index.tsx
index 97cd9aa5c691..cbd9418a83e9 100644
--- a/src/components/TagPicker/index.tsx
+++ b/src/components/TagPicker/index.tsx
@@ -15,6 +15,7 @@ import type {PolicyTag, PolicyTagList, PolicyTags, RecentlyUsedTags} from '@src/
type SelectedTagOption = {
name: string;
enabled: boolean;
+ isSelected?: boolean;
accountID: number | undefined;
};
diff --git a/src/components/TaxPicker.tsx b/src/components/TaxPicker.tsx
index 0aed28681d5c..9553fe4451ad 100644
--- a/src/components/TaxPicker.tsx
+++ b/src/components/TaxPicker.tsx
@@ -53,14 +53,14 @@ function TaxPicker({selectedTaxRate = '', policy, insets, onSubmit}: TaxPickerPr
return [
{
- name: selectedTaxRate,
- enabled: true,
+ modifiedName: selectedTaxRate,
+ isDisabled: false,
accountID: null,
},
];
}, [selectedTaxRate]);
- const sections = useMemo(() => OptionsListUtils.getTaxRatesSection(taxRates, selectedOptions as OptionsListUtils.Category[], searchValue), [taxRates, searchValue, selectedOptions]);
+ const sections = useMemo(() => OptionsListUtils.getTaxRatesSection(taxRates, selectedOptions as OptionsListUtils.Tax[], searchValue), [taxRates, searchValue, selectedOptions]);
const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(sections[0].data.length > 0, searchValue);
diff --git a/src/components/TextInputWithCurrencySymbol/types.ts b/src/components/TextInputWithCurrencySymbol/types.ts
index a55436225bbf..b31f27aef786 100644
--- a/src/components/TextInputWithCurrencySymbol/types.ts
+++ b/src/components/TextInputWithCurrencySymbol/types.ts
@@ -62,7 +62,11 @@ type TextInputWithCurrencySymbolProps = {
/** Customizes the touchable wrapper of the TextInput component */
touchableInputWrapperStyle?: StyleProp;
+ /** Max length for the amount input */
maxLength?: number;
+
+ /** Hide the focus styles on TextInput */
+ hideFocusedState?: boolean;
} & Pick;
export default TextInputWithCurrencySymbolProps;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 6e0ef3e8385b..54b739080fef 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -610,7 +610,7 @@ export default {
splitBill: 'Split expense',
splitScan: 'Split receipt',
splitDistance: 'Split distance',
- sendMoney: 'Pay someone',
+ paySomeone: ({name}: PaySomeoneParams) => `Pay ${name ?? 'someone'}`,
assignTask: 'Assign task',
header: 'Quick action',
trackManual: 'Track expense',
@@ -2022,10 +2022,19 @@ export default {
organizationDescription: 'Select the organization in Xero you are importing data from.',
importDescription: 'Choose which coding configurations are imported from Xero to Expensify.',
trackingCategories: 'Tracking categories',
+ trackingCategoriesDescription: 'Choose whether to import tracking categories and see where they are displayed.',
+ mapXeroCostCentersTo: 'Map Xero cost centers to',
+ mapXeroRegionsTo: 'Map Xero regions to',
+ mapXeroCostCentersToDescription: 'Choose where to map cost centers to when exporting to Xero.',
+ mapXeroRegionsToDescription: 'Choose where to map employee regions when exporting expense reports to Xero.',
customers: 'Re-bill customers',
customersDescription: 'Import customer contacts. Billable expenses need tags for export. Expenses will carry the customer information to Xero for sales invoices.',
taxesDescription: 'Choose whether to import tax rates and tax defaults from your accounting integration.',
notImported: 'Not imported',
+ trackingCategoriesOptions: {
+ default: 'Xero contact default',
+ tag: 'Tags',
+ },
export: 'Export',
exportDescription: 'Configure how data in Expensify gets exported to Xero.',
exportCompanyCard: 'Export company card expenses as',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index b6058f05bfb3..f91df53edd2c 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -603,7 +603,7 @@ export default {
splitBill: 'Dividir gasto',
splitScan: 'Dividir recibo',
splitDistance: 'Dividir distancia',
- sendMoney: 'Pagar a alguien',
+ paySomeone: ({name}: PaySomeoneParams) => `Pagar a ${name ?? 'alguien'}`,
assignTask: 'Assignar tarea',
header: 'Acción rápida',
trackManual: 'Crear gasto',
@@ -2054,11 +2054,20 @@ export default {
organizationDescription: 'Seleccione la organización en Xero desde la que está importando los datos.',
importDescription: 'Elija qué configuraciones de codificación se importan de Xero a Expensify.',
trackingCategories: 'Categorías de seguimiento',
+ trackingCategoriesDescription: 'Elige si deseas importar categorías de seguimiento y ver dónde se muestran.',
+ mapXeroCostCentersTo: 'Asignar centros de coste de Xero a',
+ mapXeroRegionsTo: 'Asignar regiones de Xero a',
+ mapXeroCostCentersToDescription: 'Elige dónde mapear los centros de coste al exportar a Xero.',
+ mapXeroRegionsToDescription: 'Elige dónde asignar las regiones de los empleados al exportar informes de gastos a Xero.',
customers: 'Volver a facturar a los clientes',
customersDescription:
'Importar contactos de clientes. Los gastos facturables necesitan etiquetas para la exportación. Los gastos llevarán la información del cliente a Xero para las facturas de ventas.',
taxesDescription: 'Elige si quires importar las tasas de impuestos y los impuestos por defecto de tu integración de contaduría.',
notImported: 'No importado',
+ trackingCategoriesOptions: {
+ default: 'Contacto de Xero por defecto',
+ tag: 'Etiquetas',
+ },
export: 'Exportar',
exportDescription: 'Configura cómo se exportan los datos de Expensify a Xero.',
exportCompanyCard: 'Exportar gastos de la tarjeta de empresa como',
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index ca83b07db69e..26307efb596e 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -305,6 +305,10 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/accounting/xero/XeroOrganizationConfigurationPage').default as React.ComponentType,
[SCREENS.WORKSPACE.ACCOUNTING.XERO_CUSTOMER]: () => require('../../../../pages/workspace/accounting/xero/import/XeroCustomerConfigurationPage').default as React.ComponentType,
[SCREENS.WORKSPACE.ACCOUNTING.XERO_TAXES]: () => require('../../../../pages/workspace/accounting/xero/XeroTaxesConfigurationPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.ACCOUNTING.XERO_TRACKING_CATEGORIES]: () =>
+ require('../../../../pages/workspace/accounting/xero/XeroTrackingCategoryConfigurationPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.ACCOUNTING.XERO_MAP_COST_CENTERS]: () => require('../../../../pages/workspace/accounting/xero/XeroMapCostCentersToConfigurationPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.ACCOUNTING.XERO_MAP_REGION]: () => require('../../../../pages/workspace/accounting/xero/XeroMapRegionsToConfigurationPage').default as React.ComponentType,
[SCREENS.WORKSPACE.ACCOUNTING.XERO_EXPORT]: () => require('../../../../pages/workspace/accounting/xero/export/XeroExportConfigurationPage').default as React.ComponentType,
[SCREENS.WORKSPACE.ACCOUNTING.XERO_EXPORT_PURCHASE_BILL_DATE_SELECT]: () =>
require('../../../../pages/workspace/accounting/xero/export/XeroPurchaseBillDateSelectPage').default as React.ComponentType,
diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
index 1d9ac01d91fc..19e81be1a5b2 100755
--- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
@@ -44,6 +44,9 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
SCREENS.WORKSPACE.ACCOUNTING.XERO_ORGANIZATION,
SCREENS.WORKSPACE.ACCOUNTING.XERO_CUSTOMER,
SCREENS.WORKSPACE.ACCOUNTING.XERO_TAXES,
+ SCREENS.WORKSPACE.ACCOUNTING.XERO_TRACKING_CATEGORIES,
+ SCREENS.WORKSPACE.ACCOUNTING.XERO_MAP_COST_CENTERS,
+ SCREENS.WORKSPACE.ACCOUNTING.XERO_MAP_REGION,
SCREENS.WORKSPACE.ACCOUNTING.XERO_EXPORT,
SCREENS.WORKSPACE.ACCOUNTING.XERO_EXPORT_PURCHASE_BILL_DATE_SELECT,
SCREENS.WORKSPACE.ACCOUNTING.XERO_ADVANCED,
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index acfe0e503a9c..af468c247004 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -329,6 +329,9 @@ const config: LinkingOptions['config'] = {
},
[SCREENS.WORKSPACE.ACCOUNTING.XERO_IMPORT]: {path: ROUTES.POLICY_ACCOUNTING_XERO_IMPORT.route},
[SCREENS.WORKSPACE.ACCOUNTING.XERO_ORGANIZATION]: {path: ROUTES.POLICY_ACCOUNTING_XERO_ORGANIZATION.route},
+ [SCREENS.WORKSPACE.ACCOUNTING.XERO_TRACKING_CATEGORIES]: {path: ROUTES.POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES.route},
+ [SCREENS.WORKSPACE.ACCOUNTING.XERO_MAP_COST_CENTERS]: {path: ROUTES.POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES_MAP_COST_CENTERS.route},
+ [SCREENS.WORKSPACE.ACCOUNTING.XERO_MAP_REGION]: {path: ROUTES.POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES_MAP_REGION.route},
[SCREENS.WORKSPACE.ACCOUNTING.XERO_CUSTOMER]: {path: ROUTES.POLICY_ACCOUNTING_XERO_CUSTOMER.route},
[SCREENS.WORKSPACE.ACCOUNTING.XERO_TAXES]: {path: ROUTES.POLICY_ACCOUNTING_XERO_TAXES.route},
[SCREENS.WORKSPACE.ACCOUNTING.XERO_EXPORT]: {path: ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.route},
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 2e174d773846..05a8f8739813 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -324,6 +324,15 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.ACCOUNTING.XERO_TAXES]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.ACCOUNTING.XERO_TRACKING_CATEGORIES]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.ACCOUNTING.XERO_MAP_COST_CENTERS]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.ACCOUNTING.XERO_MAP_REGION]: {
+ policyID: string;
+ };
[SCREENS.WORKSPACE.ACCOUNTING.XERO_EXPORT]: {
policyID: string;
};
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index ce02aec14fb4..082952e58f9e 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -126,6 +126,12 @@ type Category = {
isSelected?: boolean;
};
+type Tax = {
+ modifiedName: string;
+ isSelected?: boolean;
+ isDisabled?: boolean;
+};
+
type Hierarchy = Record;
type GetOptionsConfig = {
@@ -278,8 +284,14 @@ Onyx.connect({
// The report is only visible if it is the last action not deleted that
// does not match a closed or created state.
- const reportActionsForDisplay = sortedReportActions.filter((reportAction) => ReportActionUtils.shouldReportActionBeVisibleAsLastAction(reportAction));
- visibleReportActionItems[reportID] = reportActionsForDisplay[0];
+ const reportActionsForDisplay = sortedReportActions.filter(
+ (reportAction, actionKey) =>
+ ReportActionUtils.shouldReportActionBeVisible(reportAction, actionKey) &&
+ !ReportActionUtils.isWhisperAction(reportAction) &&
+ reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED &&
+ reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
+ );
+ visibleReportActionItems[reportID] = reportActionsForDisplay[reportActionsForDisplay.length - 1];
},
});
@@ -556,7 +568,7 @@ function getAlternateText(option: ReportUtils.OptionData, {showChatPreviewLine =
* Get the last message text from the report directly or from other sources for special cases.
*/
function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails: Partial | null, policy?: OnyxEntry): string {
- const lastReportAction = visibleReportActionItems[report?.reportID ?? ''] ?? null;
+ const lastReportAction = allSortedReportActions[report?.reportID ?? '']?.find((reportAction) => ReportActionUtils.shouldReportActionBeVisibleAsLastAction(reportAction)) ?? null;
// some types of actions are filtered out for lastReportAction, in some cases we need to check the actual last action
const lastOriginalReportAction = lastReportActions[report?.reportID ?? ''] ?? null;
@@ -1010,12 +1022,21 @@ function getCategoryListSections(
): CategoryTreeSection[] {
const sortedCategories = sortCategories(categories);
const enabledCategories = Object.values(sortedCategories).filter((category) => category.enabled);
-
+ const enabledCategoriesNames = enabledCategories.map((category) => category.name);
+ const selectedOptionsWithDisabledState: Category[] = [];
const categorySections: CategoryTreeSection[] = [];
const numberOfEnabledCategories = enabledCategories.length;
+ selectedOptions.forEach((option) => {
+ if (enabledCategoriesNames.includes(option.name)) {
+ selectedOptionsWithDisabledState.push({...option, isSelected: true, enabled: true});
+ return;
+ }
+ selectedOptionsWithDisabledState.push({...option, isSelected: true, enabled: false});
+ });
+
if (numberOfEnabledCategories === 0 && selectedOptions.length > 0) {
- const data = getCategoryOptionTree(selectedOptions, true);
+ const data = getCategoryOptionTree(selectedOptionsWithDisabledState, true);
categorySections.push({
// "Selected" section
title: '',
@@ -1028,9 +1049,10 @@ function getCategoryListSections(
}
if (searchInputValue) {
+ const categoriesForSearch = [...selectedOptionsWithDisabledState, ...enabledCategories];
const searchCategories: Category[] = [];
- enabledCategories.forEach((category) => {
+ categoriesForSearch.forEach((category) => {
if (!category.name.toLowerCase().includes(searchInputValue.toLowerCase())) {
return;
}
@@ -1053,7 +1075,7 @@ function getCategoryListSections(
}
if (selectedOptions.length > 0) {
- const data = getCategoryOptionTree(selectedOptions, true);
+ const data = getCategoryOptionTree(selectedOptionsWithDisabledState, true);
categorySections.push({
// "Selected" section
title: '',
@@ -1144,34 +1166,42 @@ function getTagListSections(
const tagSections = [];
const sortedTags = sortTags(tags) as PolicyTag[];
const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name);
- const enabledTags = [...selectedOptions, ...sortedTags.filter((tag) => tag.enabled && !selectedOptionNames.includes(tag.name))];
+ const enabledTags = sortedTags.filter((tag) => tag.enabled);
+ const enabledTagsNames = enabledTags.map((tag) => tag.name);
+ const enabledTagsWithoutSelectedOptions = enabledTags.filter((tag) => !selectedOptionNames.includes(tag.name));
+ const selectedTagsWithDisabledState: SelectedTagOption[] = [];
const numberOfTags = enabledTags.length;
+ selectedOptions.forEach((tag) => {
+ if (enabledTagsNames.includes(tag.name)) {
+ selectedTagsWithDisabledState.push({...tag, enabled: true});
+ return;
+ }
+ selectedTagsWithDisabledState.push({...tag, enabled: false});
+ });
+
// If all tags are disabled but there's a previously selected tag, show only the selected tag
if (numberOfTags === 0 && selectedOptions.length > 0) {
- const selectedTagOptions = selectedOptions.map((option) => ({
- name: option.name,
- // Should be marked as enabled to be able to be de-selected
- enabled: true,
- }));
tagSections.push({
// "Selected" section
title: '',
shouldShow: false,
- data: getTagsOptions(selectedTagOptions, selectedOptions),
+ data: getTagsOptions(selectedTagsWithDisabledState, selectedOptions),
});
return tagSections;
}
if (searchInputValue) {
- const searchTags = enabledTags.filter((tag) => PolicyUtils.getCleanedTagName(tag.name.toLowerCase()).includes(searchInputValue.toLowerCase()));
+ const enabledSearchTags = enabledTagsWithoutSelectedOptions.filter((tag) => PolicyUtils.getCleanedTagName(tag.name.toLowerCase()).includes(searchInputValue.toLowerCase()));
+ const selectedSearchTags = selectedTagsWithDisabledState.filter((tag) => PolicyUtils.getCleanedTagName(tag.name.toLowerCase()).includes(searchInputValue.toLowerCase()));
+ const tagsForSearch = [...selectedSearchTags, ...enabledSearchTags];
tagSections.push({
// "Search" section
title: '',
shouldShow: true,
- data: getTagsOptions(searchTags, selectedOptions),
+ data: getTagsOptions(tagsForSearch, selectedOptions),
});
return tagSections;
@@ -1182,7 +1212,7 @@ function getTagListSections(
// "All" section when items amount less than the threshold
title: '',
shouldShow: false,
- data: getTagsOptions(enabledTags, selectedOptions),
+ data: getTagsOptions([...selectedTagsWithDisabledState, ...enabledTagsWithoutSelectedOptions], selectedOptions),
});
return tagSections;
@@ -1194,20 +1224,13 @@ function getTagListSections(
return !!tagObject?.enabled && !selectedOptionNames.includes(recentlyUsedTag);
})
.map((tag) => ({name: tag, enabled: true}));
- const filteredTags = enabledTags.filter((tag) => !selectedOptionNames.includes(tag.name));
if (selectedOptions.length) {
- const selectedTagOptions = selectedOptions.map((option) => ({
- name: option.name,
- // Should be marked as enabled to be able to unselect even though the selected category is disabled
- enabled: true,
- }));
-
tagSections.push({
// "Selected" section
title: '',
shouldShow: true,
- data: getTagsOptions(selectedTagOptions, selectedOptions),
+ data: getTagsOptions(selectedTagsWithDisabledState, selectedOptions),
});
}
@@ -1226,7 +1249,7 @@ function getTagListSections(
// "All" section when items amount more than the threshold
title: Localize.translateLocal('common.all'),
shouldShow: true,
- data: getTagsOptions(filteredTags, selectedOptions),
+ data: getTagsOptions(enabledTagsWithoutSelectedOptions, selectedOptions),
});
return tagSections;
@@ -1349,46 +1372,56 @@ function getTaxRatesOptions(taxRates: Array>): TaxRatesOption[]
tooltipText: taxRate.modifiedName,
isDisabled: taxRate.isDisabled,
data: taxRate,
+ isSelected: taxRate.isSelected,
}));
}
/**
* Builds the section list for tax rates
*/
-function getTaxRatesSection(taxRates: TaxRatesWithDefault | undefined, selectedOptions: Category[], searchInputValue: string): TaxSection[] {
+function getTaxRatesSection(taxRates: TaxRatesWithDefault | undefined, selectedOptions: Tax[], searchInputValue: string): TaxSection[] {
const policyRatesSections = [];
const taxes = transformedTaxRates(taxRates);
const sortedTaxRates = sortTaxRates(taxes);
+ const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.modifiedName);
const enabledTaxRates = sortedTaxRates.filter((taxRate) => !taxRate.isDisabled);
+ const enabledTaxRatesNames = enabledTaxRates.map((tax) => tax.modifiedName);
+ const enabledTaxRatesWithoutSelectedOptions = enabledTaxRates.filter((tax) => tax.modifiedName && !selectedOptionNames.includes(tax.modifiedName));
+ const selectedTaxRateWithDisabledState: Tax[] = [];
const numberOfTaxRates = enabledTaxRates.length;
+ selectedOptions.forEach((tax) => {
+ if (enabledTaxRatesNames.includes(tax.modifiedName)) {
+ selectedTaxRateWithDisabledState.push({...tax, isDisabled: false, isSelected: true});
+ return;
+ }
+ selectedTaxRateWithDisabledState.push({...tax, isDisabled: true, isSelected: true});
+ });
+
// If all tax are disabled but there's a previously selected tag, show only the selected tag
if (numberOfTaxRates === 0 && selectedOptions.length > 0) {
- const selectedTaxRateOptions = selectedOptions.map((option) => ({
- modifiedName: option.name,
- // Should be marked as enabled to be able to be de-selected
- isDisabled: false,
- }));
policyRatesSections.push({
// "Selected" sectiong
title: '',
shouldShow: false,
- data: getTaxRatesOptions(selectedTaxRateOptions),
+ data: getTaxRatesOptions(selectedTaxRateWithDisabledState),
});
return policyRatesSections;
}
if (searchInputValue) {
- const searchTaxRates = enabledTaxRates.filter((taxRate) => taxRate.modifiedName?.toLowerCase().includes(searchInputValue.toLowerCase()));
+ const enabledSearchTaxRates = enabledTaxRatesWithoutSelectedOptions.filter((taxRate) => taxRate.modifiedName?.toLowerCase().includes(searchInputValue.toLowerCase()));
+ const selectedSearchTags = selectedTaxRateWithDisabledState.filter((taxRate) => taxRate.modifiedName?.toLowerCase().includes(searchInputValue.toLowerCase()));
+ const taxesForSearch = [...selectedSearchTags, ...enabledSearchTaxRates];
policyRatesSections.push({
// "Search" section
title: '',
shouldShow: true,
- data: getTaxRatesOptions(searchTaxRates),
+ data: getTaxRatesOptions(taxesForSearch),
});
return policyRatesSections;
@@ -1399,30 +1432,18 @@ function getTaxRatesSection(taxRates: TaxRatesWithDefault | undefined, selectedO
// "All" section when items amount less than the threshold
title: '',
shouldShow: false,
- data: getTaxRatesOptions(enabledTaxRates),
+ data: getTaxRatesOptions([...selectedTaxRateWithDisabledState, ...enabledTaxRatesWithoutSelectedOptions]),
});
return policyRatesSections;
}
- const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name);
- const filteredTaxRates = enabledTaxRates.filter((taxRate) => taxRate.modifiedName && !selectedOptionNames.includes(taxRate.modifiedName));
-
if (selectedOptions.length > 0) {
- const selectedTaxRatesOptions = selectedOptions.map((option) => {
- const taxRateObject = Object.values(taxes).find((taxRate) => taxRate.modifiedName === option.name);
-
- return {
- modifiedName: option.name,
- isDisabled: !!taxRateObject?.isDisabled,
- };
- });
-
policyRatesSections.push({
// "Selected" section
title: '',
shouldShow: true,
- data: getTaxRatesOptions(selectedTaxRatesOptions),
+ data: getTaxRatesOptions(selectedTaxRateWithDisabledState),
});
}
@@ -1430,7 +1451,7 @@ function getTaxRatesSection(taxRates: TaxRatesWithDefault | undefined, selectedO
// "All" section when number of items are more than the threshold
title: '',
shouldShow: true,
- data: getTaxRatesOptions(filteredTaxRates),
+ data: getTaxRatesOptions(enabledTaxRatesWithoutSelectedOptions),
});
return policyRatesSections;
@@ -1684,7 +1705,7 @@ function getOptions(
}
if (includeTaxRates) {
- const taxRatesOptions = getTaxRatesSection(taxRates, selectedOptions as Category[], searchInputValue);
+ const taxRatesOptions = getTaxRatesSection(taxRates, selectedOptions as Tax[], searchInputValue);
return {
recentReports: [],
@@ -2424,4 +2445,4 @@ export {
getFirstKeyForList,
};
-export type {MemberForList, CategorySection, CategoryTreeSection, Options, OptionList, SearchOption, PayeePersonalDetails, Category, TaxRatesOption, Option, OptionTree};
+export type {MemberForList, CategorySection, CategoryTreeSection, Options, OptionList, SearchOption, PayeePersonalDetails, Category, Tax, TaxRatesOption, Option, OptionTree};
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index f886558c54f6..51221ddb1236 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -143,20 +143,22 @@ const isPolicyEmployee = (policyID: string, policies: OnyxCollection): b
/**
* Checks if the current user is an owner (creator) of the policy.
*/
-const isPolicyOwner = (policy: OnyxEntry | EmptyObject, currentUserAccountID: number): boolean => policy?.ownerAccountID === currentUserAccountID;
+const isPolicyOwner = (policy: OnyxEntry, currentUserAccountID: number): boolean => policy?.ownerAccountID === currentUserAccountID;
/**
- * Create an object mapping member emails to their accountIDs. Filter for members without errors, and get the login email from the personalDetail object using the accountID.
+ * Create an object mapping member emails to their accountIDs. Filter for members without errors if includeMemberWithErrors is false, and get the login email from the personalDetail object using the accountID.
*
- * We only return members without errors. Otherwise, the members with errors would immediately be removed before the user has a chance to read the error.
+ * If includeMemberWithErrors is false, We only return members without errors. Otherwise, the members with errors would immediately be removed before the user has a chance to read the error.
*/
-function getMemberAccountIDsForWorkspace(employeeList: PolicyEmployeeList | undefined): MemberEmailsToAccountIDs {
+function getMemberAccountIDsForWorkspace(employeeList: PolicyEmployeeList | undefined, includeMemberWithErrors = false): MemberEmailsToAccountIDs {
const members = employeeList ?? {};
const memberEmailsToAccountIDs: MemberEmailsToAccountIDs = {};
Object.keys(members).forEach((email) => {
- const member = members?.[email];
- if (Object.keys(member?.errors ?? {})?.length > 0) {
- return;
+ if (!includeMemberWithErrors) {
+ const member = members?.[email];
+ if (Object.keys(member?.errors ?? {})?.length > 0) {
+ return;
+ }
}
const personalDetail = getPersonalDetailByEmail(email);
if (!personalDetail?.login) {
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 641d3ddaa268..64b77cae6313 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -6393,7 +6393,7 @@ function hasActionsWithErrors(reportID: string): boolean {
return Object.values(reportActions).some((action) => !isEmptyObject(action.errors));
}
-function canLeavePolicyExpenseChat(report: OnyxEntry, policy: OnyxEntry | EmptyObject): boolean {
+function canLeavePolicyExpenseChat(report: OnyxEntry, policy: OnyxEntry): boolean {
return isPolicyExpenseChat(report) && !(PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPolicyOwner(policy, currentUserAccountID ?? -1) || isReportOwner(report));
}
@@ -6686,6 +6686,7 @@ export {
isExpensifyOnlyParticipantInReport,
isGroupChat,
isGroupChatAdmin,
+ isGroupPolicy,
isReportInGroupPolicy,
isHoldCreator,
isIOUOwnedByCurrentUser,
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index 74fab75dcc18..c0d0c9020a64 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -36,7 +36,13 @@ Onyx.connect({
// The report is only visible if it is the last action not deleted that
// does not match a closed or created state.
- const reportActionsForDisplay = actionsArray.filter((reportAction) => ReportActionsUtils.shouldReportActionBeVisibleAsLastAction(reportAction));
+ const reportActionsForDisplay = actionsArray.filter(
+ (reportAction, actionKey) =>
+ ReportActionsUtils.shouldReportActionBeVisible(reportAction, actionKey) &&
+ !ReportActionsUtils.isWhisperAction(reportAction) &&
+ reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED &&
+ reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
+ );
visibleReportActionItems[reportID] = reportActionsForDisplay[reportActionsForDisplay.length - 1];
},
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 79ee20971e5d..7171a7d6732a 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -6423,9 +6423,8 @@ function setSplitShares(transaction: OnyxEntry, amount: n
}
const isPayer = accountID === userAccountID;
-
- // This function expects the length of participants without current user
- const splitAmount = IOUUtils.calculateAmount(accountIDs.length - 1, amount, currency, isPayer);
+ const participantsLength = newAccountIDs.includes(userAccountID) ? newAccountIDs.length - 1 : newAccountIDs.length;
+ const splitAmount = IOUUtils.calculateAmount(participantsLength, amount, currency, isPayer);
return {
...result,
[accountID]: {
diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts
index b4022b287d05..6dc46383f3fa 100644
--- a/src/libs/actions/Policy.ts
+++ b/src/libs/actions/Policy.ts
@@ -1415,7 +1415,7 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccount
const optimisticMembersState: OnyxCollection = {};
const successMembersState: OnyxCollection = {};
const failureMembersState: OnyxCollection = {};
- Object.keys(invitedEmailsToAccountIDs).forEach((email) => {
+ logins.forEach((email) => {
optimisticMembersState[email] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, role: CONST.POLICY.ROLE.USER};
successMembersState[email] = {pendingAction: null};
failureMembersState[email] = {
@@ -2749,7 +2749,7 @@ function setWorkspaceInviteMessageDraft(policyID: string, message: string | null
}
function clearErrors(policyID: string) {
- setWorkspaceErrors(policyID, {});
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errors: null});
hideWorkspaceAlertMessage(policyID);
}
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index d71664a959ed..3154ae218d72 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -1220,8 +1220,8 @@ function togglePinnedState(reportID: string, isPinnedChat: boolean) {
* tab, refresh etc without worrying about loosing what they typed out.
* When empty string or null is passed, it will delete the draft comment from Onyx store.
*/
-function saveReportDraftComment(reportID: string, comment: string | null) {
- Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, prepareDraftComment(comment));
+function saveReportDraftComment(reportID: string, comment: string | null, callback: () => void = () => {}) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, prepareDraftComment(comment)).then(callback);
}
/** Broadcasts whether or not a user is typing on a report over the report's private pusher channel. */
@@ -1252,13 +1252,17 @@ function handleReportChanged(report: OnyxEntry) {
// In this case, the API will let us know by returning a preexistingReportID.
// We should clear out the optimistically created report and re-route the user to the preexisting report.
if (report?.reportID && report.preexistingReportID) {
- Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, null);
-
+ let callback = () => {};
// Only re-route them if they are still looking at the optimistically created report
if (Navigation.getActiveRoute().includes(`/r/${report.reportID}`)) {
- // Pass 'FORCED_UP' type to replace new report on second login with proper one in the Navigation
- Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report.preexistingReportID), CONST.NAVIGATION.TYPE.FORCED_UP);
+ callback = () => {
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report.preexistingReportID ?? ''), CONST.NAVIGATION.TYPE.FORCED_UP);
+ };
}
+ DeviceEventEmitter.emit(`switchToPreExistingReport_${report.reportID}`, {
+ preexistingReportID: report.preexistingReportID,
+ callback,
+ });
return;
}
diff --git a/src/libs/actions/connections/ConnectToXero.ts b/src/libs/actions/connections/ConnectToXero.ts
index b5e8d7ab3298..43972e540d58 100644
--- a/src/libs/actions/connections/ConnectToXero.ts
+++ b/src/libs/actions/connections/ConnectToXero.ts
@@ -1,6 +1,10 @@
+import type {OnyxEntry} from 'react-native-onyx';
import type {ConnectPolicyToAccountingIntegrationParams} from '@libs/API/parameters';
import {READ_COMMANDS} from '@libs/API/types';
import {getCommandURL} from '@libs/ApiUtils';
+import CONST from '@src/CONST';
+import type * as OnyxTypes from '@src/types/onyx';
+import type {XeroTrackingCategory} from '@src/types/onyx/Policy';
const getXeroSetupLink = (policyID: string) => {
const params: ConnectPolicyToAccountingIntegrationParams = {policyID};
@@ -8,4 +12,26 @@ const getXeroSetupLink = (policyID: string) => {
return commandURL + new URLSearchParams(params).toString();
};
-export default getXeroSetupLink;
+/**
+ * Fetches the category object from the xero.data.trackingCategories based on the category name.
+ * This is required to get Xero category object with current value stored in the xero.config.mappings
+ * @param policy
+ * @param key
+ * @returns Filtered category matching the category name or undefined.
+ */
+const getTrackingCategory = (policy: OnyxEntry, categoryName: string): (XeroTrackingCategory & {value: string}) | undefined => {
+ const {trackingCategories} = policy?.connections?.xero?.data ?? {};
+ const {mappings} = policy?.connections?.xero?.config ?? {};
+
+ const category = trackingCategories?.find((currentCategory) => currentCategory.name.toLowerCase() === categoryName.toLowerCase());
+ if (!category) {
+ return undefined;
+ }
+
+ return {
+ ...category,
+ value: mappings?.[`${CONST.XERO_CONFIG.TRACKING_CATEGORY_PREFIX}${category.id}`] ?? '',
+ };
+};
+
+export {getXeroSetupLink, getTrackingCategory};
diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx
index e97df0450eb8..ca66a0e97bb5 100644
--- a/src/pages/ReportDetailsPage.tsx
+++ b/src/pages/ReportDetailsPage.tsx
@@ -1,6 +1,6 @@
import {useRoute} from '@react-navigation/native';
import type {StackScreenProps} from '@react-navigation/stack';
-import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import React, {useEffect, useMemo} from 'react';
import {View} from 'react-native';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
@@ -29,7 +29,6 @@ import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as Report from '@userActions/Report';
-import ConfirmModal from '@src/components/ConfirmModal';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -50,7 +49,6 @@ type ReportDetailsPageMenuItem = {
action: () => void;
brickRoadIndicator?: ValueOf;
subtitle?: number;
- shouldShowRightIcon?: boolean;
};
type ReportDetailsPageOnyxProps = {
@@ -67,12 +65,10 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
const {isOffline} = useNetwork();
const styles = useThemeStyles();
const route = useRoute();
- const [isLastMemberLeavingGroupModalVisible, setIsLastMemberLeavingGroupModalVisible] = useState(false);
const policy = useMemo(() => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? ''}`], [policies, report?.policyID]);
const isPolicyAdmin = useMemo(() => PolicyUtils.isPolicyAdmin(policy ?? null), [policy]);
const isPolicyEmployee = useMemo(() => PolicyUtils.isPolicyEmployee(report?.policyID ?? '', policies), [report?.policyID, policies]);
const shouldUseFullTitle = useMemo(() => ReportUtils.shouldUseFullTitleToDisplay(report), [report]);
- const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report);
const isChatRoom = useMemo(() => ReportUtils.isChatRoom(report), [report]);
const isUserCreatedPolicyRoom = useMemo(() => ReportUtils.isUserCreatedPolicyRoom(report), [report]);
const isDefaultRoom = useMemo(() => ReportUtils.isDefaultRoom(report), [report]);
@@ -83,8 +79,6 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
const isInvoiceReport = useMemo(() => ReportUtils.isInvoiceReport(report), [report]);
const canEditReportDescription = useMemo(() => ReportUtils.canEditReportDescription(report, policy), [report, policy]);
const shouldShowReportDescription = isChatRoom && (canEditReportDescription || report.description !== '');
- const canLeaveRoom = ReportUtils.canLeaveRoom(report, isPolicyEmployee);
- const canLeavePolicyExpenseChat = ReportUtils.canLeavePolicyExpenseChat(report, policy ?? {});
// eslint-disable-next-line react-hooks/exhaustive-deps -- policy is a dependency because `getChatRoomSubtitle` calls `getPolicyName` which in turn retrieves the value from the `policy` value stored in Onyx
const chatRoomSubtitle = useMemo(() => ReportUtils.getChatRoomSubtitle(report), [report, policy]);
@@ -105,11 +99,10 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
return !pendingMember || pendingMember.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? accountID : [];
});
+ const isGroupDMChat = useMemo(() => ReportUtils.isDM(report) && participants.length > 1, [report, participants.length]);
const isPrivateNotesFetchTriggered = report?.isLoadingPrivateNotes !== undefined;
const isSelfDM = useMemo(() => ReportUtils.isSelfDM(report), [report]);
- const canLeave =
- !isSelfDM && (isChatThread || isUserCreatedPolicyRoom || canLeaveRoom || canLeavePolicyExpenseChat) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
useEffect(() => {
// Do not fetch private notes if isLoadingPrivateNotes is already defined, or if the network is offline, or if the report is a self DM.
@@ -120,15 +113,6 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
Report.getReportPrivateNote(report?.reportID ?? '');
}, [report?.reportID, isOffline, isPrivateNotesFetchTriggered, isSelfDM]);
- const leaveChat = useCallback(() => {
- if (isChatRoom) {
- const isWorkspaceMemberLeavingWorkspaceRoom = (report.visibility === CONST.REPORT.VISIBILITY.RESTRICTED || isPolicyExpenseChat) && isPolicyEmployee;
- Report.leaveRoom(report.reportID, isWorkspaceMemberLeavingWorkspaceRoom);
- return;
- }
- Report.leaveGroupChat(report.reportID);
- }, [isChatRoom, isPolicyEmployee, isPolicyExpenseChat, report.reportID, report.visibility]);
-
const menuItems: ReportDetailsPageMenuItem[] = useMemo(() => {
const items: ReportDetailsPageMenuItem[] = [];
@@ -136,6 +120,16 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
return [];
}
+ if (!isGroupDMChat) {
+ items.push({
+ key: CONST.REPORT_DETAILS_MENU_ITEM.SHARE_CODE,
+ translationKey: 'common.shareCode',
+ icon: Expensicons.QrCode,
+ isAnonymousAction: true,
+ action: () => Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.getRoute(report?.reportID ?? '')),
+ });
+ }
+
if (isArchivedRoom) {
return items;
}
@@ -202,27 +196,10 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
});
}
- if (isGroupChat || (isChatRoom && canLeave)) {
- items.push({
- key: CONST.REPORT_DETAILS_MENU_ITEM.LEAVE_ROOM,
- translationKey: 'common.leave',
- icon: Expensicons.Exit,
- isAnonymousAction: true,
- shouldShowRightIcon: false,
- action: () => {
- if (Object.keys(report?.participants ?? {}).length === 1 && isGroupChat) {
- setIsLastMemberLeavingGroupModalVisible(true);
- return;
- }
-
- leaveChat();
- },
- });
- }
-
return items;
}, [
isSelfDM,
+ isGroupDMChat,
isArchivedRoom,
isGroupChat,
isDefaultRoom,
@@ -232,12 +209,9 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
participants.length,
report,
isMoneyRequestReport,
- isChatRoom,
- canLeave,
+ isInvoiceReport,
activeChatMembers.length,
session,
- leaveChat,
- isInvoiceReport,
]);
const displayNamesWithTooltips = useMemo(() => {
@@ -346,10 +320,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
{shouldShowReportDescription && (
-
+
)}
- {(isGroupChat || isChatRoom) && }
+ {isGroupChat && }
{menuItems.map((item) => {
const brickRoadIndicator =
ReportUtils.hasReportNameError(report) && item.key === CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined;
@@ -373,25 +344,12 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
icon={item.icon}
onPress={item.action}
isAnonymousAction={item.isAnonymousAction}
- shouldShowRightIcon={item.shouldShowRightIcon ?? true}
+ shouldShowRightIcon
brickRoadIndicator={brickRoadIndicator ?? item.brickRoadIndicator}
/>
);
})}
- {
- setIsLastMemberLeavingGroupModalVisible(false);
- Report.leaveGroupChat(report.reportID);
- }}
- onCancel={() => setIsLastMemberLeavingGroupModalVisible(false)}
- prompt={translate('groupChat.lastMemberWarning')}
- confirmText={translate('common.leave')}
- cancelText={translate('common.cancel')}
- />
);
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
index 013bc484fc63..105eadffd436 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
@@ -396,9 +396,10 @@ const ContextMenuActions: ContextMenuAction[] = [
return type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !isAttachmentTarget && !ReportActionsUtils.isMessageDeleted(reportAction);
},
onPress: (closePopover, {reportAction, reportID}) => {
+ const originalReportID = ReportUtils.getOriginalReportID(reportID, reportAction);
Environment.getEnvironmentURL().then((environmentURL) => {
const reportActionID = reportAction?.reportActionID;
- Clipboard.setString(`${environmentURL}/r/${reportID}/${reportActionID}`);
+ Clipboard.setString(`${environmentURL}/r/${originalReportID}/${reportActionID}`);
});
hideContextMenu(true, ReportActionComposeFocusManager.focus);
},
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
index 469a7300a84f..3120bbe9bed2 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
@@ -12,7 +12,7 @@ import type {
TextInputKeyPressEventData,
TextInputSelectionChangeEventData,
} from 'react-native';
-import {findNodeHandle, InteractionManager, NativeModules, View} from 'react-native';
+import {DeviceEventEmitter, findNodeHandle, InteractionManager, NativeModules, View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import type {useAnimatedRef} from 'react-native-reanimated';
@@ -344,6 +344,20 @@ function ComposerWithSuggestions(
[],
);
+ useEffect(() => {
+ const switchToCurrentReport = DeviceEventEmitter.addListener(`switchToPreExistingReport_${reportID}`, ({preexistingReportID, callback}) => {
+ if (!commentRef.current) {
+ callback();
+ return;
+ }
+ Report.saveReportDraftComment(preexistingReportID, commentRef.current, callback);
+ });
+
+ return () => {
+ switchToCurrentReport.remove();
+ };
+ }, [reportID]);
+
/**
* Find the newly added characters between the previous text and the new text based on the selection.
*
diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
index 09023c312983..a0e1caf5db5e 100644
--- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
+++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
@@ -21,7 +21,6 @@ import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRo
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
-import {isArchivedRoom} from '@libs/ReportUtils';
import * as App from '@userActions/App';
import * as IOU from '@userActions/IOU';
import * as Policy from '@userActions/Policy';
@@ -144,7 +143,7 @@ const getQuickActionTitle = (action: QuickActionName): TranslationPaths => {
case CONST.QUICK_ACTIONS.TRACK_DISTANCE:
return 'quickAction.trackDistance';
case CONST.QUICK_ACTIONS.SEND_MONEY:
- return 'quickAction.sendMoney';
+ return 'quickAction.paySomeone';
case CONST.QUICK_ACTIONS.ASSIGN_TASK:
return 'quickAction.assignTask';
default:
@@ -195,12 +194,30 @@ function FloatingActionButtonAndPopover(
}, [personalDetails, session?.accountID, quickActionReport, quickActionPolicy]);
const quickActionTitle = useMemo(() => {
+ if (isEmptyObject(quickActionReport)) {
+ return '';
+ }
+ if (quickAction?.action === CONST.QUICK_ACTIONS.SEND_MONEY && quickActionAvatars.length > 0) {
+ const name: string = ReportUtils.getDisplayNameForParticipant(+(quickActionAvatars[0]?.id ?? 0), true) ?? '';
+ return translate('quickAction.paySomeone', {name});
+ }
const titleKey = getQuickActionTitle(quickAction?.action ?? ('' as QuickActionName));
return titleKey ? translate(titleKey) : '';
- }, [quickAction, translate]);
+ }, [quickAction, translate, quickActionAvatars, quickActionReport]);
+
+ const hideQABSubtitle = useMemo(() => {
+ if (isEmptyObject(quickActionReport)) {
+ return true;
+ }
+ if (quickActionAvatars.length === 0) {
+ return false;
+ }
+ const displayName = personalDetails?.[quickActionAvatars[0]?.id ?? 0]?.firstName ?? '';
+ return quickAction?.action === CONST.QUICK_ACTIONS.SEND_MONEY && displayName.length === 0;
+ }, [personalDetails, quickActionReport, quickAction?.action, quickActionAvatars]);
const navigateToQuickAction = () => {
- const isValidReport = !(isEmptyObject(quickActionReport) || isArchivedRoom(quickActionReport));
+ const isValidReport = !(isEmptyObject(quickActionReport) || ReportUtils.isArchivedRoom(quickActionReport));
const quickActionReportID = isValidReport ? quickActionReport?.reportID ?? '' : ReportUtils.generateReportID();
switch (quickAction?.action) {
case CONST.QUICK_ACTIONS.REQUEST_MANUAL:
@@ -439,7 +456,7 @@ function FloatingActionButtonAndPopover(
isLabelHoverable: false,
floatRightAvatars: quickActionAvatars,
floatRightAvatarSize: CONST.AVATAR_SIZE.SMALL,
- description: ReportUtils.getReportName(quickActionReport) ?? translate('quickAction.updateDestination'),
+ description: !hideQABSubtitle ? ReportUtils.getReportName(quickActionReport) ?? translate('quickAction.updateDestination') : '',
numberOfLinesDescription: 1,
onSelected: () => interceptAnonymousUser(() => navigateToQuickAction()),
shouldShowSubscriptRightAvatar: ReportUtils.isPolicyExpenseChat(quickActionReport),
diff --git a/src/pages/iou/request/step/IOURequestStepCategory.tsx b/src/pages/iou/request/step/IOURequestStepCategory.tsx
index 0bd546318186..d4919a4172aa 100644
--- a/src/pages/iou/request/step/IOURequestStepCategory.tsx
+++ b/src/pages/iou/request/step/IOURequestStepCategory.tsx
@@ -83,9 +83,11 @@ function IOURequestStepCategory({
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const reportAction = reportActions?.[report?.parentReportActionID || reportActionID] ?? null;
- // The transactionCategory can be an empty string, so to maintain the logic we'd like to keep it in this shape until utils refactor
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- const shouldShowCategory = ReportUtils.isReportInGroupPolicy(report, policy) && (!!transactionCategory || OptionsListUtils.hasEnabledOptions(Object.values(policyCategories ?? {})));
+ const shouldShowCategory =
+ (ReportUtils.isReportInGroupPolicy(report) || ReportUtils.isGroupPolicy(policy?.type ?? '')) &&
+ // The transactionCategory can be an empty string, so to maintain the logic we'd like to keep it in this shape until utils refactor
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ (!!transactionCategory || OptionsListUtils.hasEnabledOptions(Object.values(policyCategories ?? {})));
const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT;
const canEditSplitBill = isSplitBill && reportAction && session?.accountID === reportAction.actorAccountID && TransactionUtils.areRequiredFieldsEmpty(transaction);
@@ -153,7 +155,7 @@ function IOURequestStepCategory({
{translate('iou.categorySelection')}
@@ -170,19 +172,19 @@ const IOURequestStepCategoryWithOnyx = withOnyx `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`,
+ key: ({report, transaction}) => `${ONYXKEYS.COLLECTION.POLICY}${IOU.getIOURequestPolicyID(transaction, report)}`,
},
policyDraft: {
- key: ({reportDraft}) => `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${reportDraft ? reportDraft.policyID : '0'}`,
+ key: ({reportDraft, transaction}) => `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${IOU.getIOURequestPolicyID(transaction, reportDraft)}`,
},
policyCategories: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report ? report.policyID : '0'}`,
+ key: ({report, transaction}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${IOU.getIOURequestPolicyID(transaction, report)}`,
},
policyCategoriesDraft: {
- key: ({reportDraft}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${reportDraft ? reportDraft.policyID : '0'}`,
+ key: ({reportDraft, transaction}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${IOU.getIOURequestPolicyID(transaction, reportDraft)}`,
},
policyTags: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`,
+ key: ({report, transaction}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${IOU.getIOURequestPolicyID(transaction, report)}`,
},
reportActions: {
key: ({
diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
index 831d58c43434..e458e57ae3bb 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
@@ -146,7 +146,7 @@ function IOURequestStepConfirmation({
}) ?? [],
[transaction?.participants, personalDetails, iouType],
);
- const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(report)), [report]);
+ const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(report)) || ReportUtils.isGroupPolicy(policy?.type ?? ''), [report, policy]);
const formHasBeenSubmitted = useRef(false);
useEffect(() => {
@@ -574,7 +574,7 @@ function IOURequestStepConfirmation({
iouType={iouType}
reportID={reportID}
isPolicyExpenseChat={isPolicyExpenseChat}
- policyID={report?.policyID}
+ policyID={report?.policyID ?? policy?.id}
bankAccountRoute={ReportUtils.getBankAccountRoute(report)}
iouMerchant={transaction?.merchant}
iouCreated={transaction?.created}
diff --git a/src/pages/iou/request/step/IOURequestStepDate.tsx b/src/pages/iou/request/step/IOURequestStepDate.tsx
index 26ea529cb108..2fea4c0d52e1 100644
--- a/src/pages/iou/request/step/IOURequestStepDate.tsx
+++ b/src/pages/iou/request/step/IOURequestStepDate.tsx
@@ -159,6 +159,7 @@ const IOURequestStepDateWithOnyx = withOnyx `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`,
diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx
index 1c861d510a85..88532856465a 100644
--- a/src/pages/workspace/WorkspaceInitialPage.tsx
+++ b/src/pages/workspace/WorkspaceInitialPage.tsx
@@ -34,6 +34,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
import type * as OnyxTypes from '@src/types/onyx';
+import type {PendingAction} from '@src/types/onyx/OnyxCommon';
import type {PolicyFeatureName} from '@src/types/onyx/Policy';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type IconAsset from '@src/types/utils/IconAsset';
@@ -75,9 +76,13 @@ type WorkspaceInitialPageProps = WithPolicyAndFullscreenLoadingProps & Workspace
type PolicyFeatureStates = Record;
-function dismissError(policyID: string) {
- PolicyUtils.goBackFromInvalidPolicy();
- Policy.removeWorkspace(policyID);
+function dismissError(policyID: string, pendingAction: PendingAction | undefined) {
+ if (!policyID || pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) {
+ PolicyUtils.goBackFromInvalidPolicy();
+ Policy.removeWorkspace(policyID);
+ } else {
+ Policy.clearErrors(policyID);
+ }
}
function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAccount, policyCategories}: WorkspaceInitialPageProps) {
@@ -340,7 +345,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc
dismissError(policyID)}
+ onClose={() => dismissError(policyID, policy?.pendingAction)}
errors={policy?.errors}
errorRowStyles={[styles.ph5, styles.pv2]}
shouldDisableStrikeThrough={false}
diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx
index 39a5c368d7a1..3fdeaba4da7c 100644
--- a/src/pages/workspace/WorkspaceMembersPage.tsx
+++ b/src/pages/workspace/WorkspaceMembersPage.tsx
@@ -72,7 +72,7 @@ function invertObject(object: Record): Record {
type MemberOption = Omit & {accountID: number};
function WorkspaceMembersPage({personalDetails, invitedEmailsToAccountIDsDraft, route, policy, session, currentUserPersonalDetails, isLoadingReportData = true}: WorkspaceMembersPageProps) {
- const policyMemberEmailsToAccountIDs = useMemo(() => PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList), [policy?.employeeList]);
+ const policyMemberEmailsToAccountIDs = useMemo(() => PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList, true), [policy?.employeeList]);
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const [selectedEmployees, setSelectedEmployees] = useState([]);
diff --git a/src/pages/workspace/WorkspaceProfilePage.tsx b/src/pages/workspace/WorkspaceProfilePage.tsx
index 02b41518533f..8500aecdcaa7 100644
--- a/src/pages/workspace/WorkspaceProfilePage.tsx
+++ b/src/pages/workspace/WorkspaceProfilePage.tsx
@@ -81,6 +81,7 @@ function WorkspaceProfilePage({policy, currencyList = {}, route}: WorkSpaceProfi
);
const readOnly = !PolicyUtils.isPolicyAdmin(policy);
const imageStyle: StyleProp = isSmallScreenWidth ? [styles.mhv12, styles.mhn5, styles.mbn5] : [styles.mhv8, styles.mhn8, styles.mbn5];
+ const shouldShowAddress = !readOnly || formattedAddress;
const DefaultAvatar = useCallback(
() => (
@@ -221,7 +222,7 @@ function WorkspaceProfilePage({policy, currencyList = {}, route}: WorkSpaceProfi
- {canUseSpotnanaTravel && (
+ {canUseSpotnanaTravel && shouldShowAddress && (
{
- if (!policy.pendingAction) {
- return;
- }
- dismissWorkspaceError(policy.id, policy.pendingAction);
- },
+ dismissError: () => dismissWorkspaceError(policy.id, policy.pendingAction),
disabled: policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
iconType: policy.avatarURL ? CONST.ICON_TYPE_AVATAR : CONST.ICON_TYPE_ICON,
iconFill: theme.textLight,
diff --git a/src/pages/workspace/accounting/xero/XeroImportPage.tsx b/src/pages/workspace/accounting/xero/XeroImportPage.tsx
index 13011b1952ac..fbd0d6add7e5 100644
--- a/src/pages/workspace/accounting/xero/XeroImportPage.tsx
+++ b/src/pages/workspace/accounting/xero/XeroImportPage.tsx
@@ -36,7 +36,7 @@ function XeroImportPage({policy}: WithPolicyProps) {
},
{
description: translate('workspace.xero.trackingCategories'),
- action: () => {},
+ action: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES.getRoute(policyID)),
hasError: !!policy?.errors?.importTrackingCategories,
title: importTrackingCategories ? translate('workspace.accounting.importTypes.TAG') : translate('workspace.xero.notImported'),
pendingAction: pendingFields?.importTrackingCategories,
diff --git a/src/pages/workspace/accounting/xero/XeroMapCostCentersToConfigurationPage.tsx b/src/pages/workspace/accounting/xero/XeroMapCostCentersToConfigurationPage.tsx
new file mode 100644
index 000000000000..b4f0fe04f6ce
--- /dev/null
+++ b/src/pages/workspace/accounting/xero/XeroMapCostCentersToConfigurationPage.tsx
@@ -0,0 +1,70 @@
+import React, {useCallback, useMemo} from 'react';
+import ConnectionLayout from '@components/ConnectionLayout';
+import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as Connections from '@libs/actions/connections';
+import {getTrackingCategory} from '@libs/actions/connections/ConnectToXero';
+import Navigation from '@libs/Navigation/Navigation';
+import type {WithPolicyProps} from '@pages/workspace/withPolicy';
+import withPolicyConnections from '@pages/workspace/withPolicyConnections';
+import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
+import ROUTES from '@src/ROUTES';
+
+function XeroMapCostCentersToConfigurationPage({policy}: WithPolicyProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const policyID = policy?.id ?? '';
+
+ const category = getTrackingCategory(policy, CONST.XERO_CONFIG.TRACKING_CATEGORY_FIELDS.COST_CENTERS);
+
+ const optionsList = useMemo(
+ () =>
+ Object.values(CONST.XERO_CONFIG.TRACKING_CATEGORY_OPTIONS).map((option) => ({
+ value: option,
+ text: translate(`workspace.xero.trackingCategoriesOptions.${option.toLowerCase()}` as TranslationPaths),
+ keyForList: option,
+ isSelected: option.toLowerCase() === category?.value?.toLowerCase(),
+ })),
+ [translate, category],
+ );
+
+ const updateMapping = useCallback(
+ (option: {value: string}) => {
+ if (option.value !== category?.value) {
+ Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.XERO, CONST.XERO_CONFIG.MAPPINGS, {
+ ...(policy?.connections?.xero?.config?.mappings ?? {}),
+ ...(category?.id ? {[`${CONST.XERO_CONFIG.TRACKING_CATEGORY_PREFIX}${category.id}`]: option.value} : {}),
+ });
+ }
+ Navigation.goBack(ROUTES.POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES.getRoute(policyID));
+ },
+ [category, policyID, policy?.connections?.xero?.config?.mappings],
+ );
+
+ return (
+
+
+
+ );
+}
+
+XeroMapCostCentersToConfigurationPage.displayName = 'XeroMapCostCentersToConfigurationPage';
+export default withPolicyConnections(XeroMapCostCentersToConfigurationPage);
diff --git a/src/pages/workspace/accounting/xero/XeroMapRegionsToConfigurationPage.tsx b/src/pages/workspace/accounting/xero/XeroMapRegionsToConfigurationPage.tsx
new file mode 100644
index 000000000000..bb5870da8fc3
--- /dev/null
+++ b/src/pages/workspace/accounting/xero/XeroMapRegionsToConfigurationPage.tsx
@@ -0,0 +1,69 @@
+import React, {useCallback, useMemo} from 'react';
+import ConnectionLayout from '@components/ConnectionLayout';
+import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as Connections from '@libs/actions/connections';
+import {getTrackingCategory} from '@libs/actions/connections/ConnectToXero';
+import Navigation from '@libs/Navigation/Navigation';
+import type {WithPolicyProps} from '@pages/workspace/withPolicy';
+import withPolicyConnections from '@pages/workspace/withPolicyConnections';
+import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
+import ROUTES from '@src/ROUTES';
+
+function XeroMapRegionsToConfigurationPage({policy}: WithPolicyProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const policyID = policy?.id ?? '';
+ const category = getTrackingCategory(policy, CONST.XERO_CONFIG.TRACKING_CATEGORY_FIELDS.REGION);
+
+ const optionsList = useMemo(
+ () =>
+ Object.values(CONST.XERO_CONFIG.TRACKING_CATEGORY_OPTIONS).map((option) => ({
+ value: option,
+ text: translate(`workspace.xero.trackingCategoriesOptions.${option.toLowerCase()}` as TranslationPaths),
+ keyForList: option,
+ isSelected: option.toLowerCase() === category?.value?.toLowerCase(),
+ })),
+ [translate, category],
+ );
+
+ const updateMapping = useCallback(
+ (option: {value: string}) => {
+ if (option.value !== category?.value) {
+ Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.XERO, CONST.XERO_CONFIG.MAPPINGS, {
+ ...(policy?.connections?.xero?.config?.mappings ?? {}),
+ ...(category?.id ? {[`${CONST.XERO_CONFIG.TRACKING_CATEGORY_PREFIX}${category.id}`]: option.value} : {}),
+ });
+ }
+ Navigation.goBack(ROUTES.POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES.getRoute(policyID));
+ },
+ [category, policyID, policy?.connections?.xero?.config?.mappings],
+ );
+
+ return (
+
+
+
+ );
+}
+
+XeroMapRegionsToConfigurationPage.displayName = 'XeroMapRegionsToConfigurationPage';
+export default withPolicyConnections(XeroMapRegionsToConfigurationPage);
diff --git a/src/pages/workspace/accounting/xero/XeroTrackingCategoryConfigurationPage.tsx b/src/pages/workspace/accounting/xero/XeroTrackingCategoryConfigurationPage.tsx
new file mode 100644
index 000000000000..195b93d3d73c
--- /dev/null
+++ b/src/pages/workspace/accounting/xero/XeroTrackingCategoryConfigurationPage.tsx
@@ -0,0 +1,104 @@
+import React, {useMemo} from 'react';
+import {View} from 'react-native';
+import ConnectionLayout from '@components/ConnectionLayout';
+import type {MenuItemProps} from '@components/MenuItem';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import Switch from '@components/Switch';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as Connections from '@libs/actions/connections';
+import {getTrackingCategory} from '@libs/actions/connections/ConnectToXero';
+import Navigation from '@libs/Navigation/Navigation';
+import type {WithPolicyProps} from '@pages/workspace/withPolicy';
+import withPolicyConnections from '@pages/workspace/withPolicyConnections';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
+import ROUTES from '@src/ROUTES';
+
+function XeroTrackingCategoryConfigurationPage({policy}: WithPolicyProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const policyID = policy?.id ?? '';
+ const {importTrackingCategories, pendingFields} = policy?.connections?.xero?.config ?? {};
+
+ const menuItems: MenuItemProps[] = useMemo(() => {
+ const availableCategories = [];
+
+ const costCenterCategoryValue = getTrackingCategory(policy, CONST.XERO_CONFIG.TRACKING_CATEGORY_FIELDS.COST_CENTERS)?.value ?? '';
+ const regionCategoryValue = getTrackingCategory(policy, CONST.XERO_CONFIG.TRACKING_CATEGORY_FIELDS.REGION)?.value ?? '';
+ if (costCenterCategoryValue) {
+ const isValidOption = Object.values(CONST.XERO_CONFIG.TRACKING_CATEGORY_OPTIONS).findIndex((option) => option.toLowerCase() === costCenterCategoryValue.toLowerCase()) > -1;
+ availableCategories.push({
+ description: translate('workspace.xero.mapXeroCostCentersTo'),
+ onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES_MAP_COST_CENTERS.getRoute(policyID)),
+ title: isValidOption ? translate(`workspace.xero.trackingCategoriesOptions.${costCenterCategoryValue.toLowerCase()}` as TranslationPaths) : '',
+ });
+ }
+
+ if (regionCategoryValue) {
+ const isValidOption = Object.values(CONST.XERO_CONFIG.TRACKING_CATEGORY_OPTIONS).findIndex((option) => option.toLowerCase() === regionCategoryValue.toLowerCase()) > -1;
+ availableCategories.push({
+ description: translate('workspace.xero.mapXeroRegionsTo'),
+ onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES_MAP_REGION.getRoute(policyID)),
+ title: isValidOption ? translate(`workspace.xero.trackingCategoriesOptions.${regionCategoryValue.toLowerCase()}` as TranslationPaths) : '',
+ });
+ }
+ return availableCategories;
+ }, [translate, policy, policyID]);
+
+ return (
+
+
+
+
+ {translate('workspace.accounting.import')}
+
+
+
+
+ Connections.updatePolicyConnectionConfig(
+ policyID,
+ CONST.POLICY.CONNECTIONS.NAME.XERO,
+ CONST.XERO_CONFIG.IMPORT_TRACKING_CATEGORIES,
+ !importTrackingCategories,
+ )
+ }
+ />
+
+
+
+
+ {importTrackingCategories && (
+
+ {menuItems.map((menuItem: MenuItemProps) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+XeroTrackingCategoryConfigurationPage.displayName = 'XeroTrackCategoriesPage';
+export default withPolicyConnections(XeroTrackingCategoryConfigurationPage);
diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.tsx
deleted file mode 100644
index 8fef5f4dc6f9..000000000000
--- a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.tsx
+++ /dev/null
@@ -1,158 +0,0 @@
-import type {StackScreenProps} from '@react-navigation/stack';
-import React, {useEffect} from 'react';
-import {Keyboard, View} from 'react-native';
-import FormProvider from '@components/Form/FormProvider';
-import InputWrapper from '@components/Form/InputWrapper';
-import type {FormOnyxValues} from '@components/Form/types';
-import OfflineWithFeedback from '@components/OfflineWithFeedback';
-import Picker from '@components/Picker';
-import TextInput from '@components/TextInput';
-import useLocalize from '@hooks/useLocalize';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as CurrencyUtils from '@libs/CurrencyUtils';
-import getPermittedDecimalSeparator from '@libs/getPermittedDecimalSeparator';
-import Navigation from '@libs/Navigation/Navigation';
-import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
-import * as NumberUtils from '@libs/NumberUtils';
-import * as PolicyUtils from '@libs/PolicyUtils';
-import withPolicy from '@pages/workspace/withPolicy';
-import type {WithPolicyProps} from '@pages/workspace/withPolicy';
-import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections';
-import * as BankAccounts from '@userActions/BankAccounts';
-import * as Policy from '@userActions/Policy';
-import CONST from '@src/CONST';
-import type {TranslationPaths} from '@src/languages/types';
-import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
-import type SCREENS from '@src/SCREENS';
-import type {Unit} from '@src/types/onyx/Policy';
-
-type WorkspaceRateAndUnitPageProps = WithPolicyProps & StackScreenProps;
-
-type ValidationError = {rate?: TranslationPaths | undefined};
-
-function WorkspaceRateAndUnitPage({policy, route}: WorkspaceRateAndUnitPageProps) {
- const {translate, toLocaleDigit} = useLocalize();
- const styles = useThemeStyles();
-
- useEffect(() => {
- if ((policy?.customUnits ?? []).length !== 0) {
- return;
- }
-
- BankAccounts.setReimbursementAccountLoading(true);
- Policy.openWorkspaceReimburseView(policy?.id ?? '');
- }, [policy?.customUnits, policy?.id]);
-
- const unitItems = [
- {label: translate('common.kilometers'), value: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS},
- {label: translate('common.miles'), value: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES},
- ];
-
- const saveUnitAndRate = (unit: Unit, rate: string) => {
- const distanceCustomUnit = Object.values(policy?.customUnits ?? {}).find((customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
- if (!distanceCustomUnit) {
- return;
- }
- const currentCustomUnitRate = Object.values(distanceCustomUnit?.rates ?? {}).find((r) => r.name === CONST.CUSTOM_UNITS.DEFAULT_RATE);
- const unitID = distanceCustomUnit.customUnitID ?? '';
- const unitName = distanceCustomUnit.name ?? '';
- const rateNumValue = PolicyUtils.getNumericValue(rate, toLocaleDigit);
-
- const newCustomUnit: Policy.NewCustomUnit = {
- customUnitID: unitID,
- name: unitName,
- attributes: {unit},
- rates: {
- ...currentCustomUnitRate,
- rate: Number(rateNumValue) * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET,
- },
- };
-
- Policy.updateWorkspaceCustomUnitAndRate(policy?.id ?? '', distanceCustomUnit, newCustomUnit, policy?.lastModified);
- };
-
- const submit = (values: FormOnyxValues) => {
- saveUnitAndRate(values.unit as Unit, values.rate);
- Keyboard.dismiss();
- Navigation.goBack(ROUTES.WORKSPACE_REIMBURSE.getRoute(policy?.id ?? ''));
- };
-
- const validate = (values: FormOnyxValues): ValidationError => {
- const errors: ValidationError = {};
- const decimalSeparator = toLocaleDigit('.');
- const outputCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD;
- // Allow one more decimal place for accuracy
- const rateValueRegex = RegExp(String.raw`^-?\d{0,8}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{1,${CurrencyUtils.getCurrencyDecimals(outputCurrency) + 1}})?$`, 'i');
- if (!rateValueRegex.test(values.rate) || values.rate === '') {
- errors.rate = 'workspace.reimburse.invalidRateError';
- } else if (NumberUtils.parseFloatAnyLocale(values.rate) <= 0) {
- errors.rate = 'workspace.reimburse.lowRateError';
- }
- return errors;
- };
-
- const distanceCustomUnit = Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
- const distanceCustomRate = Object.values(distanceCustomUnit?.rates ?? {}).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE);
-
- return (
-
- {() => (
-
- Policy.clearCustomUnitErrors(policy?.id ?? '', distanceCustomUnit?.customUnitID ?? '', distanceCustomRate?.customUnitRateID ?? '')}
- >
-
-
-
-
-
-
-
- )}
-
- );
-}
-
-WorkspaceRateAndUnitPage.displayName = 'WorkspaceRateAndUnitPage';
-
-export default withPolicy(WorkspaceRateAndUnitPage);
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 4555e2e1001b..060fb1c5ba90 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -799,6 +799,7 @@ const styles = (theme: ThemeColors) =>
fontSize: 17,
},
modalViewMiddle: {
+ position: 'relative',
backgroundColor: theme.border,
borderTopWidth: 0,
},
@@ -841,6 +842,10 @@ const styles = (theme: ThemeColors) =>
width: variables.iconSizeExtraSmall,
height: variables.iconSizeExtraSmall,
},
+ chevronContainer: {
+ pointerEvents: 'none',
+ opacity: 0,
+ },
} satisfies CustomPickerStyle),
badge: {
@@ -1137,11 +1142,6 @@ const styles = (theme: ThemeColors) =>
borderColor: theme.border,
},
- optionRowAmountInputWrapper: {
- borderColor: theme.border,
- borderBottomWidth: 2,
- },
-
optionRowAmountInput: {
textAlign: 'right',
},
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index 3a322405c6e1..bd15a871a139 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -61,6 +61,9 @@ type TaxRate = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** Indicates if the tax rate is disabled. */
isDisabled?: boolean;
+ /** Indicates if the tax rate is selected. */
+ isSelected?: boolean;
+
/** An error message to display to the user */
errors?: OnyxCommon.Errors;
@@ -205,6 +208,11 @@ type Tenant = {
value: string;
};
+type XeroTrackingCategory = {
+ id: string;
+ name: string;
+};
+
type XeroConnectionData = {
bankAccounts: Account[];
countryCode: string;
@@ -214,7 +222,13 @@ type XeroConnectionData = {
name: string;
}>;
tenants: Tenant[];
- trackingCategories: unknown[];
+ trackingCategories: XeroTrackingCategory[];
+};
+
+type XeroMappingType = {
+ customer: string;
+} & {
+ [key in `trackingCategory_${string}`]: string;
};
/**
@@ -242,9 +256,7 @@ type XeroConnectionConfig = OnyxCommon.OnyxValueWithOfflineFeedback<{
importTaxRates: boolean;
importTrackingCategories: boolean;
isConfigured: boolean;
- mappings: {
- customer: string;
- };
+ mappings: XeroMappingType;
sync: {
hasChosenAutoSyncOption: boolean;
hasChosenSyncReimbursedReportsOption: boolean;
@@ -561,4 +573,5 @@ export type {
QBONonReimbursableExportAccountType,
QBOReimbursableExportAccountType,
QBOConnectionConfig,
+ XeroTrackingCategory,
};
diff --git a/tests/actions/PolicyTaxTest.ts b/tests/actions/PolicyTaxTest.ts
new file mode 100644
index 000000000000..a17179d8f7af
--- /dev/null
+++ b/tests/actions/PolicyTaxTest.ts
@@ -0,0 +1,884 @@
+import Onyx from 'react-native-onyx';
+import {createPolicyTax, deletePolicyTaxes, renamePolicyTax, setPolicyTaxesEnabled, updatePolicyTaxValue} from '@libs/actions/TaxRate';
+import CONST from '@src/CONST';
+import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager';
+import * as Policy from '@src/libs/actions/Policy';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Policy as PolicyType, TaxRate} from '@src/types/onyx';
+import createRandomPolicy from '../utils/collections/policies';
+import * as TestHelper from '../utils/TestHelper';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+
+OnyxUpdateManager();
+describe('actions/PolicyTax', () => {
+ const fakePolicy: PolicyType = {...createRandomPolicy(0), taxRates: CONST.DEFAULT_TAX};
+ beforeAll(() => {
+ Onyx.init({
+ keys: ONYXKEYS,
+ });
+ });
+
+ beforeEach(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ global.fetch = TestHelper.getGlobalFetchMock();
+ return Onyx.clear()
+ .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy))
+ .then(waitForBatchedUpdates);
+ });
+
+ describe('SetPolicyCustomTaxName', () => {
+ it('Set policy`s custom tax name', () => {
+ const customTaxName = 'Custom tag name';
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+ Policy.setPolicyCustomTaxName(fakePolicy.id, customTaxName);
+ return (
+ waitForBatchedUpdates()
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ expect(policy?.taxRates?.name).toBe(customTaxName);
+ expect(policy?.taxRates?.pendingFields?.name).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ expect(policy?.taxRates?.errorFields).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ expect(policy?.taxRates?.pendingFields?.name).toBeFalsy();
+ expect(policy?.taxRates?.errorFields).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+ it('Reset policy`s custom tax name when API returns an error', () => {
+ const customTaxName = 'Custom tag name';
+ const originalCustomTaxName = fakePolicy?.taxRates?.name;
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+ Policy.setPolicyCustomTaxName(fakePolicy.id, customTaxName);
+ return waitForBatchedUpdates()
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ expect(policy?.taxRates?.name).toBe(customTaxName);
+ expect(policy?.taxRates?.pendingFields?.name).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ expect(policy?.taxRates?.errorFields).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.fail();
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ return fetch.resume() as Promise;
+ })
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ expect(policy?.taxRates?.name).toBe(originalCustomTaxName);
+ expect(policy?.taxRates?.pendingFields?.name).toBeFalsy();
+ expect(policy?.taxRates?.errorFields?.name).toBeTruthy();
+ resolve();
+ },
+ });
+ }),
+ );
+ });
+ });
+
+ describe('SetPolicyCurrencyDefaultTax', () => {
+ it('Set policy`s currency default tax', () => {
+ const taxCode = 'id_TAX_RATE_1';
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+ Policy.setWorkspaceCurrencyDefault(fakePolicy.id, taxCode);
+ return (
+ waitForBatchedUpdates()
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ expect(policy?.taxRates?.defaultExternalID).toBe(taxCode);
+ expect(policy?.taxRates?.pendingFields?.defaultExternalID).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ expect(policy?.taxRates?.errorFields).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ expect(policy?.taxRates?.pendingFields?.defaultExternalID).toBeFalsy();
+ expect(policy?.taxRates?.errorFields).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+ it('Reset policy`s currency default tax when API returns an error', () => {
+ const taxCode = 'id_TAX_RATE_1';
+ const originalDefaultExternalID = fakePolicy?.taxRates?.defaultExternalID;
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+ Policy.setWorkspaceCurrencyDefault(fakePolicy.id, taxCode);
+ return waitForBatchedUpdates()
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ expect(policy?.taxRates?.defaultExternalID).toBe(taxCode);
+ expect(policy?.taxRates?.pendingFields?.defaultExternalID).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ expect(policy?.taxRates?.errorFields).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.fail();
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ return fetch.resume() as Promise;
+ })
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ expect(policy?.taxRates?.defaultExternalID).toBe(originalDefaultExternalID);
+ expect(policy?.taxRates?.pendingFields?.defaultExternalID).toBeFalsy();
+ expect(policy?.taxRates?.errorFields?.defaultExternalID).toBeTruthy();
+ resolve();
+ },
+ });
+ }),
+ );
+ });
+ });
+ describe('SetPolicyForeignCurrencyDefaultTax', () => {
+ it('Set policy`s foreign currency default', () => {
+ const taxCode = 'id_TAX_RATE_1';
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+ Policy.setForeignCurrencyDefault(fakePolicy.id, taxCode);
+ return (
+ waitForBatchedUpdates()
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ expect(policy?.taxRates?.foreignTaxDefault).toBe(taxCode);
+ expect(policy?.taxRates?.pendingFields?.foreignTaxDefault).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ expect(policy?.taxRates?.errorFields).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ // Check if the policy pendingFields was cleared
+ expect(policy?.taxRates?.pendingFields?.foreignTaxDefault).toBeFalsy();
+ expect(policy?.taxRates?.errorFields).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+ it('Reset policy`s foreign currency default when API returns an error', () => {
+ const taxCode = 'id_TAX_RATE_1';
+ const originalDefaultForeignCurrencyID = fakePolicy?.taxRates?.foreignTaxDefault;
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+ Policy.setForeignCurrencyDefault(fakePolicy.id, taxCode);
+ return waitForBatchedUpdates()
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ expect(policy?.taxRates?.foreignTaxDefault).toBe(taxCode);
+ expect(policy?.taxRates?.pendingFields?.foreignTaxDefault).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ expect(policy?.taxRates?.errorFields).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+
+ .then(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.fail();
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ return fetch.resume() as Promise;
+ })
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ // Check if the policy pendingFields was cleared
+ expect(policy?.taxRates?.foreignTaxDefault).toBe(originalDefaultForeignCurrencyID);
+ expect(policy?.taxRates?.pendingFields?.foreignTaxDefault).toBeFalsy();
+ expect(policy?.taxRates?.errorFields?.foreignTaxDefault).toBeTruthy();
+ resolve();
+ },
+ });
+ }),
+ );
+ });
+ });
+ describe('CreatePolicyTax', () => {
+ it('Create a new tax', () => {
+ const newTaxRate: TaxRate = {
+ name: 'Tax rate 2',
+ value: '2%',
+ code: 'id_TAX_RATE_2',
+ };
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+ createPolicyTax(fakePolicy.id, newTaxRate);
+ return (
+ waitForBatchedUpdates()
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const createdTax = policy?.taxRates?.taxes?.[newTaxRate.code ?? ''];
+ expect(createdTax?.code).toBe(newTaxRate.code);
+ expect(createdTax?.name).toBe(newTaxRate.name);
+ expect(createdTax?.value).toBe(newTaxRate.value);
+ expect(createdTax?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
+ resolve();
+ },
+ });
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const createdTax = policy?.taxRates?.taxes?.[newTaxRate.code ?? ''];
+ expect(createdTax?.errors).toBeFalsy();
+ expect(createdTax?.pendingFields).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+
+ it('Remove the optimistic tax if the API returns an error', () => {
+ const newTaxRate: TaxRate = {
+ name: 'Tax rate 2',
+ value: '2%',
+ code: 'id_TAX_RATE_2',
+ };
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+ createPolicyTax(fakePolicy.id, newTaxRate);
+ return waitForBatchedUpdates()
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const createdTax = policy?.taxRates?.taxes?.[newTaxRate.code ?? ''];
+ expect(createdTax?.code).toBe(newTaxRate.code);
+ expect(createdTax?.name).toBe(newTaxRate.name);
+ expect(createdTax?.value).toBe(newTaxRate.value);
+ expect(createdTax?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.fail();
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ return fetch.resume() as Promise;
+ })
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const createdTax = policy?.taxRates?.taxes?.[newTaxRate.code ?? ''];
+ expect(createdTax?.errors).toBeTruthy();
+ resolve();
+ },
+ });
+ }),
+ );
+ });
+ });
+ describe('SetPolicyTaxesEnabled', () => {
+ it('Disable policy`s taxes', () => {
+ const disableTaxID = 'id_TAX_RATE_1';
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+ setPolicyTaxesEnabled(fakePolicy.id, [disableTaxID], false);
+ return (
+ waitForBatchedUpdates()
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const disabledTax = policy?.taxRates?.taxes?.[disableTaxID];
+ expect(disabledTax?.isDisabled).toBeTruthy();
+ expect(disabledTax?.pendingFields?.isDisabled).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ expect(disabledTax?.errorFields?.isDisabled).toBeFalsy();
+
+ resolve();
+ },
+ });
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const disabledTax = policy?.taxRates?.taxes?.[disableTaxID];
+ expect(disabledTax?.errorFields?.isDisabled).toBeFalsy();
+ expect(disabledTax?.pendingFields?.isDisabled).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+
+ it('Disable policy`s taxes but API returns an error, then enable policy`s taxes again', () => {
+ const disableTaxID = 'id_TAX_RATE_1';
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+ setPolicyTaxesEnabled(fakePolicy.id, [disableTaxID], false);
+ const originalTaxes = {...fakePolicy?.taxRates?.taxes};
+ return waitForBatchedUpdates()
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const disabledTax = policy?.taxRates?.taxes?.[disableTaxID];
+ expect(disabledTax?.isDisabled).toBeTruthy();
+ expect(disabledTax?.pendingFields?.isDisabled).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ expect(disabledTax?.errorFields?.isDisabled).toBeFalsy();
+
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.fail();
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ return fetch.resume() as Promise;
+ })
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const disabledTax = policy?.taxRates?.taxes?.[disableTaxID];
+ expect(disabledTax?.isDisabled).toBe(!!originalTaxes[disableTaxID].isDisabled);
+ expect(disabledTax?.errorFields?.isDisabled).toBeTruthy();
+ expect(disabledTax?.pendingFields?.isDisabled).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ );
+ });
+ });
+
+ describe('RenamePolicyTax', () => {
+ it('Rename tax', () => {
+ const taxID = 'id_TAX_RATE_1';
+ const newTaxName = 'Tax rate 1 updated';
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+ renamePolicyTax(fakePolicy.id, taxID, newTaxName);
+ return (
+ waitForBatchedUpdates()
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const updatedTax = policy?.taxRates?.taxes?.[taxID];
+ expect(updatedTax?.name).toBe(newTaxName);
+ expect(updatedTax?.pendingFields?.name).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ expect(updatedTax?.errorFields?.name).toBeFalsy();
+
+ resolve();
+ },
+ });
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const updatedTax = policy?.taxRates?.taxes?.[taxID];
+ expect(updatedTax?.errorFields?.name).toBeFalsy();
+ expect(updatedTax?.pendingFields?.name).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+
+ it('Rename tax but API returns an error, then recover the original tax`s name', () => {
+ const taxID = 'id_TAX_RATE_1';
+ const newTaxName = 'Tax rate 1 updated';
+ const originalTaxRate = {...fakePolicy?.taxRates?.taxes[taxID]};
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+ renamePolicyTax(fakePolicy.id, taxID, newTaxName);
+ return waitForBatchedUpdates()
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const updatedTax = policy?.taxRates?.taxes?.[taxID];
+ expect(updatedTax?.name).toBe(newTaxName);
+ expect(updatedTax?.pendingFields?.name).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ expect(updatedTax?.errorFields?.name).toBeFalsy();
+
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.fail();
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ return fetch.resume() as Promise;
+ })
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const updatedTax = policy?.taxRates?.taxes?.[taxID];
+ expect(updatedTax?.name).toBe(originalTaxRate.name);
+ expect(updatedTax?.errorFields?.name).toBeTruthy();
+ expect(updatedTax?.pendingFields?.name).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ );
+ });
+ });
+ describe('UpdatePolicyTaxValue', () => {
+ it('Update tax`s value', () => {
+ const taxID = 'id_TAX_RATE_1';
+ const newTaxValue = 10;
+ const stringTaxValue = `${newTaxValue}%`;
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+ updatePolicyTaxValue(fakePolicy.id, taxID, newTaxValue);
+ return (
+ waitForBatchedUpdates()
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const updatedTax = policy?.taxRates?.taxes?.[taxID];
+ expect(updatedTax?.value).toBe(stringTaxValue);
+ expect(updatedTax?.pendingFields?.value).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ expect(updatedTax?.errorFields?.value).toBeFalsy();
+
+ resolve();
+ },
+ });
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const updatedTax = policy?.taxRates?.taxes?.[taxID];
+ expect(updatedTax?.errorFields?.value).toBeFalsy();
+ expect(updatedTax?.pendingFields?.value).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+
+ it('Update tax`s value but API returns an error, then recover the original tax`s value', () => {
+ const taxID = 'id_TAX_RATE_1';
+ const newTaxValue = 10;
+ const originalTaxRate = {...fakePolicy?.taxRates?.taxes[taxID]};
+ const stringTaxValue = `${newTaxValue}%`;
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+ updatePolicyTaxValue(fakePolicy.id, taxID, newTaxValue);
+ return waitForBatchedUpdates()
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const updatedTax = policy?.taxRates?.taxes?.[taxID];
+ expect(updatedTax?.value).toBe(stringTaxValue);
+ expect(updatedTax?.pendingFields?.value).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ expect(updatedTax?.errorFields?.value).toBeFalsy();
+
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.fail();
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ return fetch.resume() as Promise;
+ })
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const updatedTax = policy?.taxRates?.taxes?.[taxID];
+ expect(updatedTax?.value).toBe(originalTaxRate.value);
+ expect(updatedTax?.errorFields?.value).toBeTruthy();
+ expect(updatedTax?.pendingFields?.value).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ );
+ });
+ });
+ describe('DeletePolicyTaxes', () => {
+ it('Delete tax that is not foreignTaxDefault', () => {
+ const foreignTaxDefault = fakePolicy?.taxRates?.foreignTaxDefault;
+ const taxID = 'id_TAX_RATE_1';
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+ deletePolicyTaxes(fakePolicy.id, [taxID]);
+ return (
+ waitForBatchedUpdates()
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const taxRates = policy?.taxRates;
+ const deletedTax = taxRates?.taxes?.[taxID];
+ expect(taxRates?.pendingFields?.foreignTaxDefault).toBeFalsy();
+ expect(taxRates?.foreignTaxDefault).toBe(foreignTaxDefault);
+ expect(deletedTax?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
+ expect(deletedTax?.errors).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const taxRates = policy?.taxRates;
+ const deletedTax = taxRates?.taxes?.[taxID];
+ expect(taxRates?.pendingFields?.foreignTaxDefault).toBeFalsy();
+ expect(deletedTax).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+
+ it('Delete tax that is foreignTaxDefault', () => {
+ const taxID = 'id_TAX_RATE_1';
+ const firstTaxID = 'id_TAX_EXEMPT';
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+ return (
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, {taxRates: {foreignTaxDefault: 'id_TAX_RATE_1'}})
+ .then(() => {
+ deletePolicyTaxes(fakePolicy.id, [taxID]);
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const taxRates = policy?.taxRates;
+ const deletedTax = taxRates?.taxes?.[taxID];
+ expect(taxRates?.pendingFields?.foreignTaxDefault).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ expect(taxRates?.foreignTaxDefault).toBe(firstTaxID);
+ expect(deletedTax?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
+ expect(deletedTax?.errors).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const taxRates = policy?.taxRates;
+ const deletedTax = taxRates?.taxes?.[taxID];
+ expect(taxRates?.pendingFields?.foreignTaxDefault).toBeFalsy();
+ expect(deletedTax).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+
+ it('Delete tax that is not foreignTaxDefault but API return an error, then recover the delated tax', () => {
+ const foreignTaxDefault = fakePolicy?.taxRates?.foreignTaxDefault;
+ const taxID = 'id_TAX_RATE_1';
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+ deletePolicyTaxes(fakePolicy.id, [taxID]);
+ return waitForBatchedUpdates()
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const taxRates = policy?.taxRates;
+ const deletedTax = taxRates?.taxes?.[taxID];
+ expect(taxRates?.pendingFields?.foreignTaxDefault).toBeFalsy();
+ expect(taxRates?.foreignTaxDefault).toBe(foreignTaxDefault);
+ expect(deletedTax?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
+ expect(deletedTax?.errors).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.fail();
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ return fetch.resume() as Promise;
+ })
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ const taxRates = policy?.taxRates;
+ const deletedTax = taxRates?.taxes?.[taxID];
+ expect(taxRates?.pendingFields?.foreignTaxDefault).toBeFalsy();
+ expect(deletedTax?.pendingAction).toBeFalsy();
+ expect(deletedTax?.errors).toBeTruthy();
+ resolve();
+ },
+ });
+ }),
+ );
+ });
+ });
+});
diff --git a/tests/e2e/utils/installApp.ts b/tests/e2e/utils/installApp.ts
index dc6a9d64053f..82d0066c885b 100644
--- a/tests/e2e/utils/installApp.ts
+++ b/tests/e2e/utils/installApp.ts
@@ -19,7 +19,8 @@ export default function (packageName: string, path: string, platform = 'android'
// Ignore errors
Logger.warn('Failed to uninstall app:', error.message);
})
+ // install and grant push notifications permissions right away (the popup may block e2e tests sometimes)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
- .finally(() => execAsync(`adb install ${path}`))
+ .finally(() => execAsync(`adb install ${path}`).then(() => execAsync(`adb shell pm grant ${packageName.split('/')[0]} android.permission.POST_NOTIFICATIONS`)))
);
}
diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts
index 76b4324f697b..0df4e2fe124b 100644
--- a/tests/unit/OptionsListUtilsTest.ts
+++ b/tests/unit/OptionsListUtilsTest.ts
@@ -1067,8 +1067,8 @@ describe('OptionsListUtils', () => {
keyForList: 'Medical',
searchText: 'Medical',
tooltipText: 'Medical',
- isDisabled: false,
- isSelected: false,
+ isDisabled: true,
+ isSelected: true,
},
],
},
@@ -1236,8 +1236,8 @@ describe('OptionsListUtils', () => {
keyForList: 'Medical',
searchText: 'Medical',
tooltipText: 'Medical',
- isDisabled: false,
- isSelected: false,
+ isDisabled: true,
+ isSelected: true,
},
],
},
@@ -2587,6 +2587,7 @@ describe('OptionsListUtils', () => {
searchText: 'Tax exempt 1 (0%) • Default',
tooltipText: 'Tax exempt 1 (0%) • Default',
isDisabled: undefined,
+ isSelected: undefined,
// creates a data option.
data: {
name: 'Tax exempt 1',
@@ -2601,6 +2602,7 @@ describe('OptionsListUtils', () => {
searchText: 'Tax option 3 (5%)',
tooltipText: 'Tax option 3 (5%)',
isDisabled: undefined,
+ isSelected: undefined,
data: {
name: 'Tax option 3',
code: 'CODE3',
@@ -2614,6 +2616,7 @@ describe('OptionsListUtils', () => {
searchText: 'Tax rate 2 (3%)',
tooltipText: 'Tax rate 2 (3%)',
isDisabled: undefined,
+ isSelected: undefined,
data: {
name: 'Tax rate 2',
code: 'CODE2',
@@ -2637,6 +2640,7 @@ describe('OptionsListUtils', () => {
searchText: 'Tax rate 2 (3%)',
tooltipText: 'Tax rate 2 (3%)',
isDisabled: undefined,
+ isSelected: undefined,
data: {
name: 'Tax rate 2',
code: 'CODE2',