diff --git a/android/app/build.gradle b/android/app/build.gradle
index 46d9b09fe89f..ce5c92aec37f 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -110,8 +110,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1009006503
- versionName "9.0.65-3"
+ versionCode 1009006505
+ versionName "9.0.65-5"
// Supported language variants must be declared here to avoid from being removed during the compilation.
// This also helps us to not include unnecessary language variants in the APK.
resConfigs "en", "es"
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md
deleted file mode 100644
index b231984f61e2..000000000000
--- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md
+++ /dev/null
@@ -1,79 +0,0 @@
----
-title: Create and Pay Bills
-description: Expensify bill management and payment methods.
----
-Streamline your operations by receiving and paying vendor or supplier bills directly in Expensify. Vendors can send bills even if they don't have an Expensify account, and you can manage payments seamlessly.
-
-# Receive Bills in Expensify
-You can receive bills in three ways:
-- Directly from Vendors: Provide your Expensify billing email to vendors.
-- Forwarding Emails: Forward bills received in your email to Expensify.
-- Manual Upload: For physical bills, create a Bill in Expensify from the Reports page.
-
-# Bill Pay Workflow
-1. When a vendor or supplier sends a bill to Expensify, the document is automatically SmartScanned, and a Bill is created. This Bill is managed by the primary domain contact, who can view it on the Reports page within their default group workspace.
-
-2. Once the Bill is ready for processing, it follows the established approval workflow. As each person approves it, the Bill appears in the next approver’s Inbox. The final approver will pay the Bill using one of the available payment methods.
-
-3. During this process, the Bill is coded with the appropriate GL codes from your connected accounting software. After completing the approval workflow, the Bill can be exported back to your accounting system.
-
-# Payment Methods
-There are multiple ways to pay Bills in Expensify. Let’s go over each method below.
-
-## ACH bank-to-bank transfer
-
-To use this payment method, you must have a [business bank account connected to your Expensify account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account).
-
-**To pay with an ACH bank-to-bank transfer:**
-1. Sign in to your [Expensify web account](www.expensify.com).
-2. Go to the Home or Reports page and locate the Bill that needs to be paid.
-3. Click the Pay button to be redirected to the Bill.
-4. Choose the ACH option from the drop-down list.
-
-**Fees:** None
-
-## Credit or Debit Card
-This option is available to all US and International customers receiving a bill from a US vendor with a US business bank account.
-
-**To pay with a credit or debit card:**
-1. Sign in to your [Expensify web account](www.expensify.com).
-2. Click on the Bill you’d like to pay to see the details.
-3. Click the Pay button.
-4. Enter your credit card or debit card details.
-
-**Fees:** 2.9% of the total amount paid.
-
-## Venmo
-If both you and the vendor must have Venmo connected to Expensify, you can pay the bill by following the steps outlined [here](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments#setting-up-third-party-payments).
-
-**Fees:** Venmo charges a 3% sender’s fee.
-
-
-## Pay outside of Expensify
-If you are unable to pay using one of the above methods, you can still mark the Bill as paid. This will update its status to indicate that the payment was made outside Expensify.
-
-**To mark a Bill as paid outside of Expensify:**
-1. Sign in to your [Expensify web account](www.expensify.com).
-2. Click on the Bill you’d like to pay to see the details.
-3. Click the Reimburse button.
-4. Choose **I’ll do it manually**.
-
-**Fees:** None.
-
-{% include faq-begin.md %}
-
-## Who receives vendor bills in Expensify?
-Bills are sent to the Primary Contact listed under **Settings > Domains > [Domain Name] > Domain Admins**.
-
-## Who can view and pay a Bill?
-Only the primary domain contact can view and pay a Bill.
-
-## How can others access Bills?
-The primary contact can share Bills or grant Copilot access for others to manage payments.
-
-## Is Bill Pay supported internationally?
-Currently, payments are only supported in USD.
-
-## What's the difference between a Bill and an Invoice in Expensify?
-A Bill represents a payable amount owed to a vendor, while an Invoice is a receivable amount owed to you.
-{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md
new file mode 100644
index 000000000000..328b7f2051bc
--- /dev/null
+++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md
@@ -0,0 +1,111 @@
+---
+title: Receive and Pay Bills
+description: Expensify bill management and payment methods.
+---
+
+Easily receive and pay vendor or supplier bills directly in Expensify. Your vendors don’t even need an Expensify account! Manage everything seamlessly in one place.
+
+# Receiving Bills
+
+Expensify makes it easy to receive bills in three simple ways:
+
+### 1. Directly from Vendors
+Share your Expensify billing email with vendors to receive bills automatically.
+
+- Set a Primary Contact under **Settings > Domains > Domain Admins**.
+- Ask vendors to email bills to your billing address: `domainname@expensify.cash` (e.g., for *expensify.com*, use `expensify@expensify.cash`).
+- Once emailed, the bill is automatically created in Expensify, ready for payment.
+
+### 2. Forwarding Emails
+Received a bill in your email? Forward it to Expensify.
+
+- Ensure your Primary Contact is set under **Settings > Domains > Domain Admins**.
+- Forward bills to `domainname@expensify.cash`. Example: `domainname@expensify.cash` (e.g., for *expensify.com*, use `expensify@expensify.cash`).
+- Expensify will create a bill automatically, ready for payment.
+
+### 3. Manual Upload
+Got a paper bill? Create a bill manually in [Expensify](https://www.expensify.com/):
+
+1. Log in to [Expensify](https://www.expensify.com).
+2. Go to **Reports > New Report > Bill**.
+3. Enter the invoice details: sender’s email, merchant name, amount, and date.
+4. Upload the invoice as a receipt.
+
+
+# Paying Bills in Expensify
+
+Expensify makes it easy to manage and pay vendor bills with a straightforward workflow and flexible payment options. Here’s how it works:
+
+## Bill Pay Workflow
+
+1. **SmartScan & Create**: When a vendor sends a bill, Expensify automatically SmartScans the document and creates a bill.
+2. **Submission to Primary Contact**: The bill is submitted to the primary contact, who can review it on the Reports page under their default group policy.
+3. **Communication**: If the approver needs clarification, they can communicate directly with the sender via the invoice linked to the bill.
+4. **Approval Workflow**: Once reviewed, the bill follows your workspace’s approval process. The final approver handles the payment.
+5. **Accounting Integration**: During approval, the bill is coded with the correct GL codes from your connected accounting software. Once approved, it can be exported back to your accounting system.
+
+## Payment Methods
+
+Expensify offers several ways to pay bills. Choose the method that works best for you:
+
+### 1. ACH Bank-to-Bank Transfer
+
+Fast and fee-free, this method requires a connected [business bank account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account).
+
+**How to Pay via ACH:**
+1. Log in to your [Expensify web account](https://www.expensify.com/).
+2. Find the bill on the Home or Reports page.
+3. Click **Pay** and select the ACH option.
+
+**Fees:** None.
+
+---
+
+### 2. Credit or Debit Card
+
+Pay vendors using a credit or debit card. This option is available for US and international customers paying US vendors with a US business bank account.
+
+**How to Pay with a Card:**
+1. Log in to your [Expensify web account](https://www.expensify.com/).
+2. Open the bill details and click **Pay**.
+3. Enter your card information to complete the payment.
+
+**Fees:** 2.9% of the total amount paid.
+
+---
+
+### 3. Venmo
+
+If both you and the vendor have Venmo accounts connected to Expensify, you can pay through Venmo. Learn how to set up Venmo [here](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments#setting-up-third-party-payments).
+
+**Fees:** Venmo charges a 3% sender’s fee.
+
+---
+
+### 4. Pay Outside Expensify
+
+If you prefer to pay outside Expensify, you can still track the payment within the platform.
+
+**How to Mark as Paid Outside Expensify:**
+1. Log in to your [Expensify web account](https://www.expensify.com/).
+2. Open the bill details and click **Pay**.
+3. Select **Mark as Paid** to update its status.
+
+**Fees:** None.
+{% include faq-begin.md %}
+
+## Who receives vendor bills in Expensify?
+bills are sent to the Primary Contact listed under **Settings > Domains > [Domain Name] > Domain Admins**.
+
+## Who can view and pay a bill?
+Only the primary domain contact can view and pay a bill.
+
+## How can others access bills?
+The primary contact can share bills or grant Copilot access for others to manage payments.
+
+## Is bill Pay supported internationally?
+Currently, payments are only supported in USD.
+
+## What's the difference between a bill and an Invoice in Expensify?
+A bill represents a payable amount owed to a vendor, while an Invoice is a receivable amount owed to you.
+{% include faq-end.md %}
diff --git a/docs/articles/new-expensify/expenses-&-payments/Connect-a-Business-Bank-Account.md b/docs/articles/new-expensify/expenses-&-payments/Connect-a-Business-Bank-Account.md
index 516497c9dce7..4a4ec72a671a 100644
--- a/docs/articles/new-expensify/expenses-&-payments/Connect-a-Business-Bank-Account.md
+++ b/docs/articles/new-expensify/expenses-&-payments/Connect-a-Business-Bank-Account.md
@@ -80,6 +80,9 @@ Wait until the end of the second business day. If you still don’t see them, pl
Once that's all set, make sure to contact your account manager or concierge, and our team will be able to re-trigger those three test transactions!
+## Is my data safe?
+
+We take security seriously. Our measures align with what banks use to protect sensitive financial data. We regularly test and update our security to stay ahead of any threats. Plus, we’re checked daily by McAfee for extra reassurance against hackers. You can verify our security strength below or on the McAfee SECURE site. Discover how Expensify safeguards your information [here](https://help.expensify.com/articles/new-expensify/settings/Encryption-and-Data-Security).
{% include faq-end.md %}
diff --git a/docs/redirects.csv b/docs/redirects.csv
index 5c83d510ccb8..e1c0e12eb070 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -603,3 +603,4 @@ https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-T
https://help.expensify.com/articles/expensify-classic/spending-insights/Default-Export-Templates,https://help.expensify.com/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports/
https://help.expensify.com/articles/expensify-classic/spending-insights/Other-Export-Options,https://help.expensify.com/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports/
https://help.expensify.com/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements,https://help.expensify.com/articles/expensify-classic/travel/Book-with-Expensify-Travel
+https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills
diff --git a/help/README.md b/help/README.md
index d62513f07f53..c0fb4dbf524a 100644
--- a/help/README.md
+++ b/help/README.md
@@ -48,7 +48,7 @@ Every PR pushed by an authorized Expensify employee or representative will autom
3. Install Ruby and Jekyll
4. Build the entire site using Jekyll
5. Create a "preview" of the newly built site in Cloudflare
-6. Record a link to that preview in the PR.
+6. Record a link to that preview in the PR
## How to deploy the site for real
Whenever a PR that touches the `/help` directory is merged, it will re-run the build just like before. However, it will detect that this build is being run from the `main` branch, and thus push the changes to the `production` Cloudflare environment -- meaning, it will replace the contents hosted at https://newhelp.expensify.com
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 7f65a14b4d6e..48ab1740093e 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 9.0.65.3
+ 9.0.65.5
FullStory
OrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 38d7dea5a9ce..c283e62e44af 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 9.0.65.3
+ 9.0.65.5
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index b53c5eb457a1..7db594f06494 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -13,7 +13,7 @@
CFBundleShortVersionString
9.0.65
CFBundleVersion
- 9.0.65.3
+ 9.0.65.5
NSExtension
NSExtensionPointIdentifier
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 01aefab63378..5ea5b19896e4 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -2661,7 +2661,7 @@ PODS:
- RNSound/Core (= 0.11.2)
- RNSound/Core (0.11.2):
- React-Core
- - RNSVG (15.6.0):
+ - RNSVG (15.9.0):
- DoubleConversion
- glog
- hermes-engine
@@ -2681,9 +2681,9 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- - RNSVG/common (= 15.6.0)
+ - RNSVG/common (= 15.9.0)
- Yoga
- - RNSVG/common (15.6.0):
+ - RNSVG/common (15.9.0):
- DoubleConversion
- glog
- hermes-engine
@@ -3295,7 +3295,7 @@ SPEC CHECKSUMS:
RNScreens: de6e57426ba0e6cbc3fb5b4f496e7f08cb2773c2
RNShare: bd4fe9b95d1ee89a200778cc0753ebe650154bb0
RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852
- RNSVG: 1079f96b39a35753d481a20e30603fd6fc4f6fa9
+ RNSVG: b2fbe96b2bb3887752f8abc1f495953847e90384
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
diff --git a/package-lock.json b/package-lock.json
index 60c3db01596c..318b08eaf217 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.0.65-3",
+ "version": "9.0.65-5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.65-3",
+ "version": "9.0.65-5",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -111,7 +111,7 @@
"react-native-screens": "3.34.0",
"react-native-share": "11.0.2",
"react-native-sound": "^0.11.2",
- "react-native-svg": "15.6.0",
+ "react-native-svg": "15.9.0",
"react-native-tab-view": "^3.5.2",
"react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "3.8.0",
@@ -36031,9 +36031,10 @@
}
},
"node_modules/react-native-svg": {
- "version": "15.6.0",
- "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.6.0.tgz",
- "integrity": "sha512-TUtR+h+yi1ODsd8FHdom1TpjfWOmnaK5pri5rnSBXnMqpzq8o2zZfonHTjPX+nS3wb/Pu2XsoARgYaHNjVWXhQ==",
+ "version": "15.9.0",
+ "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.9.0.tgz",
+ "integrity": "sha512-pwo7hteAM0P8jNpPGQtiSd0SnbBhE8tNd94LT8AcZcbnH5AJdXBIcXU4+tWYYeGUjiNAH2E5d0T5XIfnvaz1gA==",
+ "license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3",
diff --git a/package.json b/package.json
index 05fe606b9f91..57de821f096e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.65-3",
+ "version": "9.0.65-5",
"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.",
@@ -168,7 +168,7 @@
"react-native-screens": "3.34.0",
"react-native-share": "11.0.2",
"react-native-sound": "^0.11.2",
- "react-native-svg": "15.6.0",
+ "react-native-svg": "15.9.0",
"react-native-tab-view": "^3.5.2",
"react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "3.8.0",
diff --git a/src/CONST.ts b/src/CONST.ts
index 06feb863a80a..496b7d8b28ec 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -2843,6 +2843,7 @@ const CONST = {
AMEX_CUSTOM_FEED: {
CORPORATE: 'American Express Corporate Cards',
BUSINESS: 'American Express Business Cards',
+ PERSONAL: 'American Express Personal Cards',
},
DELETE_TRANSACTIONS: {
RESTRICT: 'corporate',
@@ -2969,8 +2970,8 @@ const CONST = {
// eslint-disable-next-line max-len, no-misleading-character-class
EMOJI: /[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu,
- // eslint-disable-next-line max-len, no-misleading-character-class
- EMOJIS: /[\p{Extended_Pictographic}](\u200D[\p{Extended_Pictographic}]|[\u{1F3FB}-\u{1F3FF}]|[\u{E0020}-\u{E007F}]|\uFE0F|\u20E3)*|[\u{1F1E6}-\u{1F1FF}]{2}|[#*0-9]\uFE0F?\u20E3/gu,
+ // eslint-disable-next-line max-len, no-misleading-character-class, no-empty-character-class
+ EMOJIS: /[\p{Extended_Pictographic}](\u200D[\p{Extended_Pictographic}]|[\u{1F3FB}-\u{1F3FF}]|[\u{E0020}-\u{E007F}]|\uFE0F|\u20E3)*|[\u{1F1E6}-\u{1F1FF}]{2}|[#*0-9]\uFE0F?\u20E3/du,
// eslint-disable-next-line max-len, no-misleading-character-class
EMOJI_SKIN_TONES: /[\u{1f3fb}-\u{1f3ff}]/gu,
@@ -3007,6 +3008,10 @@ const CONST = {
return new RegExp(`[\\n\\s]|${this.SPECIAL_CHAR.source}|${this.EMOJI.source}`, 'gu');
},
+ get ALL_EMOJIS() {
+ return new RegExp(this.EMOJIS, this.EMOJIS.flags.concat('g'));
+ },
+
MERGED_ACCOUNT_PREFIX: /^(MERGED_\d+@)/,
ROUTES: {
VALIDATE_LOGIN: /\/v($|(\/\/*))/,
@@ -5959,6 +5964,9 @@ const CONST = {
ACTION_TYPES: {
VIEW: 'view',
REVIEW: 'review',
+ SUBMIT: 'submit',
+ APPROVE: 'approve',
+ PAY: 'pay',
DONE: 'done',
PAID: 'paid',
},
@@ -6294,10 +6302,17 @@ const CONST = {
},
DEBUG: {
+ FORMS: {
+ REPORT: 'report',
+ REPORT_ACTION: 'reportAction',
+ TRANSACTION: 'transaction',
+ TRANSACTION_VIOLATION: 'transactionViolation',
+ },
DETAILS: 'details',
JSON: 'json',
REPORT_ACTIONS: 'actions',
REPORT_ACTION_PREVIEW: 'preview',
+ TRANSACTION_VIOLATIONS: 'violations',
},
REPORT_IN_LHN_REASONS: {
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index b4510a2faeed..f97edbd744eb 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -716,10 +716,6 @@ const ONYXKEYS = {
RULES_MAX_EXPENSE_AMOUNT_FORM_DRAFT: 'rulesMaxExpenseAmountFormDraft',
RULES_MAX_EXPENSE_AGE_FORM: 'rulesMaxExpenseAgeForm',
RULES_MAX_EXPENSE_AGE_FORM_DRAFT: 'rulesMaxExpenseAgeFormDraft',
- DEBUG_REPORT_PAGE_FORM: 'debugReportPageForm',
- DEBUG_REPORT_PAGE_FORM_DRAFT: 'debugReportPageFormDraft',
- DEBUG_REPORT_ACTION_PAGE_FORM: 'debugReportActionPageForm',
- DEBUG_REPORT_ACTION_PAGE_FORM_DRAFT: 'debugReportActionPageFormDraft',
DEBUG_DETAILS_FORM: 'debugDetailsForm',
DEBUG_DETAILS_FORM_DRAFT: 'debugDetailsFormDraft',
},
@@ -814,9 +810,7 @@ type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AMOUNT_FORM]: FormTypes.RulesMaxExpenseAmountForm;
[ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AGE_FORM]: FormTypes.RulesMaxExpenseAgeForm;
[ONYXKEYS.FORMS.SEARCH_SAVED_SEARCH_RENAME_FORM]: FormTypes.SearchSavedSearchRenameForm;
- [ONYXKEYS.FORMS.DEBUG_REPORT_PAGE_FORM]: FormTypes.DebugReportForm;
- [ONYXKEYS.FORMS.DEBUG_REPORT_ACTION_PAGE_FORM]: FormTypes.DebugReportActionForm;
- [ONYXKEYS.FORMS.DEBUG_DETAILS_FORM]: FormTypes.DebugReportForm | FormTypes.DebugReportActionForm;
+ [ONYXKEYS.FORMS.DEBUG_DETAILS_FORM]: FormTypes.DebugReportForm | FormTypes.DebugReportActionForm | FormTypes.DebugTransactionForm | FormTypes.DebugTransactionViolationForm;
};
type OnyxFormDraftValuesMapping = {
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index bdf4d4774ec1..d8f8b0f91105 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -1295,6 +1295,10 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/per-diem',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/per-diem` as const,
},
+ WORKSPACE_PER_DIEM_SETTINGS: {
+ route: 'settings/workspaces/:policyID/per-diem/settings',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/per-diem/settings` as const,
+ },
RULES_CUSTOM_NAME: {
route: 'settings/workspaces/:policyID/rules/name',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/name` as const,
@@ -1777,13 +1781,46 @@ const ROUTES = {
getRoute: (reportID: string, reportActionID: string) => `debug/report/${reportID}/actions/${reportActionID}/preview` as const,
},
DETAILS_CONSTANT_PICKER_PAGE: {
- route: 'debug/details/constant/:fieldName',
- getRoute: (fieldName: string, fieldValue?: string, backTo?: string) => getUrlWithBackToParam(`debug/details/constant/${fieldName}?fieldValue=${fieldValue}`, backTo),
+ route: 'debug/:formType/details/constant/:fieldName',
+ getRoute: (formType: string, fieldName: string, fieldValue?: string, policyID?: string, backTo?: string) =>
+ getUrlWithBackToParam(`debug/${formType}/details/constant/${fieldName}?fieldValue=${fieldValue}&policyID=${policyID}`, backTo),
},
DETAILS_DATE_TIME_PICKER_PAGE: {
route: 'debug/details/datetime/:fieldName',
getRoute: (fieldName: string, fieldValue?: string, backTo?: string) => getUrlWithBackToParam(`debug/details/datetime/${fieldName}?fieldValue=${fieldValue}`, backTo),
},
+ DEBUG_TRANSACTION: {
+ route: 'debug/transaction/:transactionID',
+ getRoute: (transactionID: string) => `debug/transaction/${transactionID}` as const,
+ },
+ DEBUG_TRANSACTION_TAB_DETAILS: {
+ route: 'debug/transaction/:transactionID/details',
+ getRoute: (transactionID: string) => `debug/transaction/${transactionID}/details` as const,
+ },
+ DEBUG_TRANSACTION_TAB_JSON: {
+ route: 'debug/transaction/:transactionID/json',
+ getRoute: (transactionID: string) => `debug/transaction/${transactionID}/json` as const,
+ },
+ DEBUG_TRANSACTION_TAB_VIOLATIONS: {
+ route: 'debug/transaction/:transactionID/violations',
+ getRoute: (transactionID: string) => `debug/transaction/${transactionID}/violations` as const,
+ },
+ DEBUG_TRANSACTION_VIOLATION_CREATE: {
+ route: 'debug/transaction/:transactionID/violations/create',
+ getRoute: (transactionID: string) => `debug/transaction/${transactionID}/violations/create` as const,
+ },
+ DEBUG_TRANSACTION_VIOLATION: {
+ route: 'debug/transaction/:transactionID/violations/:index',
+ getRoute: (transactionID: string, index: string) => `debug/transaction/${transactionID}/violations/${index}` as const,
+ },
+ DEBUG_TRANSACTION_VIOLATION_TAB_DETAILS: {
+ route: 'debug/transaction/:transactionID/violations/:index/details',
+ getRoute: (transactionID: string, index: string) => `debug/transaction/${transactionID}/violations/${index}/details` as const,
+ },
+ DEBUG_TRANSACTION_VIOLATION_TAB_JSON: {
+ route: 'debug/transaction/:transactionID/violations/:index/json',
+ getRoute: (transactionID: string, index: string) => `debug/transaction/${transactionID}/violations/${index}/json` as const,
+ },
} as const;
/**
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 543e8708fea3..5fd64b0fc0d0 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -546,6 +546,7 @@ const SCREENS = {
RULES_MAX_EXPENSE_AGE: 'Rules_Max_Expense_Age',
RULES_BILLABLE_DEFAULT: 'Rules_Billable_Default',
PER_DIEM: 'Per_Diem',
+ PER_DIEM_SETTINGS: 'Per_Diem_Settings',
},
EDIT_REQUEST: {
@@ -620,6 +621,9 @@ const SCREENS = {
REPORT_ACTION_CREATE: 'Debug_Report_Action_Create',
DETAILS_CONSTANT_PICKER_PAGE: 'Debug_Details_Constant_Picker_Page',
DETAILS_DATE_TIME_PICKER_PAGE: 'Debug_Details_Date_Time_Picker_Page',
+ TRANSACTION: 'Debug_Transaction',
+ TRANSACTION_VIOLATION_CREATE: 'Debug_Transaction_Violation_Create',
+ TRANSACTION_VIOLATION: 'Debug_Transaction_Violation',
},
} as const;
diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx
index 9a90de17595d..ed2eae7a0a4c 100644
--- a/src/components/AccountSwitcher.tsx
+++ b/src/components/AccountSwitcher.tsx
@@ -10,6 +10,7 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import {clearDelegatorErrors, connect, disconnect} from '@libs/actions/Delegate';
+import * as EmojiUtils from '@libs/EmojiUtils';
import * as ErrorUtils from '@libs/ErrorUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import variables from '@styles/variables';
@@ -46,6 +47,7 @@ function AccountSwitcher() {
const isActingAsDelegate = !!account?.delegatedAccess?.delegate ?? false;
const canSwitchAccounts = delegators.length > 0 || isActingAsDelegate;
+ const processedTextArray = EmojiUtils.splitTextWithEmojis(currentUserPersonalDetails?.displayName);
const createBaseMenuItem = (
personalDetails: PersonalDetails | undefined,
@@ -149,7 +151,9 @@ function AccountSwitcher() {
numberOfLines={1}
style={[styles.textBold, styles.textLarge, styles.flexShrink1]}
>
- {currentUserPersonalDetails?.displayName}
+ {processedTextArray.length !== 0
+ ? EmojiUtils.getProcessedText(processedTextArray, styles.initialSettingsUsernameEmoji)
+ : currentUserPersonalDetails?.displayName}
{!!canSwitchAccounts && (
diff --git a/src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx b/src/components/CategorySelector/CategorySelectorModal.tsx
similarity index 100%
rename from src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx
rename to src/components/CategorySelector/CategorySelectorModal.tsx
diff --git a/src/pages/workspace/distanceRates/CategorySelector/index.tsx b/src/components/CategorySelector/index.tsx
similarity index 100%
rename from src/pages/workspace/distanceRates/CategorySelector/index.tsx
rename to src/components/CategorySelector/index.tsx
diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx
index be875790d75e..e71ade65e66d 100755
--- a/src/components/Composer/implementation/index.tsx
+++ b/src/components/Composer/implementation/index.tsx
@@ -19,6 +19,7 @@ import * as Browser from '@libs/Browser';
import * as EmojiUtils from '@libs/EmojiUtils';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition';
+import variables from '@styles/variables';
import CONST from '@src/CONST';
const excludeNoStyles: Array = [];
@@ -70,6 +71,7 @@ function Composer(
start: selectionProp.start,
end: selectionProp.end,
});
+ const [hasMultipleLines, setHasMultipleLines] = useState(false);
const [isRendered, setIsRendered] = useState(false);
const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? '');
const [prevScroll, setPrevScroll] = useState();
@@ -328,10 +330,10 @@ function Composer(
scrollStyleMemo,
StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize),
isComposerFullSize ? {height: '100%', maxHeight: 'none'} : undefined,
- textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {},
+ textContainsOnlyEmojis && hasMultipleLines ? styles.onlyEmojisTextLineHeight : {},
],
- [style, styles.rtlTextRenderForSafari, styles.onlyEmojisTextLineHeight, scrollStyleMemo, StyleUtils, maxLines, isComposerFullSize, textContainsOnlyEmojis],
+ [style, styles.rtlTextRenderForSafari, styles.onlyEmojisTextLineHeight, scrollStyleMemo, hasMultipleLines, StyleUtils, maxLines, isComposerFullSize, textContainsOnlyEmojis],
);
return (
@@ -350,6 +352,9 @@ function Composer(
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...props}
onSelectionChange={addCursorPositionToSelectionChange}
+ onContentSizeChange={(e) => {
+ setHasMultipleLines(e.nativeEvent.contentSize.height > variables.componentSizeLarge);
+ }}
disabled={isDisabled}
onKeyPress={handleKeyPress}
addAuthTokenToImageURLCallback={addEncryptedAuthTokenToURL}
diff --git a/src/components/CurrencySelectionList/index.tsx b/src/components/CurrencySelectionList/index.tsx
index 201ed7bab730..1e8b5294286f 100644
--- a/src/components/CurrencySelectionList/index.tsx
+++ b/src/components/CurrencySelectionList/index.tsx
@@ -1,6 +1,6 @@
import {Str} from 'expensify-common';
import React, {useCallback, useMemo, useState} from 'react';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import SelectionList from '@components/SelectionList';
import RadioListItem from '@components/SelectionList/RadioListItem';
import SelectableListItem from '@components/SelectionList/SelectableListItem';
@@ -8,17 +8,17 @@ import useLocalize from '@hooks/useLocalize';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import ONYXKEYS from '@src/ONYXKEYS';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
-import type {CurrencyListItem, CurrencySelectionListOnyxProps, CurrencySelectionListProps} from './types';
+import type {CurrencyListItem, CurrencySelectionListProps} from './types';
function CurrencySelectionList({
searchInputLabel,
initiallySelectedCurrencyCode,
onSelect,
- currencyList,
selectedCurrencies = [],
canSelectMultiple = false,
recentlyUsedCurrencies,
}: CurrencySelectionListProps) {
+ const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST);
const [searchValue, setSearchValue] = useState('');
const {translate} = useLocalize();
const getUnselectedOptions = useCallback((options: CurrencyListItem[]) => options.filter((option) => !option.isSelected), []);
@@ -107,8 +107,4 @@ function CurrencySelectionList({
CurrencySelectionList.displayName = 'CurrencySelectionList';
-const CurrencySelectionListWithOnyx = withOnyx({
- currencyList: {key: ONYXKEYS.CURRENCY_LIST},
-})(CurrencySelectionList);
-
-export default CurrencySelectionListWithOnyx;
+export default CurrencySelectionList;
diff --git a/src/components/CurrencySelectionList/types.ts b/src/components/CurrencySelectionList/types.ts
index 3001b0ceeaab..5cfef604ab94 100644
--- a/src/components/CurrencySelectionList/types.ts
+++ b/src/components/CurrencySelectionList/types.ts
@@ -1,18 +1,11 @@
-import type {OnyxEntry} from 'react-native-onyx';
import type {ListItem} from '@components/SelectionList/types';
-import type {CurrencyList} from '@src/types/onyx';
type CurrencyListItem = ListItem & {
currencyName: string;
currencyCode: string;
};
-type CurrencySelectionListOnyxProps = {
- /** List of available currencies */
- currencyList: OnyxEntry;
-};
-
-type CurrencySelectionListProps = CurrencySelectionListOnyxProps & {
+type CurrencySelectionListProps = {
/** Label for the search text input */
searchInputLabel: string;
@@ -32,4 +25,4 @@ type CurrencySelectionListProps = CurrencySelectionListOnyxProps & {
canSelectMultiple?: boolean;
};
-export type {CurrencyListItem, CurrencySelectionListProps, CurrencySelectionListOnyxProps};
+export type {CurrencyListItem, CurrencySelectionListProps};
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/EmojiRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/EmojiRenderer.tsx
index 31d092800d20..879684210825 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/EmojiRenderer.tsx
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/EmojiRenderer.tsx
@@ -1,11 +1,22 @@
-import React from 'react';
+import React, {useMemo} from 'react';
+import type {TextStyle} from 'react-native';
import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html';
import EmojiWithTooltip from '@components/EmojiWithTooltip';
import useThemeStyles from '@hooks/useThemeStyles';
function EmojiRenderer({tnode, style: styleProp}: CustomRendererProps) {
const styles = useThemeStyles();
- const style = {...styleProp, ...('islarge' in tnode.attributes ? styles.onlyEmojisText : {})};
+ const style = useMemo(() => {
+ if ('islarge' in tnode.attributes) {
+ return [styleProp as TextStyle, styles.onlyEmojisText];
+ }
+
+ if ('ismedium' in tnode.attributes) {
+ return [styleProp as TextStyle, styles.emojisWithTextFontSize, styles.verticalAlignTopText];
+ }
+
+ return null;
+ }, [tnode.attributes, styles, styleProp]);
return (
{
const lineWidth = getLinedWidth(itemIndex);
diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx
index b59d1604a5aa..a98683b68b41 100644
--- a/src/components/PopoverProvider/index.tsx
+++ b/src/components/PopoverProvider/index.tsx
@@ -7,6 +7,7 @@ import type {AnchorRef, PopoverContextProps, PopoverContextValue} from './types'
const PopoverContext = createContext({
onOpen: () => {},
popover: null,
+ popoverAnchor: null,
close: () => {},
isOpen: false,
});
@@ -21,6 +22,7 @@ function elementContains(ref: RefObject | undefined,
function PopoverContextProvider(props: PopoverContextProps) {
const [isOpen, setIsOpen] = useState(false);
const activePopoverRef = useRef(null);
+ const [activePopoverAnchor, setActivePopoverAnchor] = useState(null);
const closePopover = useCallback((anchorRef?: RefObject): boolean => {
if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) {
@@ -30,6 +32,7 @@ function PopoverContextProvider(props: PopoverContextProps) {
activePopoverRef.current.close();
activePopoverRef.current = null;
setIsOpen(false);
+ setActivePopoverAnchor(null);
return true;
}, []);
@@ -108,6 +111,7 @@ function PopoverContextProvider(props: PopoverContextProps) {
closePopover(activePopoverRef.current.anchorRef);
}
activePopoverRef.current = popoverParams;
+ setActivePopoverAnchor(popoverParams.anchorRef.current);
setIsOpen(true);
},
[closePopover],
@@ -119,9 +123,10 @@ function PopoverContextProvider(props: PopoverContextProps) {
close: closePopover,
// eslint-disable-next-line react-compiler/react-compiler
popover: activePopoverRef.current,
+ popoverAnchor: activePopoverAnchor,
isOpen,
}),
- [onOpen, closePopover, isOpen],
+ [onOpen, closePopover, isOpen, activePopoverAnchor],
);
return {props.children};
diff --git a/src/components/PopoverProvider/types.ts b/src/components/PopoverProvider/types.ts
index b3d21e9ed5d9..04b0b8f90305 100644
--- a/src/components/PopoverProvider/types.ts
+++ b/src/components/PopoverProvider/types.ts
@@ -9,6 +9,7 @@ type PopoverContextProps = {
type PopoverContextValue = {
onOpen?: (popoverParams: AnchorRef) => void;
popover?: AnchorRef | null;
+ popoverAnchor?: AnchorRef['anchorRef']['current'];
close: (anchorRef?: RefObject) => void;
isOpen: boolean;
};
diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx
index dfc88840446f..de20575aeef4 100644
--- a/src/components/ReportActionItem/MoneyReportView.tsx
+++ b/src/components/ReportActionItem/MoneyReportView.tsx
@@ -122,7 +122,7 @@ function MoneyReportView({report, policy, isCombinedReport = false, shouldShowTo
return (
!!report) as string[]) ?? [];
+ const reportIDList = selectedReports?.filter((report) => !!report) ?? [];
SearchActions.exportSearchItemsToCSV(
{query: status, jsonQuery: JSON.stringify(queryJSON), reportIDList, transactionIDList: selectedTransactionsKeys, policyIDs: [activeWorkspaceID ?? '']},
() => {
diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts
index 74bf7b16d020..130ad7ae6f6e 100644
--- a/src/components/Search/types.ts
+++ b/src/components/Search/types.ts
@@ -24,6 +24,13 @@ type SelectedTransactionInfo = {
/** Model of selected results */
type SelectedTransactions = Record;
+/** Model of payment data used by Search bulk actions */
+type PaymentData = {
+ reportID: string;
+ amount: number;
+ paymentType: ValueOf;
+};
+
type SortOrder = ValueOf;
type SearchColumnType = ValueOf;
type ExpenseSearchStatus = ValueOf;
@@ -117,5 +124,6 @@ export type {
TripSearchStatus,
ChatSearchStatus,
SearchAutocompleteResult,
+ PaymentData,
SearchAutocompleteQueryRange,
};
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index 66d648f1b472..0e12e993cc79 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -487,7 +487,8 @@ function BaseSelectionList(
const renderItem = ({item, index, section}: SectionListRenderItemInfo>) => {
const normalizedIndex = index + (section?.indexOffset ?? 0);
const isDisabled = !!section.isDisabled || item.isDisabled;
- const isItemFocused = (!isDisabled || item.isSelected) && (focusedIndex === normalizedIndex || itemsToHighlight?.has(item.keyForList ?? ''));
+ const isItemFocused = (!isDisabled || item.isSelected) && focusedIndex === normalizedIndex;
+ const isItemHighlighted = !!itemsToHighlight?.has(item.keyForList ?? '');
// We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade.
const showTooltip = shouldShowTooltips && normalizedIndex < 10;
@@ -495,7 +496,10 @@ function BaseSelectionList(
onItemLayout(event, item?.keyForList)}>
(
* @param timeout - The timeout in milliseconds before removing the highlight.
*/
const scrollAndHighlightItem = useCallback(
- (items: string[], timeout: number) => {
+ (items: string[]) => {
const newItemsToHighlight = new Set();
items.forEach((item) => {
newItemsToHighlight.add(item);
});
const index = flattenedSections.allOptions.findIndex((option) => newItemsToHighlight.has(option.keyForList ?? ''));
- updateAndScrollToFocusedIndex(index);
+ scrollToIndex(index);
setItemsToHighlight(newItemsToHighlight);
if (itemFocusTimeoutRef.current) {
clearTimeout(itemFocusTimeoutRef.current);
}
+ const duration =
+ CONST.ANIMATED_HIGHLIGHT_ENTRY_DELAY +
+ CONST.ANIMATED_HIGHLIGHT_ENTRY_DURATION +
+ CONST.ANIMATED_HIGHLIGHT_START_DELAY +
+ CONST.ANIMATED_HIGHLIGHT_START_DURATION +
+ CONST.ANIMATED_HIGHLIGHT_END_DELAY +
+ CONST.ANIMATED_HIGHLIGHT_END_DURATION;
itemFocusTimeoutRef.current = setTimeout(() => {
- setFocusedIndex(-1);
setItemsToHighlight(null);
- }, timeout);
+ }, duration);
},
- [flattenedSections.allOptions, setFocusedIndex, updateAndScrollToFocusedIndex],
+ [flattenedSections.allOptions, scrollToIndex],
);
/**
diff --git a/src/components/SelectionList/Search/ActionCell.tsx b/src/components/SelectionList/Search/ActionCell.tsx
index faafa6159dc1..0a360a96e7c7 100644
--- a/src/components/SelectionList/Search/ActionCell.tsx
+++ b/src/components/SelectionList/Search/ActionCell.tsx
@@ -4,6 +4,7 @@ import Badge from '@components/Badge';
import Button from '@components/Button';
import * as Expensicons from '@components/Icon/Expensicons';
import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -15,6 +16,9 @@ import type {SearchTransactionAction} from '@src/types/onyx/SearchResults';
const actionTranslationsMap: Record = {
view: 'common.view',
review: 'common.review',
+ submit: 'common.submit',
+ approve: 'iou.approve',
+ pay: 'iou.pay',
done: 'common.done',
paid: 'iou.settledExpensify',
};
@@ -26,13 +30,23 @@ type ActionCellProps = {
goToItem: () => void;
isChildListItem?: boolean;
parentAction?: string;
+ isLoading?: boolean;
};
-function ActionCell({action = CONST.SEARCH.ACTION_TYPES.VIEW, isLargeScreenWidth = true, isSelected = false, goToItem, isChildListItem = false, parentAction = ''}: ActionCellProps) {
+function ActionCell({
+ action = CONST.SEARCH.ACTION_TYPES.VIEW,
+ isLargeScreenWidth = true,
+ isSelected = false,
+ goToItem,
+ isChildListItem = false,
+ parentAction = '',
+ isLoading = false,
+}: ActionCellProps) {
const {translate} = useLocalize();
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
+ const {isOffline} = useNetwork();
const text = translate(actionTranslationsMap[action]);
@@ -61,9 +75,8 @@ function ActionCell({action = CONST.SEARCH.ACTION_TYPES.VIEW, isLargeScreenWidth
);
}
- const buttonInnerStyles = isSelected ? styles.buttonDefaultHovered : {};
-
if (action === CONST.SEARCH.ACTION_TYPES.VIEW || shouldUseViewAction) {
+ const buttonInnerStyles = isSelected ? styles.buttonDefaultHovered : {};
return isLargeScreenWidth ? (
- );
- }
+ const buttonInnerStyles = isSelected ? styles.buttonSuccessHovered : {};
+ return (
+
+ );
}
ActionCell.displayName = 'ActionCell';
diff --git a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx
index 185b9991e0a6..384262a78b15 100644
--- a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx
+++ b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx
@@ -28,6 +28,7 @@ type ExpenseItemHeaderNarrowProps = {
isDisabled?: boolean | null;
isDisabledCheckbox?: boolean;
handleCheckboxPress?: () => void;
+ isLoading?: boolean;
};
function ExpenseItemHeaderNarrow({
@@ -44,6 +45,7 @@ function ExpenseItemHeaderNarrow({
isDisabled,
handleCheckboxPress,
text,
+ isLoading = false,
}: ExpenseItemHeaderNarrowProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -102,6 +104,7 @@ function ExpenseItemHeaderNarrow({
goToItem={onButtonPress}
isLargeScreenWidth={false}
isSelected={isSelected}
+ isLoading={isLoading}
/>
diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx
index 7add6be940d2..b03932db2532 100644
--- a/src/components/SelectionList/Search/ReportListItem.tsx
+++ b/src/components/SelectionList/Search/ReportListItem.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import {View} from 'react-native';
import Checkbox from '@components/Checkbox';
+import {useSearchContext} from '@components/Search/SearchContext';
import BaseListItem from '@components/SelectionList/BaseListItem';
import type {ListItem, ReportListItemProps, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types';
import Text from '@components/Text';
@@ -10,6 +11,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import {handleActionButtonPress} from '@libs/actions/Search';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import Navigation from '@libs/Navigation/Navigation';
import variables from '@styles/variables';
@@ -69,6 +71,7 @@ function ReportListItem({
const styles = useThemeStyles();
const {isLargeScreenWidth} = useResponsiveLayout();
const StyleUtils = useStyleUtils();
+ const {currentSearchHash} = useSearchContext();
const animatedHighlightStyle = useAnimatedHighlightStyle({
borderRadius: variables.componentBorderRadius,
@@ -93,7 +96,7 @@ function ReportListItem({
];
const handleOnButtonPress = () => {
- onSelectRow(item);
+ handleActionButtonPress(currentSearchHash, reportItem, () => onSelectRow(item));
};
const openReportInRHP = (transactionItem: TransactionListItemType) => {
@@ -165,6 +168,7 @@ function ReportListItem({
action={reportItem.action}
onButtonPress={handleOnButtonPress}
containerStyle={[styles.ph3, styles.pt1half, styles.mb1half]}
+ isLoading={reportItem.isActionLoading}
/>
)}
@@ -199,6 +203,7 @@ function ReportListItem({
action={reportItem.action}
goToItem={handleOnButtonPress}
isSelected={item.isSelected}
+ isLoading={reportItem.isActionLoading}
/>
)}
diff --git a/src/components/SelectionList/Search/TransactionListItem.tsx b/src/components/SelectionList/Search/TransactionListItem.tsx
index 1a27599ed0bd..a23a7048644e 100644
--- a/src/components/SelectionList/Search/TransactionListItem.tsx
+++ b/src/components/SelectionList/Search/TransactionListItem.tsx
@@ -1,10 +1,12 @@
import React from 'react';
+import {useSearchContext} from '@components/Search/SearchContext';
import BaseListItem from '@components/SelectionList/BaseListItem';
import type {ListItem, TransactionListItemProps, TransactionListItemType} from '@components/SelectionList/types';
import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import {handleActionButtonPress} from '@libs/actions/Search';
import variables from '@styles/variables';
import TransactionListItemRow from './TransactionListItemRow';
@@ -26,6 +28,7 @@ function TransactionListItem({
const theme = useTheme();
const {isLargeScreenWidth} = useResponsiveLayout();
+ const {currentSearchHash} = useSearchContext();
const listItemPressableStyle = [
styles.selectionListPressableItemWrapper,
@@ -75,13 +78,14 @@ function TransactionListItem({
item={transactionItem}
showTooltip={showTooltip}
onButtonPress={() => {
- onSelectRow(item);
+ handleActionButtonPress(currentSearchHash, transactionItem, () => onSelectRow(item));
}}
onCheckboxPress={() => onCheckboxPress?.(item)}
isDisabled={!!isDisabled}
canSelectMultiple={!!canSelectMultiple}
isButtonSelected={item.isSelected}
shouldShowTransactionCheckbox={false}
+ isLoading={transactionItem.isActionLoading}
/>
);
diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx
index 37b0429b7caa..6bd071fa5ded 100644
--- a/src/components/SelectionList/Search/TransactionListItemRow.tsx
+++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx
@@ -59,6 +59,7 @@ type TransactionListItemRowProps = {
isButtonSelected?: boolean;
parentAction?: string;
shouldShowTransactionCheckbox?: boolean;
+ isLoading?: boolean;
};
const getTypeIcon = (type?: SearchTransactionType) => {
@@ -257,6 +258,7 @@ function TransactionListItemRow({
isButtonSelected = false,
parentAction = '',
shouldShowTransactionCheckbox,
+ isLoading = false,
}: TransactionListItemRowProps) {
const styles = useThemeStyles();
const {isLargeScreenWidth} = useResponsiveLayout();
@@ -280,6 +282,7 @@ function TransactionListItemRow({
isDisabled={item.isDisabled}
isDisabledCheckbox={item.isDisabledCheckbox}
handleCheckboxPress={onCheckboxPress}
+ isLoading={isLoading}
/>
)}
@@ -445,6 +448,7 @@ function TransactionListItemRow({
isChildListItem={isChildListItem}
parentAction={parentAction}
goToItem={onButtonPress}
+ isLoading={isLoading}
/>
diff --git a/src/components/SelectionList/Search/UserInfoCell.tsx b/src/components/SelectionList/Search/UserInfoCell.tsx
index 6a653471683a..4e71b97028bb 100644
--- a/src/components/SelectionList/Search/UserInfoCell.tsx
+++ b/src/components/SelectionList/Search/UserInfoCell.tsx
@@ -35,7 +35,7 @@ function UserInfoCell({participant, displayName}: UserInfoCellProps) {
/>
{displayName}
diff --git a/src/components/SelectionList/SearchTableHeaderColumn.tsx b/src/components/SelectionList/SearchTableHeaderColumn.tsx
deleted file mode 100644
index e69de29bb2d1..000000000000
diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx
index 0900e49f43ce..8b27ee8a20f8 100644
--- a/src/components/SelectionList/TableListItem.tsx
+++ b/src/components/SelectionList/TableListItem.tsx
@@ -5,6 +5,7 @@ import * as Expensicons from '@components/Icon/Expensicons';
import MultipleAvatars from '@components/MultipleAvatars';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import TextWithTooltip from '@components/TextWithTooltip';
+import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -30,6 +31,13 @@ function TableListItem({
const theme = useTheme();
const StyleUtils = useStyleUtils();
+ const animatedHighlightStyle = useAnimatedHighlightStyle({
+ borderRadius: styles.selectionListPressableItemWrapper.borderRadius,
+ shouldHighlight: !!item.shouldAnimateInHighlight,
+ highlightColor: theme.messageHighlightBG,
+ backgroundColor: theme.highlightBG,
+ });
+
const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor;
const hoveredBackgroundColor = styles.sidebarLinkHover?.backgroundColor ? styles.sidebarLinkHover.backgroundColor : theme.sidebar;
@@ -44,7 +52,17 @@ function TableListItem({
return (
= Partial & {
} & TRightHandSideComponent;
type SelectionListHandle = {
- scrollAndHighlightItem?: (items: string[], timeout: number) => void;
+ scrollAndHighlightItem?: (items: string[]) => void;
clearInputAfterSelect?: () => void;
scrollToIndex: (index: number, animated?: boolean) => void;
updateAndScrollToFocusedIndex: (newFocusedIndex: number) => void;
diff --git a/src/components/SkeletonViewContentLoader/index.native.tsx b/src/components/SkeletonViewContentLoader/index.native.tsx
index 6d275e065bb0..afd58361947a 100644
--- a/src/components/SkeletonViewContentLoader/index.native.tsx
+++ b/src/components/SkeletonViewContentLoader/index.native.tsx
@@ -1,10 +1,17 @@
import React from 'react';
import SkeletonViewContentLoader from 'react-content-loader/native';
+import {StyleSheet} from 'react-native';
import type SkeletonViewContentLoaderProps from './types';
-function ContentLoader(props: SkeletonViewContentLoaderProps) {
- // eslint-disable-next-line react/jsx-props-no-spreading
- return ;
+function ContentLoader({style, ...props}: SkeletonViewContentLoaderProps) {
+ return (
+
+ );
}
export default ContentLoader;
diff --git a/src/components/SkeletonViewContentLoader/index.tsx b/src/components/SkeletonViewContentLoader/index.tsx
index ad3858a2d8d4..cab7710d02ee 100644
--- a/src/components/SkeletonViewContentLoader/index.tsx
+++ b/src/components/SkeletonViewContentLoader/index.tsx
@@ -1,10 +1,19 @@
import React from 'react';
+// eslint-disable-next-line no-restricted-imports
+import type {CSSProperties} from 'react';
import SkeletonViewContentLoader from 'react-content-loader';
+import {StyleSheet} from 'react-native';
import type SkeletonViewContentLoaderProps from './types';
-function ContentLoader(props: SkeletonViewContentLoaderProps) {
- // eslint-disable-next-line react/jsx-props-no-spreading
- return ;
+function ContentLoader({style, ...props}: SkeletonViewContentLoaderProps) {
+ return (
+
+ );
}
export default ContentLoader;
diff --git a/src/components/SkeletonViewContentLoader/types.ts b/src/components/SkeletonViewContentLoader/types.ts
index 5f4089f316dd..de1bdef558ef 100644
--- a/src/components/SkeletonViewContentLoader/types.ts
+++ b/src/components/SkeletonViewContentLoader/types.ts
@@ -1,6 +1,6 @@
import type {IContentLoaderProps} from 'react-content-loader';
import type {IContentLoaderProps as NativeIContentLoaderProps} from 'react-content-loader/native';
-type SkeletonViewContentLoaderProps = IContentLoaderProps & NativeIContentLoaderProps;
+type SkeletonViewContentLoaderProps = Omit & NativeIContentLoaderProps;
export default SkeletonViewContentLoaderProps;
diff --git a/src/components/Skeletons/CardRowSkeleton.tsx b/src/components/Skeletons/CardRowSkeleton.tsx
index d0e14b2bbb9a..24a2f8826908 100644
--- a/src/components/Skeletons/CardRowSkeleton.tsx
+++ b/src/components/Skeletons/CardRowSkeleton.tsx
@@ -31,7 +31,7 @@ function CardRowSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacityEn
shouldAnimate={shouldAnimate}
fixedNumItems={fixedNumItems}
gradientOpacityEnabled={gradientOpacityEnabled}
- itemViewStyle={[styles.highlightBG, styles.mb3, styles.br3, styles.mh5]}
+ itemViewStyle={[styles.highlightBG, styles.mb3, styles.br3, styles.ml5]}
renderSkeletonItem={() => (
<>
-
- {renderSkeletonItem({itemIndex: i})}
-
- ,
+ {renderSkeletonItem({itemIndex: i})}
+ ,
);
}
return items;
@@ -83,7 +80,7 @@ function ItemListSkeletonView({
return (
{skeletonViewItems}
diff --git a/src/components/Skeletons/SearchRowSkeleton.tsx b/src/components/Skeletons/SearchRowSkeleton.tsx
index 3535ba329a90..53f5aaa6065b 100644
--- a/src/components/Skeletons/SearchRowSkeleton.tsx
+++ b/src/components/Skeletons/SearchRowSkeleton.tsx
@@ -42,7 +42,7 @@ function SearchRowSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacity
(
<>
(
<>
policyTag.enabled && !selectedNames.includes(policyTag.name))];
}, [selectedOptions, policyTagList, shouldShowDisabledAndSelectedOption]);
- const sections = useMemo(
- () =>
- TagOptionListUtils.getTagListSections({
- searchValue,
- selectedOptions,
- tags: enabledTags,
- recentlyUsedTags: policyRecentlyUsedTagsList,
- }),
- [searchValue, enabledTags, selectedOptions, policyRecentlyUsedTagsList],
- );
+ const sections = useMemo(() => {
+ const tagSections = TagOptionListUtils.getTagListSections({
+ searchValue,
+ selectedOptions,
+ tags: enabledTags,
+ recentlyUsedTags: policyRecentlyUsedTagsList,
+ });
+ return shouldOrderListByTagName
+ ? tagSections.map((option) => ({
+ ...option,
+ data: option.data.sort((a, b) => a.text?.localeCompare(b.text ?? '') ?? 0),
+ }))
+ : tagSections;
+ }, [searchValue, selectedOptions, enabledTags, policyRecentlyUsedTagsList, shouldOrderListByTagName]);
const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList((sections?.at(0)?.data?.length ?? 0) > 0, searchValue);
diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx
index 670126f8c6ec..9de6b6dd6d08 100644
--- a/src/components/TextInput/BaseTextInput/index.native.tsx
+++ b/src/components/TextInput/BaseTextInput/index.native.tsx
@@ -183,8 +183,11 @@ function BaseTextInput(
const layout = event.nativeEvent.layout;
+ // We need to increase the height for single line inputs to escape cursor jumping on ios
+ const heightToFitEmojis = 1;
+
setWidth((prevWidth: number | null) => (autoGrowHeight ? layout.width : prevWidth));
- setHeight((prevHeight: number) => (!multiline ? layout.height : prevHeight));
+ setHeight((prevHeight: number) => (!multiline ? layout.height + heightToFitEmojis : prevHeight));
},
[autoGrowHeight, multiline],
);
diff --git a/src/components/TextWithTooltip/index.native.tsx b/src/components/TextWithTooltip/index.native.tsx
index b857ded2588b..9f5f246ff9d3 100644
--- a/src/components/TextWithTooltip/index.native.tsx
+++ b/src/components/TextWithTooltip/index.native.tsx
@@ -1,14 +1,19 @@
import React from 'react';
import Text from '@components/Text';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as EmojiUtils from '@libs/EmojiUtils';
import type TextWithTooltipProps from './types';
function TextWithTooltip({text, style, numberOfLines = 1}: TextWithTooltipProps) {
+ const styles = useThemeStyles();
+ const processedTextArray = EmojiUtils.splitTextWithEmojis(text);
+
return (
- {text}
+ {processedTextArray.length !== 0 ? EmojiUtils.getProcessedText(processedTextArray, [style, styles.emojisFontFamily]) : text}
);
}
diff --git a/src/components/Tooltip/PopoverAnchorTooltip.tsx b/src/components/Tooltip/PopoverAnchorTooltip.tsx
index 1af0f01cf957..7e2d9b1ebd4c 100644
--- a/src/components/Tooltip/PopoverAnchorTooltip.tsx
+++ b/src/components/Tooltip/PopoverAnchorTooltip.tsx
@@ -5,24 +5,19 @@ import BaseTooltip from './BaseTooltip';
import type {TooltipExtendedProps} from './types';
function PopoverAnchorTooltip({shouldRender = true, children, ...props}: TooltipExtendedProps) {
- const {isOpen, popover} = useContext(PopoverContext);
+ const {isOpen, popoverAnchor} = useContext(PopoverContext);
const tooltipRef = useRef(null);
const isPopoverRelatedToTooltipOpen = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/dot-notation, react-compiler/react-compiler
const tooltipNode = (tooltipRef.current?.['_childNode'] as Node | undefined) ?? null;
- if (
- isOpen &&
- popover?.anchorRef?.current &&
- tooltipNode &&
- ((popover.anchorRef.current instanceof Node && tooltipNode.contains(popover.anchorRef.current)) || tooltipNode === popover.anchorRef.current)
- ) {
+ if (isOpen && popoverAnchor && tooltipNode && ((popoverAnchor instanceof Node && tooltipNode.contains(popoverAnchor)) || tooltipNode === popoverAnchor)) {
return true;
}
return false;
- }, [isOpen, popover]);
+ }, [isOpen, popoverAnchor]);
if (!shouldRender || isPopoverRelatedToTooltipOpen) {
return children;
diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/index.android.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/index.android.tsx
index 704405f93a2c..08c2b087b7d5 100644
--- a/src/components/ValidateCodeActionModal/ValidateCodeForm/index.android.tsx
+++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/index.android.tsx
@@ -1,4 +1,5 @@
import React, {forwardRef} from 'react';
+import {gestureHandlerRootHOC} from 'react-native-gesture-handler';
import BaseValidateCodeForm from './BaseValidateCodeForm';
import type {ValidateCodeFormHandle, ValidateCodeFormProps} from './BaseValidateCodeForm';
@@ -11,4 +12,4 @@ const ValidateCodeForm = forwardRef
));
-export default ValidateCodeForm;
+export default gestureHandlerRootHOC(ValidateCodeForm);
diff --git a/src/components/WorkspacesListRowDisplayName/index.native.tsx b/src/components/WorkspacesListRowDisplayName/index.native.tsx
new file mode 100644
index 000000000000..1a91e2857db3
--- /dev/null
+++ b/src/components/WorkspacesListRowDisplayName/index.native.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import Text from '@components/Text';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as EmojiUtils from '@libs/EmojiUtils';
+import type WorkspacesListRowDisplayNameProps from './types';
+
+function WorkspacesListRowDisplayName({isDeleted, ownerName}: WorkspacesListRowDisplayNameProps) {
+ const styles = useThemeStyles();
+ const processedOwnerName = EmojiUtils.splitTextWithEmojis(ownerName);
+
+ return (
+
+ {processedOwnerName.length !== 0
+ ? EmojiUtils.getProcessedText(processedOwnerName, [styles.labelStrong, isDeleted ? styles.offlineFeedback.deleted : {}, styles.emojisWithTextFontFamily])
+ : ownerName}
+
+ );
+}
+
+WorkspacesListRowDisplayName.displayName = 'WorkspacesListRowDisplayName';
+
+export default WorkspacesListRowDisplayName;
diff --git a/src/components/WorkspacesListRowDisplayName/index.tsx b/src/components/WorkspacesListRowDisplayName/index.tsx
new file mode 100644
index 000000000000..0d3acb736d2f
--- /dev/null
+++ b/src/components/WorkspacesListRowDisplayName/index.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import Text from '@components/Text';
+import useThemeStyles from '@hooks/useThemeStyles';
+import type WorkspacesListRowDisplayNameProps from './types';
+
+function WorkspacesListRowDisplayName({isDeleted, ownerName}: WorkspacesListRowDisplayNameProps) {
+ const styles = useThemeStyles();
+
+ return (
+
+ {ownerName}
+
+ );
+}
+
+WorkspacesListRowDisplayName.displayName = 'WorkspacesListRowDisplayName';
+
+export default WorkspacesListRowDisplayName;
diff --git a/src/components/WorkspacesListRowDisplayName/types.tsx b/src/components/WorkspacesListRowDisplayName/types.tsx
new file mode 100644
index 000000000000..0744ebc18fc1
--- /dev/null
+++ b/src/components/WorkspacesListRowDisplayName/types.tsx
@@ -0,0 +1,9 @@
+type WorkspacesListRowDisplayNameProps = {
+ /** Should the deleted style be applied */
+ isDeleted: boolean;
+
+ /** Workspace owner name */
+ ownerName: string;
+};
+
+export default WorkspacesListRowDisplayNameProps;
diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts
index 2006ca85dd13..7b38cc12347f 100644
--- a/src/hooks/useMarkdownStyle.ts
+++ b/src/hooks/useMarkdownStyle.ts
@@ -10,7 +10,7 @@ const defaultEmptyArray: Array = [];
function useMarkdownStyle(message: string | null = null, excludeStyles: Array = defaultEmptyArray): MarkdownStyle {
const theme = useTheme();
const hasMessageOnlyEmojis = message != null && message.length > 0 && containsOnlyEmojis(message);
- const emojiFontSize = hasMessageOnlyEmojis ? variables.fontSizeOnlyEmojis : variables.fontSizeNormal;
+ const emojiFontSize = hasMessageOnlyEmojis ? variables.fontSizeOnlyEmojis : variables.fontSizeEmojisWithinText;
// this map is used to reset the styles that are not needed - passing undefined value can break the native side
const nonStylingDefaultValues: Record = useMemo(
@@ -38,6 +38,7 @@ function useMarkdownStyle(message: string | null = null, excludeStyles: Array ({
one: 'Are you sure you want to delete this rate?',
@@ -5327,6 +5328,9 @@ const translations = {
createReportAction: 'Create Report Action',
reportAction: 'Report Action',
report: 'Report',
+ transaction: 'Transaction',
+ violations: 'Violations',
+ transactionViolation: 'Transaction Violation',
hint: "Data changes won't be sent to the backend",
textFields: 'Text fields',
numberFields: 'Number fields',
@@ -5342,6 +5346,8 @@ const translations = {
true: 'true',
false: 'false',
viewReport: 'View Report',
+ viewTransaction: 'View transaction',
+ createTransactionViolation: 'Create transaction violation',
reasonVisibleInLHN: {
hasDraftComment: 'Has draft comment',
hasGBR: 'Has GBR',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 90bb2ba466f1..bfb13aa96c5b 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -642,8 +642,8 @@ const translations = {
copyEmailToClipboard: 'Copiar email al portapapeles',
markAsUnread: 'Marcar como no leído',
markAsRead: 'Marcar como leído',
- editAction: ({action}: EditActionParams) => `Editar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'gastos' : 'comentario'}`,
- deleteAction: ({action}: DeleteActionParams) => `Eliminar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'gastos' : 'comentario'}`,
+ editAction: ({action}: EditActionParams) => `Editar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'gasto' : 'comentario'}`,
+ deleteAction: ({action}: DeleteActionParams) => `Eliminar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'gasto' : 'comentario'}`,
deleteConfirmation: ({action}: DeleteConfirmationParams) =>
`¿Estás seguro de que quieres eliminar este ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'gasto' : 'comentario'}?`,
onlyVisible: 'Visible sólo para',
@@ -1414,7 +1414,7 @@ const translations = {
enableWalletToSendAndReceiveMoney: 'Habilita tu Billetera Expensify para comenzar a enviar y recibir dinero con amigos.',
walletEnabledToSendAndReceiveMoney: 'Tu billetera ha sido habilitada para enviar y recibir dinero con amigos.',
enableWallet: 'Habilitar billetera',
- addBankAccountToSendAndReceive: 'Añade una cuenta bancaria para recibir reembolsos por los gastos que envíes a un espacio de trabajo.',
+ addBankAccountToSendAndReceive: 'Recibe el reembolso de los gastos que envíes a un espacio de trabajo.',
addBankAccount: 'Añadir cuenta bancaria',
assignedCards: 'Tarjetas asignadas',
assignedCardsDescription: 'Son tarjetas asignadas por un administrador del espacio de trabajo para gestionar los gastos de la empresa.',
@@ -2550,6 +2550,7 @@ const translations = {
return 'Miembro';
}
},
+ defaultCategory: 'Categoría predeterminada',
},
perDiem: {
subtitle: 'Establece las tasas per diem para controlar los gastos diarios de los empleados. ',
@@ -3324,6 +3325,7 @@ const translations = {
},
amexCorporate: 'Seleccione esto si el frente de sus tarjetas dice “Corporativa”',
amexBusiness: 'Seleccione esta opción si el frente de sus tarjetas dice “Negocios”',
+ amexPersonal: 'Selecciona esta opción si tus tarjetas son personales',
error: {
pleaseSelectProvider: 'Seleccione un proveedor de tarjetas antes de continuar.',
pleaseSelectBankAccount: 'Seleccione una cuenta bancaria antes de continuar.',
@@ -4135,7 +4137,6 @@ const translations = {
unit: 'Unidad',
taxFeatureNotEnabledMessage: 'Los impuestos deben estar activados en el área de trabajo para poder utilizar esta función. Dirígete a ',
changePromptMessage: ' para hacer ese cambio.',
- defaultCategory: 'Categoría predeterminada',
deleteDistanceRate: 'Eliminar tasa de distancia',
areYouSureDelete: () => ({
one: '¿Estás seguro de que quieres eliminar esta tasa?',
@@ -5846,6 +5847,9 @@ const translations = {
createReportAction: 'Crear Report Action',
reportAction: 'Report Action',
report: 'Report',
+ transaction: 'Transacción',
+ violations: 'Violaciones',
+ transactionViolation: 'Violación de transacción',
hint: 'Los cambios de datos no se enviarán al backend',
textFields: 'Campos de texto',
numberFields: 'Campos numéricos',
@@ -5861,6 +5865,8 @@ const translations = {
true: 'verdadero',
false: 'falso',
viewReport: 'Ver Informe',
+ viewTransaction: 'Ver transacción',
+ createTransactionViolation: 'Crear infracción de transacción',
reasonVisibleInLHN: {
hasDraftComment: 'Tiene comentario en borrador',
hasGBR: 'Tiene GBR',
diff --git a/src/libs/API/parameters/EnableDistanceRequestTaxParams.ts b/src/libs/API/parameters/EnableDistanceRequestTaxParams.ts
new file mode 100644
index 000000000000..0d42d9ba84b4
--- /dev/null
+++ b/src/libs/API/parameters/EnableDistanceRequestTaxParams.ts
@@ -0,0 +1,6 @@
+type EnableDistanceRequestTaxParams = {
+ policyID: string;
+ customUnit: string;
+};
+
+export default EnableDistanceRequestTaxParams;
diff --git a/src/libs/API/parameters/PayMoneyRequestOnSearchParams.ts b/src/libs/API/parameters/PayMoneyRequestOnSearchParams.ts
index 10174f0baa37..a12c0fdc32f2 100644
--- a/src/libs/API/parameters/PayMoneyRequestOnSearchParams.ts
+++ b/src/libs/API/parameters/PayMoneyRequestOnSearchParams.ts
@@ -1,11 +1,10 @@
type PayMoneyRequestOnSearchParams = {
hash: number;
- paymentType: string;
/**
* Stringified JSON object with type of following structure:
- * Array<{reportID: string, amount: number}>
+ * Array<{reportID: string, amount: number, paymentType: string}>
*/
- reportsAndAmounts: string;
+ paymentData: string;
};
export default PayMoneyRequestOnSearchParams;
diff --git a/src/libs/API/parameters/SetCustomUnitDefaultCategoryParams.ts b/src/libs/API/parameters/SetCustomUnitDefaultCategoryParams.ts
new file mode 100644
index 000000000000..53eac3110af7
--- /dev/null
+++ b/src/libs/API/parameters/SetCustomUnitDefaultCategoryParams.ts
@@ -0,0 +1,7 @@
+type SetCustomUnitDefaultCategoryParams = {
+ policyID: string;
+ customUnitID: string;
+ category: string;
+};
+
+export default SetCustomUnitDefaultCategoryParams;
diff --git a/src/libs/API/parameters/SetPolicyDistanceRatesDefaultCategoryParams.ts b/src/libs/API/parameters/SetPolicyDistanceRatesDefaultCategoryParams.ts
deleted file mode 100644
index d2d11993a172..000000000000
--- a/src/libs/API/parameters/SetPolicyDistanceRatesDefaultCategoryParams.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-type SetPolicyDistanceRatesDefaultCategoryParams = {
- policyID: string;
- customUnit: string;
-};
-
-export default SetPolicyDistanceRatesDefaultCategoryParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index 681114fd3b08..61b0c68f874f 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -206,7 +206,8 @@ export type {default as EnablePolicyTaxesParams} from './EnablePolicyTaxesParams
export type {default as OpenPolicyMoreFeaturesPageParams} from './OpenPolicyMoreFeaturesPageParams';
export type {default as CreatePolicyDistanceRateParams} from './CreatePolicyDistanceRateParams';
export type {default as SetPolicyDistanceRatesUnitParams} from './SetPolicyDistanceRatesUnitParams';
-export type {default as SetPolicyDistanceRatesDefaultCategoryParams} from './SetPolicyDistanceRatesDefaultCategoryParams';
+export type {default as EnableDistanceRequestTaxParams} from './EnableDistanceRequestTaxParams';
+export type {default as SetCustomUnitDefaultCategoryParams} from './SetCustomUnitDefaultCategoryParams';
export type {default as UpdatePolicyDistanceRateValueParams} from './UpdatePolicyDistanceRateValueParams';
export type {default as SetPolicyDistanceRatesEnabledParams} from './SetPolicyDistanceRatesEnabledParams';
export type {default as DeletePolicyDistanceRatesParams} from './DeletePolicyDistanceRatesParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index bd8a58555617..d7258f1dd49e 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -285,7 +285,7 @@ const WRITE_COMMANDS = {
REQUEST_WORKSPACE_OWNER_CHANGE: 'RequestWorkspaceOwnerChange',
ADD_BILLING_CARD_AND_REQUEST_WORKSPACE_OWNER_CHANGE: 'AddBillingCardAndRequestPolicyOwnerChange',
SET_POLICY_DISTANCE_RATES_UNIT: 'SetPolicyDistanceRatesUnit',
- SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY: 'SetPolicyDistanceRatesDefaultCategory',
+ SET_CUSTOM_UNIT_DEFAULT_CATEGORY: 'SetCustomUnitDefaultCategory',
ENABLE_DISTANCE_REQUEST_TAX: 'EnableDistanceRequestTax',
UPDATE_POLICY_DISTANCE_RATE_VALUE: 'UpdatePolicyDistanceRateValue',
UPDATE_POLICY_DISTANCE_TAX_RATE_VALUE: 'UpdateDistanceTaxRate',
@@ -688,8 +688,8 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.RENAME_POLICY_TAX]: Parameters.RenamePolicyTaxParams;
[WRITE_COMMANDS.UPDATE_POLICY_TAX_CODE]: Parameters.UpdatePolicyTaxCodeParams;
[WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT]: Parameters.SetPolicyDistanceRatesUnitParams;
- [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams;
- [WRITE_COMMANDS.ENABLE_DISTANCE_REQUEST_TAX]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams;
+ [WRITE_COMMANDS.SET_CUSTOM_UNIT_DEFAULT_CATEGORY]: Parameters.SetCustomUnitDefaultCategoryParams;
+ [WRITE_COMMANDS.ENABLE_DISTANCE_REQUEST_TAX]: Parameters.EnableDistanceRequestTaxParams;
[WRITE_COMMANDS.REPORT_EXPORT]: Parameters.ReportExportParams;
[WRITE_COMMANDS.MARK_AS_EXPORTED]: Parameters.MarkAsExportedParams;
[WRITE_COMMANDS.REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE]: Parameters.RequestExpensifyCardLimitIncreaseParams;
diff --git a/src/libs/Authentication.ts b/src/libs/Authentication.ts
index 34630af81733..5e7b00472471 100644
--- a/src/libs/Authentication.ts
+++ b/src/libs/Authentication.ts
@@ -62,55 +62,44 @@ function reauthenticate(command = ''): Promise {
partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD,
partnerUserID: credentials?.autoGeneratedLogin,
partnerUserSecret: credentials?.autoGeneratedPassword,
- })
- .then((response) => {
- if (response.jsonCode === CONST.JSON_CODE.UNABLE_TO_RETRY) {
- // If authentication fails, then the network can be unpaused
- NetworkStore.setIsAuthenticating(false);
+ }).then((response) => {
+ if (response.jsonCode === CONST.JSON_CODE.UNABLE_TO_RETRY) {
+ // When a fetch() fails due to a network issue and an error is thrown we won't log the user out. Most likely they
+ // have a spotty connection and will need to retry reauthenticate when they come back online. Error so it can be handled by the retry mechanism.
+ throw new Error('Unable to retry Authenticate request');
+ }
- // When a fetch() fails due to a network issue and an error is thrown we won't log the user out. Most likely they
- // have a spotty connection and will need to try to reauthenticate when they come back online. We will error so it
- // can be handled by callers of reauthenticate().
- throw new Error('Unable to retry Authenticate request');
- }
-
- // If authentication fails and we are online then log the user out
- if (response.jsonCode !== 200) {
- const errorMessage = ErrorUtils.getAuthenticateErrorMessage(response);
- NetworkStore.setIsAuthenticating(false);
- Log.hmmm('Redirecting to Sign In because we failed to reauthenticate', {
- command,
- error: errorMessage,
- });
- redirectToSignIn(errorMessage);
- return;
- }
+ // If authentication fails and we are online then log the user out
+ if (response.jsonCode !== 200) {
+ const errorMessage = ErrorUtils.getAuthenticateErrorMessage(response);
+ NetworkStore.setIsAuthenticating(false);
+ Log.hmmm('Redirecting to Sign In because we failed to reauthenticate', {
+ command,
+ error: errorMessage,
+ });
+ redirectToSignIn(errorMessage);
+ return;
+ }
- // If we reauthenticated due to an expired delegate token, restore the delegate's original account.
- // This is because the credentials used to reauthenticate were for the delegate's original account, and not for the account they were connected as.
- if (Delegate.isConnectedAsDelegate()) {
- Log.info('Reauthenticated while connected as a delegate. Restoring original account.');
- Delegate.restoreDelegateSession(response);
- return;
- }
+ // If we reauthenticated due to an expired delegate token, restore the delegate's original account.
+ // This is because the credentials used to reauthenticate were for the delegate's original account, and not for the account they were connected as.
+ if (Delegate.isConnectedAsDelegate()) {
+ Log.info('Reauthenticated while connected as a delegate. Restoring original account.');
+ Delegate.restoreDelegateSession(response);
+ return;
+ }
- // Update authToken in Onyx and in our local variables so that API requests will use the new authToken
- updateSessionAuthTokens(response.authToken, response.encryptedAuthToken);
+ // Update authToken in Onyx and in our local variables so that API requests will use the new authToken
+ updateSessionAuthTokens(response.authToken, response.encryptedAuthToken);
- // Note: It is important to manually set the authToken that is in the store here since any requests that are hooked into
- // reauthenticate .then() will immediate post and use the local authToken. Onyx updates subscribers lately so it is not
- // enough to do the updateSessionAuthTokens() call above.
- NetworkStore.setAuthToken(response.authToken ?? null);
+ // Note: It is important to manually set the authToken that is in the store here since any requests that are hooked into
+ // reauthenticate .then() will immediate post and use the local authToken. Onyx updates subscribers lately so it is not
+ // enough to do the updateSessionAuthTokens() call above.
+ NetworkStore.setAuthToken(response.authToken ?? null);
- // The authentication process is finished so the network can be unpaused to continue processing requests
- NetworkStore.setIsAuthenticating(false);
- })
- .catch((error) => {
- // In case the authenticate call throws error, we need to sign user out as most likely they are missing credentials
- NetworkStore.setIsAuthenticating(false);
- Log.hmmm('Redirecting to Sign In because we failed to reauthenticate', {error});
- redirectToSignIn('passwordForm.error.fallback');
- });
+ // The authentication process is finished so the network can be unpaused to continue processing requests
+ NetworkStore.setIsAuthenticating(false);
+ });
}
export {reauthenticate, Authenticate};
diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts
index 77aeb8e0ecc3..4144f0be94ec 100644
--- a/src/libs/CardUtils.ts
+++ b/src/libs/CardUtils.ts
@@ -9,6 +9,7 @@ import type {TranslationPaths} from '@src/languages/types';
import type {OnyxValues} from '@src/ONYXKEYS';
import ONYXKEYS from '@src/ONYXKEYS';
import type {BankAccountList, Card, CardFeeds, CardList, CompanyCardFeed, PersonalDetailsList, WorkspaceCardsList} from '@src/types/onyx';
+import type {FilteredCardList} from '@src/types/onyx/Card';
import type {CompanyCardNicknames, CompanyFeeds} from '@src/types/onyx/CardFeeds';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type IconAsset from '@src/types/utils/IconAsset';
@@ -212,14 +213,6 @@ function sortCardsByCardholderName(cardsList: OnyxEntry, per
});
}
-function getCompanyCardNumber(cardList: Record, lastFourPAN?: string, cardName = ''): string {
- if (!lastFourPAN) {
- return '';
- }
-
- return Object.keys(cardList).find((card) => card.endsWith(lastFourPAN)) ?? cardName;
-}
-
function getCardFeedIcon(cardFeed: CompanyCardFeed | typeof CONST.EXPENSIFY_CARD.BANK): IconAsset {
const feedIcons = {
[CONST.COMPANY_CARD.FEED_BANK_NAME.VISA]: Illustrations.VisaCompanyCardDetailLarge,
@@ -352,6 +345,16 @@ function getSelectedFeed(lastSelectedFeed: OnyxEntry, cardFeeds
return lastSelectedFeed ?? defaultFeed;
}
+function getFilteredCardList(list?: WorkspaceCardsList) {
+ const {cardList, ...cards} = list ?? {};
+ // We need to filter out cards which already has been assigned
+ return Object.fromEntries(Object.entries(cardList ?? {}).filter(([cardNumber]) => !Object.values(cards).find((card) => card.lastFourPAN && cardNumber.endsWith(card.lastFourPAN))));
+}
+
+function hasOnlyOneCardToAssign(list: FilteredCardList) {
+ return Object.keys(list).length === 1;
+}
+
export {
isExpensifyCard,
isCorporateCard,
@@ -368,7 +371,6 @@ export {
getTranslationKeyForLimitType,
getEligibleBankAccountsForCard,
sortCardsByCardholderName,
- getCompanyCardNumber,
getCardFeedIcon,
getCardFeedName,
getCompanyFeeds,
@@ -378,4 +380,6 @@ export {
getCorrectStepForSelectedBank,
getCustomOrFormattedFeedName,
removeExpensifyCardFromCompanyCards,
+ getFilteredCardList,
+ hasOnlyOneCardToAssign,
};
diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts
index eef25d02ef0a..671fb03f268b 100644
--- a/src/libs/DebugUtils.ts
+++ b/src/libs/DebugUtils.ts
@@ -1,13 +1,18 @@
+/* eslint-disable default-case */
+
/* eslint-disable max-classes-per-file */
import {isMatch, isValid} from 'date-fns';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
+import type {TupleToUnion} from 'type-fest';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {Beta, Policy, Report, ReportAction, ReportActions, TransactionViolation} from '@src/types/onyx';
+import type {Beta, Policy, Report, ReportAction, ReportActions, Transaction, TransactionViolation} from '@src/types/onyx';
+import * as ReportActionsUtils from './ReportActionsUtils';
import * as ReportUtils from './ReportUtils';
import SidebarUtils from './SidebarUtils';
+import * as TransactionUtils from './TransactionUtils';
class NumberError extends SyntaxError {
constructor() {
@@ -35,38 +40,25 @@ class ObjectError extends SyntaxError {
}
}
-type ObjectType = Record;
+type ObjectType> = Record;
-type ConstantEnum = Record;
+type ConstantEnum = Record>;
type PropertyTypes = Array<'string' | 'number' | 'object' | 'boolean' | 'undefined'>;
-const OPTIONAL_BOOLEAN_STRINGS = ['true', 'false', 'undefined'];
+type ArrayTypeFromOnyxDefinition = T extends unknown[] ? NonNullable : never;
+
+type ArrayElement, K extends keyof TOnyx> = ArrayTypeFromOnyxDefinition[K]>;
-const REPORT_NUMBER_PROPERTIES: Array = [
- 'lastReadSequenceNumber',
- 'managerID',
- 'lastActorAccountID',
- 'ownerAccountID',
- 'total',
- 'unheldTotal',
- 'nonReimbursableTotal',
-] satisfies Array;
-
-const REPORT_BOOLEAN_PROPERTIES: Array = [
- 'hasOutstandingChildRequest',
- 'hasOutstandingChildTask',
- 'isOwnPolicyExpenseChat',
- 'isPinned',
- 'hasParentAccess',
- 'isDeletedParentAction',
- 'isOptimisticReport',
- 'isWaitingOnBankAccount',
- 'isCancelledIOU',
- 'isHidden',
-] satisfies Array;
-
-const REPORT_DATE_PROPERTIES: Array = ['lastVisibleActionCreated', 'lastReadTime', 'lastMentionedTime', 'lastVisibleActionLastModified'] satisfies Array;
+type KeysOfUnion = T extends T ? keyof T : never;
+
+type ObjectElement = Required[K] extends Record
+ ? TCollectionKey extends string | number
+ ? {[ValueTypeKey in KeysOfUnion]: ValueType[ValueTypeKey]}
+ : {[ElementKey in KeysOfUnion[K]>]: Required[K]>[ElementKey]}
+ : never;
+
+const OPTIONAL_BOOLEAN_STRINGS = ['true', 'false', 'undefined'];
const REPORT_REQUIRED_PROPERTIES: Array = ['reportID'] satisfies Array;
@@ -88,18 +80,9 @@ const REPORT_ACTION_NUMBER_PROPERTIES: Array = [
'timestamp',
] satisfies Array;
-const REPORT_ACTION_BOOLEAN_PROPERTIES: Array = [
- 'isLoading',
- 'automatic',
- 'shouldShow',
- 'isFirstItem',
- 'isAttachmentOnly',
- 'isAttachmentWithText',
- 'isNewestReportAction',
- 'isOptimisticAction',
-] satisfies Array;
+const TRANSACTION_REQUIRED_PROPERTIES: Array = ['transactionID', 'reportID', 'amount', 'created', 'currency', 'merchant'] satisfies Array;
-const REPORT_ACTION_DATE_PROPERTIES: Array = ['created', 'lastModified'] satisfies Array;
+const TRANSACTION_VIOLATION_REQUIRED_PROPERTIES: Array = ['type', 'name'] satisfies Array;
let isInFocusMode: OnyxEntry;
Onyx.connect({
@@ -174,6 +157,10 @@ type OnyxData = (T extends 'number' ? number : T extends
* @throws {SyntaxError} if type is object but the provided string does not represent an object
*/
function stringToOnyxData(data: string, type?: T): OnyxData {
+ if (isEmptyValue(data)) {
+ return data as OnyxData;
+ }
+
let onyxData;
switch (type) {
@@ -235,11 +222,28 @@ function onyxDataToDraftData(data: OnyxEntry>) {
return Object.fromEntries(Object.entries(data ?? {}).map(([key, value]) => [key, onyxDataToString(value)]));
}
+/**
+ * Whether a string representation is an empty value
+ *
+ * @param value - string representantion
+ * @returns whether the value is an empty value
+ */
+function isEmptyValue(value: string): boolean {
+ switch (value) {
+ case 'undefined':
+ case 'null':
+ case '':
+ return true;
+ default:
+ return false;
+ }
+}
+
/**
* Validates if a string is a valid representation of a number.
*/
function validateNumber(value: string) {
- if (value === 'undefined' || value === '' || (!value.includes(' ') && !Number.isNaN(Number(value)))) {
+ if (isEmptyValue(value) || (!value.includes(' ') && !Number.isNaN(Number(value)))) {
return;
}
@@ -261,7 +265,7 @@ function validateBoolean(value: string) {
* Validates if a string is a valid representation of a date.
*/
function validateDate(value: string) {
- if (value === 'undefined' || (isMatch(value, CONST.DATE.FNS_DB_FORMAT_STRING) && isValid(new Date(value)))) {
+ if (isEmptyValue(value) || ((isMatch(value, CONST.DATE.FNS_DB_FORMAT_STRING) || isMatch(value, CONST.DATE.FNS_FORMAT_STRING)) && isValid(new Date(value)))) {
return;
}
@@ -279,7 +283,7 @@ function validateConstantEnum(value: string, constEnum: ConstantEnum) {
return String(val);
});
- if (value === 'undefined' || value === '' || enumValues.includes(value)) {
+ if (isEmptyValue(value) || enumValues.includes(value)) {
return;
}
@@ -289,11 +293,15 @@ function validateConstantEnum(value: string, constEnum: ConstantEnum) {
/**
* Validates if a string is a valid representation of an array.
*/
-function validateArray(
+function validateArray | 'constantEnum' = 'string'>(
value: string,
- arrayType: 'string' | 'number' | 'boolean' | ConstantEnum | Record,
+ arrayType: T extends Record
+ ? Record
+ : T extends 'constantEnum'
+ ? ConstantEnum
+ : T,
) {
- if (value === 'undefined') {
+ if (isEmptyValue(value)) {
return;
}
@@ -306,22 +314,22 @@ function validateArray(
array.forEach((element) => {
// Element is an object
if (element && typeof element === 'object' && typeof arrayType === 'object') {
- Object.entries(arrayType).forEach(([key, val]) => {
- const property = element[key as keyof typeof element];
+ Object.entries(element).forEach(([key, val]) => {
+ const expectedType = arrayType[key as keyof typeof arrayType];
// Property is a constant enum, so we apply validateConstantEnum
- if (typeof val === 'object' && !Array.isArray(val)) {
- return validateConstantEnum(property, val as ConstantEnum);
+ if (typeof expectedType === 'object' && !Array.isArray(expectedType)) {
+ return validateConstantEnum(String(val), expectedType as ConstantEnum);
}
// Expected property type is array
- if (val === 'array') {
+ if (expectedType === 'array') {
// Property type is not array
- if (!Array.isArray(property)) {
+ if (!Array.isArray(val)) {
throw new ArrayError(arrayType);
}
return;
}
// Property type is not one of the valid types
- if (Array.isArray(val) ? !val.includes(typeof property) : typeof property !== val) {
+ if (Array.isArray(expectedType) ? !expectedType.includes(typeof val as TupleToUnion) : typeof val !== expectedType) {
throw new ArrayError(arrayType);
}
});
@@ -345,8 +353,8 @@ function validateArray(
/**
* Validates if a string is a valid representation of an object.
*/
-function validateObject(value: string, type: ObjectType, collectionIndexType?: 'string' | 'number') {
- if (value === 'undefined') {
+function validateObject>(value: string, type: ObjectType, collectionIndexType?: 'string' | 'number') {
+ if (isEmptyValue(value)) {
return;
}
@@ -356,7 +364,7 @@ function validateObject(value: string, type: ObjectType, collectionIndexType?: '
}
: type;
- const object = parseJSON(value) as ObjectType;
+ const object = parseJSON(value) as ObjectType;
if (typeof object !== 'object' || Array.isArray(object)) {
throw new ObjectError(expectedType);
@@ -381,12 +389,13 @@ function validateObject(value: string, type: ObjectType, collectionIndexType?: '
throw new ObjectError(expectedType);
}
- Object.entries(type).forEach(([key, val]) => {
- // test[key] is a constant enum
- if (typeof val === 'object') {
- return validateConstantEnum(test[key] as string, val);
+ Object.entries(test).forEach(([key, val]) => {
+ const expectedValueType = type[key];
+ // val is a constant enum
+ if (typeof expectedValueType === 'object') {
+ return validateConstantEnum(val as string, expectedValueType);
}
- if (val === 'array' ? !Array.isArray(test[key]) : typeof test[key] !== val) {
+ if (expectedValueType === 'array' ? !Array.isArray(val) : typeof val !== expectedValueType) {
throw new ObjectError(expectedType);
}
});
@@ -397,7 +406,7 @@ function validateObject(value: string, type: ObjectType, collectionIndexType?: '
* Validates if a string is a valid representation of a string.
*/
function validateString(value: string) {
- if (value === 'undefined') {
+ if (isEmptyValue(value)) {
return;
}
@@ -415,6 +424,17 @@ function validateString(value: string) {
}
}
+/**
+ * Execute validation of a union type (e.g. Record | Array)
+ */
+function unionValidation(firstValidation: () => void, secondValidation: () => void) {
+ try {
+ firstValidation();
+ } catch (e) {
+ secondValidation();
+ }
+}
+
/**
* Validates if a property of Report is of the expected type
*
@@ -422,78 +442,200 @@ function validateString(value: string) {
* @param value - value provided by the user
*/
function validateReportDraftProperty(key: keyof Report, value: string) {
- if (REPORT_REQUIRED_PROPERTIES.includes(key) && value === 'undefined') {
+ if (REPORT_REQUIRED_PROPERTIES.includes(key) && isEmptyValue(value)) {
throw SyntaxError('debug.missingValue');
}
- if (key === 'privateNotes') {
- return validateObject(
- value,
- {
- note: 'string',
- },
- 'number',
- );
- }
- if (key === 'permissions') {
- return validateArray(value, CONST.REPORT.PERMISSIONS);
- }
- if (key === 'pendingChatMembers') {
- return validateArray(value, {
- accountID: 'string',
- pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION,
- });
- }
- if (key === 'participants') {
- return validateObject(
- value,
- {
- notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE,
- },
- 'number',
- );
- }
- if (REPORT_NUMBER_PROPERTIES.includes(key)) {
- return validateNumber(value);
- }
- if (REPORT_BOOLEAN_PROPERTIES.includes(key)) {
- return validateBoolean(value);
- }
- if (REPORT_DATE_PROPERTIES.includes(key)) {
- return validateDate(value);
- }
- if (key === 'tripData') {
- return validateObject(value, {
- startDate: 'string',
- endDate: 'string',
- tripID: 'string',
- });
- }
- if (key === 'lastActionType') {
- return validateConstantEnum(value, CONST.REPORT.ACTIONS.TYPE);
- }
- if (key === 'writeCapability') {
- return validateConstantEnum(value, CONST.REPORT.WRITE_CAPABILITIES);
- }
- if (key === 'visibility') {
- return validateConstantEnum(value, CONST.REPORT.VISIBILITY);
- }
- if (key === 'stateNum') {
- return validateConstantEnum(value, CONST.REPORT.STATE_NUM);
- }
- if (key === 'statusNum') {
- return validateConstantEnum(value, CONST.REPORT.STATUS_NUM);
- }
- if (key === 'chatType') {
- return validateConstantEnum(value, CONST.REPORT.CHAT_TYPE);
- }
- if (key === 'errorFields') {
- return validateObject(value, {});
- }
- if (key === 'pendingFields') {
- return validateObject(value, {});
+ switch (key) {
+ case 'avatarUrl':
+ case 'lastMessageText':
+ case 'lastVisibleActionCreated':
+ case 'lastReadTime':
+ case 'lastMentionedTime':
+ case 'policyAvatar':
+ case 'policyName':
+ case 'oldPolicyName':
+ case 'description':
+ case 'policyID':
+ case 'reportName':
+ case 'reportID':
+ case 'reportActionID':
+ case 'chatReportID':
+ case 'type':
+ case 'lastMessageTranslationKey':
+ case 'parentReportID':
+ case 'parentReportActionID':
+ case 'lastVisibleActionLastModified':
+ case 'lastMessageHtml':
+ case 'currency':
+ case 'iouReportID':
+ case 'preexistingReportID':
+ case 'private_isArchived':
+ return validateString(value);
+ case 'hasOutstandingChildRequest':
+ case 'hasOutstandingChildTask':
+ case 'isOwnPolicyExpenseChat':
+ case 'isPinned':
+ case 'hasParentAccess':
+ case 'isDeletedParentAction':
+ case 'isOptimisticReport':
+ case 'isWaitingOnBankAccount':
+ case 'isCancelledIOU':
+ case 'isHidden':
+ return validateBoolean(value);
+ case 'lastReadSequenceNumber':
+ case 'managerID':
+ case 'lastActorAccountID':
+ case 'ownerAccountID':
+ case 'total':
+ case 'unheldTotal':
+ case 'nonReimbursableTotal':
+ return validateNumber(value);
+ case 'chatType':
+ return validateConstantEnum(value, CONST.REPORT.CHAT_TYPE);
+ case 'stateNum':
+ return validateConstantEnum(value, CONST.REPORT.STATE_NUM);
+ case 'statusNum':
+ return validateConstantEnum(value, CONST.REPORT.STATUS_NUM);
+ case 'writeCapability':
+ return validateConstantEnum(value, CONST.REPORT.WRITE_CAPABILITIES);
+ case 'visibility':
+ return validateConstantEnum(value, CONST.REPORT.VISIBILITY);
+ case 'invoiceReceiver':
+ return validateObject>(value, {
+ type: 'string',
+ policyID: 'string',
+ accountID: 'string',
+ });
+ case 'lastActionType':
+ return validateConstantEnum(value, CONST.REPORT.ACTIONS.TYPE);
+ case 'participants':
+ return validateObject>(
+ value,
+ {
+ role: CONST.REPORT.ROLE,
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ pendingFields: 'object',
+ notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE,
+ },
+ 'number',
+ );
+ case 'errorFields':
+ return validateObject>(value, {}, 'string');
+ case 'privateNotes':
+ return validateObject>(
+ value,
+ {
+ note: 'string',
+ errors: 'string',
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ pendingFields: 'object',
+ },
+ 'number',
+ );
+ case 'pendingChatMembers':
+ return validateArray>(value, {
+ accountID: 'string',
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ errors: 'object',
+ });
+ case 'fieldList':
+ return validateObject>(
+ value,
+ {
+ fieldID: 'string',
+ type: 'string',
+ name: 'string',
+ keys: 'array',
+ values: 'array',
+ defaultValue: 'string',
+ orderWeight: 'number',
+ deletable: 'boolean',
+ value: 'string',
+ target: 'string',
+ externalIDs: 'array',
+ disabledOptions: 'array',
+ isTax: 'boolean',
+ externalID: 'string',
+ origin: 'string',
+ defaultExternalID: 'string',
+ },
+ 'string',
+ );
+ case 'permissions':
+ return validateArray<'constantEnum'>(value, CONST.REPORT.PERMISSIONS);
+ case 'tripData':
+ return validateObject>(value, {
+ startDate: 'string',
+ endDate: 'string',
+ tripID: 'string',
+ });
+ case 'pendingAction':
+ return validateConstantEnum(value, CONST.RED_BRICK_ROAD_PENDING_ACTION);
+ case 'pendingFields':
+ return validateObject>(value, {
+ description: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ privateNotes: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ currency: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ type: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ policyID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ reportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ avatarUrl: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ chatType: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ hasOutstandingChildRequest: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ hasOutstandingChildTask: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ isOwnPolicyExpenseChat: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ isPinned: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ lastMessageText: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ lastVisibleActionCreated: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ lastReadTime: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ lastReadSequenceNumber: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ lastMentionedTime: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ policyAvatar: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ policyName: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ oldPolicyName: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ hasParentAccess: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ isDeletedParentAction: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ reportName: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ reportActionID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ chatReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ stateNum: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ statusNum: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ writeCapability: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ visibility: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ invoiceReceiver: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ lastMessageTranslationKey: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ parentReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ parentReportActionID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ isOptimisticReport: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ lastVisibleActionLastModified: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ lastMessageHtml: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ lastActorAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ lastActionType: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ ownerAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ participants: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ total: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ unheldTotal: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ isWaitingOnBankAccount: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ isCancelledIOU: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ iouReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ preexistingReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ nonReimbursableTotal: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ isHidden: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ pendingChatMembers: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ fieldList: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ permissions: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ tripData: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ private_isArchived: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ avatar: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ partial: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ reimbursed: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ preview: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ });
}
-
- validateString(value);
}
/**
@@ -503,49 +645,608 @@ function validateReportDraftProperty(key: keyof Report, value: string) {
* @param value - value provided by the user
*/
function validateReportActionDraftProperty(key: keyof ReportAction, value: string) {
- if (REPORT_ACTION_REQUIRED_PROPERTIES.includes(key) && value === 'undefined') {
+ if (REPORT_ACTION_REQUIRED_PROPERTIES.includes(key) && isEmptyValue(value)) {
throw SyntaxError('debug.missingValue');
}
- if (REPORT_ACTION_NUMBER_PROPERTIES.includes(key)) {
- return validateNumber(value);
- }
- if (REPORT_ACTION_BOOLEAN_PROPERTIES.includes(key)) {
- return validateBoolean(value);
- }
- if (key === 'actionName') {
- return validateConstantEnum(value, CONST.REPORT.ACTIONS.TYPE);
- }
- if (key === 'childStatusNum') {
- return validateConstantEnum(value, CONST.REPORT.STATUS_NUM);
- }
- if (key === 'childStateNum') {
- return validateConstantEnum(value, CONST.REPORT.STATE_NUM);
- }
- if (key === 'childReportNotificationPreference') {
- return validateConstantEnum(value, CONST.REPORT.NOTIFICATION_PREFERENCE);
- }
- if (REPORT_ACTION_DATE_PROPERTIES.includes(key)) {
- return validateDate(value);
- }
- if (key === 'whisperedToAccountIDs') {
- return validateArray(value, 'number');
- }
- if (key === 'message') {
- return validateArray(value, {text: 'string', html: ['string', 'undefined'], type: 'string'});
+ switch (key) {
+ case 'reportID':
+ case 'reportActionID':
+ case 'parentReportID':
+ case 'childReportID':
+ case 'childReportName':
+ case 'childType':
+ case 'childOldestFourAccountIDs':
+ case 'childLastVisibleActionCreated':
+ case 'actor':
+ case 'avatar':
+ case 'childLastMoneyRequestComment':
+ case 'reportActionTimestamp':
+ case 'timestamp':
+ case 'error':
+ return validateString(value);
+ case 'actorAccountID':
+ case 'sequenceNumber':
+ case 'accountID':
+ case 'childCommenterCount':
+ case 'childVisibleActionCount':
+ case 'childManagerAccountID':
+ case 'childOwnerAccountID':
+ case 'childLastActorAccountID':
+ case 'childMoneyRequestCount':
+ case 'adminAccountID':
+ case 'delegateAccountID':
+ return validateNumber(value);
+ case 'isLoading':
+ case 'automatic':
+ case 'shouldShow':
+ case 'isFirstItem':
+ case 'isAttachmentOnly':
+ case 'isAttachmentWithText':
+ case 'isNewestReportAction':
+ case 'isOptimisticAction':
+ return validateBoolean(value);
+ case 'created':
+ case 'lastModified':
+ return validateDate(value);
+ case 'errors':
+ return validateObject>(value, {});
+ case 'pendingAction':
+ return validateConstantEnum(value, CONST.RED_BRICK_ROAD_PENDING_ACTION);
+ case 'pendingFields':
+ return validateObject>(value, {
+ reportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ reportActionID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ parentReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ errors: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ sequenceNumber: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ actionName: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ actorAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ actor: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ person: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ created: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ isLoading: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ avatar: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ automatic: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ shouldShow: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ childReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ childReportName: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ childType: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ accountID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ childOldestFourAccountIDs: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ childCommenterCount: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ childLastVisibleActionCreated: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ childVisibleActionCount: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ childManagerAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ childOwnerAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ childStatusNum: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ childStateNum: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ childLastMoneyRequestComment: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ childLastActorAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ childMoneyRequestCount: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ isFirstItem: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ isAttachmentOnly: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ isAttachmentWithText: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ receipt: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ lastModified: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ delegateAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ error: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ childRecentReceiptTransactionIDs: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ linkMetadata: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ childReportNotificationPreference: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ isNewestReportAction: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ isOptimisticAction: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ adminAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ whisperedToAccountIDs: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ reportActionTimestamp: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ timestamp: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ });
+ case 'actionName':
+ return validateConstantEnum(value, CONST.REPORT.ACTIONS.TYPE);
+ case 'person':
+ return validateArray>(value, {
+ type: 'string',
+ text: 'string',
+ style: 'string',
+ });
+ case 'childStatusNum':
+ return validateConstantEnum(value, CONST.REPORT.STATUS_NUM);
+ case 'childStateNum':
+ return validateConstantEnum(value, CONST.REPORT.STATE_NUM);
+ case 'receipt':
+ return validateObject>(value, {
+ state: 'string',
+ type: 'string',
+ name: 'string',
+ receiptID: 'string',
+ source: 'string',
+ filename: 'string',
+ reservationList: 'string',
+ });
+ case 'childRecentReceiptTransactionIDs':
+ return validateObject>(value, {}, 'string');
+ case 'linkMetadata':
+ return validateArray>(value, {
+ url: 'string',
+ image: 'object',
+ description: 'string',
+ title: 'string',
+ publisher: 'string',
+ logo: 'object',
+ });
+ case 'childReportNotificationPreference':
+ return validateConstantEnum(value, CONST.REPORT.NOTIFICATION_PREFERENCE);
+ case 'whisperedToAccountIDs':
+ return validateArray(value, 'number');
+ case 'message':
+ return unionValidation(
+ () =>
+ validateArray>(value, {
+ text: 'string',
+ html: 'string',
+ type: 'string',
+ isDeletedParentAction: 'boolean',
+ policyID: 'string',
+ reportID: 'string',
+ currency: 'string',
+ amount: 'number',
+ style: 'string',
+ target: 'string',
+ href: 'string',
+ iconUrl: 'string',
+ isEdited: 'boolean',
+ isReversedTransaction: 'boolean',
+ whisperedTo: 'array',
+ moderationDecision: 'object',
+ translationKey: 'string',
+ taskReportID: 'string',
+ cancellationReason: 'string',
+ expenseReportID: 'string',
+ resolution: {
+ ...CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION,
+ ...CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION,
+ },
+ deleted: 'string',
+ }),
+ () =>
+ validateObject>(value, {
+ html: 'string',
+ text: 'string',
+ amount: 'string',
+ currency: 'string',
+ type: 'string',
+ policyID: 'string',
+ reportID: 'string',
+ isDeletedParentAction: 'boolean',
+ target: 'string',
+ style: 'string',
+ href: 'string',
+ iconUrl: 'boolean',
+ isEdited: 'boolean',
+ isReversedTransaction: 'boolean',
+ whisperedTo: 'array',
+ moderationDecision: 'object',
+ translationKey: 'string',
+ taskReportID: 'string',
+ cancellationReason: 'string',
+ expenseReportID: 'string',
+ resolution: {
+ ...CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION,
+ ...CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION,
+ },
+ deleted: 'string',
+ }),
+ );
+ case 'originalMessage':
+ return validateObject>(value, {});
+ case 'previousMessage':
+ return unionValidation(
+ () =>
+ validateObject>(value, {
+ html: 'string',
+ text: 'string',
+ amount: 'string',
+ currency: 'string',
+ type: 'string',
+ policyID: 'string',
+ reportID: 'string',
+ style: 'string',
+ target: 'string',
+ href: 'string',
+ iconUrl: 'string',
+ isEdited: 'boolean',
+ isDeletedParentAction: 'boolean',
+ isReversedTransaction: 'boolean',
+ whisperedTo: 'array',
+ moderationDecision: 'string',
+ translationKey: 'string',
+ taskReportID: 'string',
+ cancellationReason: 'string',
+ expenseReportID: 'string',
+ resolution: 'string',
+ deleted: 'string',
+ }),
+ () =>
+ validateArray>(value, {
+ reportID: 'string',
+ html: 'string',
+ text: 'string',
+ amount: 'string',
+ currency: 'string',
+ type: 'string',
+ policyID: 'string',
+ style: 'string',
+ target: 'string',
+ href: 'string',
+ iconUrl: 'string',
+ isEdited: 'string',
+ isDeletedParentAction: 'string',
+ isReversedTransaction: 'string',
+ whisperedTo: 'string',
+ moderationDecision: 'string',
+ translationKey: 'string',
+ taskReportID: 'string',
+ cancellationReason: 'string',
+ expenseReportID: 'string',
+ resolution: 'string',
+ deleted: 'string',
+ }),
+ );
}
- if (key === 'person') {
- return validateArray(value, {});
+}
+
+/**
+ * Validates if a property of Transaction is of the expected type
+ *
+ * @param key - property key
+ * @param value - value provided by the user
+ */
+function validateTransactionDraftProperty(key: keyof Transaction, value: string) {
+ if (TRANSACTION_REQUIRED_PROPERTIES.includes(key) && isEmptyValue(value)) {
+ throw SyntaxError('debug.missingValue');
}
- if (key === 'errors') {
- return validateObject(value, {});
+ switch (key) {
+ case 'reportID':
+ case 'currency':
+ case 'tag':
+ case 'category':
+ case 'merchant':
+ case 'taxCode':
+ case 'filename':
+ case 'modifiedCurrency':
+ case 'modifiedMerchant':
+ case 'transactionID':
+ case 'parentTransactionID':
+ case 'originalCurrency':
+ case 'actionableWhisperReportActionID':
+ case 'linkedTrackedExpenseReportID':
+ case 'bank':
+ case 'cardName':
+ case 'cardNumber':
+ return validateString(value);
+ case 'created':
+ case 'modifiedCreated':
+ return validateDate(value);
+ case 'isLoading':
+ case 'billable':
+ case 'reimbursable':
+ case 'participantsAutoAssigned':
+ case 'isFromGlobalCreate':
+ case 'hasEReceipt':
+ case 'shouldShowOriginalAmount':
+ case 'managedCard':
+ return validateBoolean(value);
+ case 'amount':
+ case 'taxAmount':
+ case 'modifiedAmount':
+ case 'cardID':
+ case 'originalAmount':
+ return validateNumber(value);
+ case 'iouRequestType':
+ return validateConstantEnum(value, CONST.IOU.REQUEST_TYPE);
+ case 'participants':
+ return validateArray>(value, {
+ accountID: 'number',
+ login: 'string',
+ displayName: 'string',
+ isPolicyExpenseChat: 'boolean',
+ isInvoiceRoom: 'boolean',
+ isOwnPolicyExpenseChat: 'boolean',
+ chatType: CONST.REPORT.CHAT_TYPE,
+ reportID: 'string',
+ policyID: 'string',
+ selected: 'boolean',
+ searchText: 'string',
+ alternateText: 'string',
+ firstName: 'string',
+ keyForList: 'string',
+ lastName: 'string',
+ phoneNumber: 'string',
+ text: 'string',
+ isSelected: 'boolean',
+ isSelfDM: 'boolean',
+ isSender: 'boolean',
+ iouType: CONST.IOU.TYPE,
+ ownerAccountID: 'number',
+ icons: 'array',
+ item: 'string',
+ });
+ case 'errors':
+ return validateObject>(value, {});
+ case 'errorFields':
+ return validateObject>(
+ value,
+ {
+ route: 'object',
+ },
+ 'string',
+ );
+ case 'pendingAction':
+ return validateConstantEnum(value, CONST.RED_BRICK_ROAD_PENDING_ACTION);
+ case 'pendingFields':
+ return validateObject>(
+ value,
+ {
+ comment: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ hold: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ isLoading: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ type: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ customUnit: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ source: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ originalTransactionID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ splits: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ dismissedViolations: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ customUnitID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ customUnitRateID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ quantity: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ name: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ defaultP2PRate: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ distanceUnit: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ attendees: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ amount: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ taxAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ taxCode: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ billable: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ category: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ created: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ currency: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ errors: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ filename: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ iouRequestType: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ modifiedAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ modifiedAttendees: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ modifiedCreated: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ modifiedCurrency: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ modifiedMerchant: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ modifiedWaypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ participantsAutoAssigned: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ participants: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ receipt: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ reportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ routes: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ transactionID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ tag: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ isFromGlobalCreate: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ taxRate: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ parentTransactionID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ reimbursable: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ cardID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ status: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ hasEReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ mccGroup: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ modifiedMCCGroup: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ originalAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ originalCurrency: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ splitShares: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ splitPayerAccountIDs: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ shouldShowOriginalAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ actionableWhisperReportActionID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ linkedTrackedExpenseReportAction: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ linkedTrackedExpenseReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ bank: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ cardName: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ cardNumber: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ managedCard: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ },
+ 'string',
+ );
+ case 'receipt':
+ return validateObject>(value, {
+ type: 'string',
+ source: 'string',
+ name: 'string',
+ filename: 'string',
+ state: CONST.IOU.RECEIPT_STATE,
+ receiptID: 'number',
+ reservationList: 'array',
+ });
+ case 'taxRate':
+ return validateObject>(value, {
+ keyForList: 'string',
+ text: 'string',
+ data: 'object',
+ });
+ case 'status':
+ return validateConstantEnum(value, CONST.TRANSACTION.STATUS);
+ case 'comment':
+ return validateObject>(value, {
+ comment: 'string',
+ hold: 'string',
+ waypoints: 'object',
+ isLoading: 'boolean',
+ type: CONST.TRANSACTION.TYPE,
+ customUnit: 'object',
+ source: 'string',
+ originalTransactionID: 'string',
+ splits: 'array',
+ dismissedViolations: 'object',
+ });
+ case 'attendees':
+ return validateArray>(value, {
+ email: 'string',
+ displayName: 'string',
+ avatarUrl: 'string',
+ accountID: 'number',
+ text: 'string',
+ login: 'string',
+ searchText: 'string',
+ selected: 'boolean',
+ iouType: CONST.IOU.TYPE,
+ reportID: 'string',
+ });
+ case 'modifiedAttendees':
+ return validateArray>(value, {
+ email: 'string',
+ displayName: 'string',
+ avatarUrl: 'string',
+ accountID: 'number',
+ text: 'string',
+ login: 'string',
+ searchText: 'string',
+ selected: 'boolean',
+ iouType: CONST.IOU.TYPE,
+ reportID: 'string',
+ });
+ case 'modifiedWaypoints':
+ return validateObject>(
+ value,
+ {
+ name: 'string',
+ address: 'string',
+ lat: 'number',
+ lng: 'number',
+ keyForList: 'string',
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ street: 'string',
+ city: 'string',
+ state: 'string',
+ zipCode: 'string',
+ country: 'string',
+ street2: 'string',
+ },
+ 'string',
+ );
+ case 'routes':
+ return validateObject>(
+ value,
+ {
+ distance: 'number',
+ geometry: 'object',
+ },
+ 'string',
+ );
+ case 'mccGroup':
+ return validateConstantEnum(value, CONST.MCC_GROUPS);
+ case 'modifiedMCCGroup':
+ return validateConstantEnum(value, CONST.MCC_GROUPS);
+ case 'splitShares':
+ return validateObject>(
+ value,
+ {
+ amount: 'number',
+ isModified: 'boolean',
+ },
+ 'number',
+ );
+ case 'splitPayerAccountIDs':
+ return validateArray(value, 'number');
+ case 'linkedTrackedExpenseReportAction':
+ return validateObject(value, {
+ accountID: 'number',
+ message: 'string',
+ created: 'string',
+ error: 'string',
+ avatar: 'string',
+ receipt: 'object',
+ reportID: 'string',
+ automatic: 'boolean',
+ reportActionID: 'string',
+ parentReportID: 'string',
+ errors: 'object',
+ isLoading: 'boolean',
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION,
+ pendingFields: 'object',
+ sequenceNumber: 'number',
+ actionName: CONST.REPORT.ACTIONS.TYPE,
+ actorAccountID: 'number',
+ actor: 'string',
+ person: 'array',
+ shouldShow: 'boolean',
+ childReportID: 'string',
+ childReportName: 'string',
+ childType: 'string',
+ childOldestFourAccountIDs: 'string',
+ childCommenterCount: 'number',
+ childLastVisibleActionCreated: 'string',
+ childVisibleActionCount: 'number',
+ childManagerAccountID: 'number',
+ childOwnerAccountID: 'number',
+ childStatusNum: CONST.REPORT.STATUS_NUM,
+ childStateNum: CONST.REPORT.STATE_NUM,
+ childLastMoneyRequestComment: 'string',
+ childLastActorAccountID: 'number',
+ childMoneyRequestCount: 'number',
+ isFirstItem: 'boolean',
+ isAttachmentOnly: 'boolean',
+ isAttachmentWithText: 'boolean',
+ lastModified: 'string',
+ delegateAccountID: 'number',
+ childRecentReceiptTransactionIDs: 'object',
+ linkMetadata: 'array',
+ childReportNotificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE,
+ isNewestReportAction: 'boolean',
+ isOptimisticAction: 'boolean',
+ adminAccountID: 'number',
+ whisperedToAccountIDs: 'array',
+ reportActionTimestamp: 'string',
+ timestamp: 'string',
+ originalMessage: 'object',
+ previousMessage: 'object',
+ });
}
- if (key === 'originalMessage') {
- return validateObject(value, {});
+}
+
+function validateTransactionViolationDraftProperty(key: keyof TransactionViolation, value: string) {
+ if (TRANSACTION_VIOLATION_REQUIRED_PROPERTIES.includes(key) && isEmptyValue(value)) {
+ throw SyntaxError('debug.missingValue');
}
- if (key === 'childRecentReceiptTransactionIDs') {
- return validateObject(value, {}, 'string');
+ switch (key) {
+ case 'type':
+ return validateConstantEnum(value, CONST.VIOLATION_TYPES);
+
+ case 'name':
+ return validateConstantEnum(value, CONST.VIOLATIONS);
+
+ case 'data':
+ return validateObject>(value, {
+ rejectedBy: 'string',
+ rejectReason: 'string',
+ formattedLimit: 'string',
+ surcharge: 'number',
+ invoiceMarkup: 'number',
+ maxAge: 'number',
+ tagName: 'string',
+ category: 'string',
+ brokenBankConnection: 'boolean',
+ isAdmin: 'boolean',
+ email: 'string',
+ isTransactionOlderThan7Days: 'boolean',
+ member: 'string',
+ taxName: 'string',
+ tagListIndex: 'number',
+ tagListName: 'string',
+ errorIndexes: 'array',
+ pendingPattern: 'string',
+ type: CONST.MODIFIED_AMOUNT_VIOLATION_DATA,
+ displayPercentVariance: 'number',
+ duplicates: 'array',
+ rterType: CONST.RTER_VIOLATION_TYPES,
+ tooltip: 'string',
+ });
+ case 'showInReview':
+ return validateBoolean(value);
}
- validateString(value);
}
/**
@@ -562,7 +1263,7 @@ function validateReportActionJSON(json: string) {
});
Object.entries(parsedReportAction).forEach(([key, val]) => {
try {
- if (val !== 'undefined' && REPORT_ACTION_NUMBER_PROPERTIES.includes(key as keyof ReportAction) && typeof val !== 'number') {
+ if (!isEmptyValue(val as string) && REPORT_ACTION_NUMBER_PROPERTIES.includes(key as keyof ReportAction) && typeof val !== 'number') {
throw new NumberError();
}
validateReportActionDraftProperty(key as keyof ReportAction, onyxDataToString(val));
@@ -573,6 +1274,25 @@ function validateReportActionJSON(json: string) {
});
}
+function validateTransactionViolationJSON(json: string) {
+ const parsedTransactionViolation = parseJSON(json) as TransactionViolation;
+ TRANSACTION_VIOLATION_REQUIRED_PROPERTIES.forEach((key) => {
+ if (parsedTransactionViolation[key] !== undefined) {
+ return;
+ }
+
+ throw new SyntaxError('debug.missingProperty', {cause: {propertyName: key}});
+ });
+ Object.entries(parsedTransactionViolation).forEach(([key, val]) => {
+ try {
+ validateTransactionViolationDraftProperty(key as keyof TransactionViolation, onyxDataToString(val));
+ } catch (e) {
+ const {cause} = e as SyntaxError & {cause: {expectedValues: string}};
+ throw new SyntaxError('debug.invalidProperty', {cause: {propertyName: key, expectedType: cause.expectedValues}});
+ }
+ });
+}
+
/**
* Gets the reason for showing LHN row
*/
@@ -648,6 +1368,16 @@ function getReasonAndReportActionForRBRInLHNRow(report: Report, reportActions: O
return null;
}
+function getTransactionID(report: OnyxEntry, reportActions: OnyxEntry) {
+ const transactionID = TransactionUtils.getTransactionID(report?.reportID ?? '-1');
+
+ return Number(transactionID) > 0
+ ? transactionID
+ : Object.values(reportActions ?? {})
+ .map((reportAction) => ReportActionsUtils.getLinkedTransactionID(reportAction))
+ .find(Boolean);
+}
+
const DebugUtils = {
stringifyJSON,
onyxDataToDraftData,
@@ -664,12 +1394,17 @@ const DebugUtils = {
validateString,
validateReportDraftProperty,
validateReportActionDraftProperty,
+ validateTransactionDraftProperty,
+ validateTransactionViolationDraftProperty,
validateReportActionJSON,
+ validateTransactionViolationJSON,
getReasonForShowingRowInLHN,
getReasonAndReportActionForGBRInLHNRow,
getReasonAndReportActionForRBRInLHNRow,
+ getTransactionID,
REPORT_ACTION_REQUIRED_PROPERTIES,
REPORT_REQUIRED_PROPERTIES,
+ TRANSACTION_REQUIRED_PROPERTIES,
};
export type {ObjectType, OnyxDataType};
diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.tsx
similarity index 89%
rename from src/libs/EmojiUtils.ts
rename to src/libs/EmojiUtils.tsx
index f9fb5f226280..a8fb6f7a92b3 100644
--- a/src/libs/EmojiUtils.ts
+++ b/src/libs/EmojiUtils.tsx
@@ -1,9 +1,12 @@
import {Str} from 'expensify-common';
import lodashSortBy from 'lodash/sortBy';
+import React from 'react';
+import type {StyleProp, TextStyle} from 'react-native';
import Onyx from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import * as Emojis from '@assets/emojis';
import type {Emoji, HeaderEmoji, PickerEmojis} from '@assets/emojis/types';
+import Text from '@components/Text';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {FrequentlyUsedEmoji, Locale} from '@src/types/onyx';
@@ -19,6 +22,10 @@ type EmojiPickerListItem = EmojiSpacer | Emoji | HeaderEmoji;
type EmojiPickerList = EmojiPickerListItem[];
type ReplacedEmoji = {text: string; emojis: Emoji[]; cursorPosition?: number};
type EmojiTrieModule = {default: typeof EmojiTrie};
+type TextWithEmoji = {
+ text: string;
+ isEmoji: boolean;
+};
const findEmojiByName = (name: string): Emoji => Emojis.emojiNameTable[name];
@@ -151,7 +158,7 @@ function trimEmojiUnicode(emojiCode: string): string {
*/
function isFirstLetterEmoji(message: string): boolean {
const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', '');
- const match = trimmedMessage.match(CONST.REGEX.EMOJIS);
+ const match = trimmedMessage.match(CONST.REGEX.ALL_EMOJIS);
if (!match) {
return false;
@@ -165,7 +172,7 @@ function isFirstLetterEmoji(message: string): boolean {
*/
function containsOnlyEmojis(message: string): boolean {
const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', '');
- const match = trimmedMessage.match(CONST.REGEX.EMOJIS);
+ const match = trimmedMessage.match(CONST.REGEX.ALL_EMOJIS);
if (!match) {
return false;
@@ -288,7 +295,7 @@ function extractEmojis(text: string): Emoji[] {
}
// Parse Emojis including skin tones - Eg: ['👩🏻', '👩🏻', '👩🏼', '👩🏻', '👩🏼', '👩']
- const parsedEmojis = text.match(CONST.REGEX.EMOJIS);
+ const parsedEmojis = text.match(CONST.REGEX.ALL_EMOJIS);
if (!parsedEmojis) {
return [];
@@ -598,6 +605,75 @@ function getSpacersIndexes(allEmojis: EmojiPickerList): number[] {
return spacersIndexes;
}
+/** Splits the text with emojis into array if emojis exist in the text */
+function splitTextWithEmojis(text = ''): TextWithEmoji[] {
+ if (!text) {
+ return [];
+ }
+
+ const doesTextContainEmojis = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')).test(text);
+
+ if (!doesTextContainEmojis) {
+ return [];
+ }
+
+ // The regex needs to be cloned because `exec()` is a stateful operation and maintains the state inside
+ // the regex variable itself, so we must have an independent instance for each function's call.
+ const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g'));
+
+ const splitText: TextWithEmoji[] = [];
+ let regexResult: RegExpExecArray | null;
+ let lastMatchIndexEnd = 0;
+
+ do {
+ regexResult = emojisRegex.exec(text);
+
+ if (regexResult?.indices) {
+ const matchIndexStart = regexResult.indices[0][0];
+ const matchIndexEnd = regexResult.indices[0][1];
+
+ if (matchIndexStart > lastMatchIndexEnd) {
+ splitText.push({
+ text: text.slice(lastMatchIndexEnd, matchIndexStart),
+ isEmoji: false,
+ });
+ }
+
+ splitText.push({
+ text: text.slice(matchIndexStart, matchIndexEnd),
+ isEmoji: true,
+ });
+
+ lastMatchIndexEnd = matchIndexEnd;
+ }
+ } while (regexResult !== null);
+
+ if (lastMatchIndexEnd < text.length) {
+ splitText.push({
+ text: text.slice(lastMatchIndexEnd, text.length),
+ isEmoji: false,
+ });
+ }
+
+ return splitText;
+}
+
+function getProcessedText(processedTextArray: TextWithEmoji[], style: StyleProp): Array {
+ return processedTextArray.map(({text, isEmoji}, index) =>
+ isEmoji ? (
+
+ {text}
+
+ ) : (
+ text
+ ),
+ );
+}
+
export type {HeaderIndice, EmojiPickerList, EmojiSpacer, EmojiPickerListItem};
export {
@@ -605,6 +681,7 @@ export {
findEmojiByCode,
getEmojiName,
getLocalizedEmojiName,
+ getProcessedText,
getHeaderEmojis,
mergeEmojisWithFrequentlyUsedEmojis,
containsOnlyEmojis,
@@ -623,4 +700,5 @@ export {
hasAccountIDEmojiReacted,
getRemovedSkinToneEmoji,
getSpacersIndexes,
+ splitTextWithEmojis,
};
diff --git a/src/libs/Environment/Environment.ts b/src/libs/Environment/Environment.ts
index 204e78aa5458..23c7b360dc63 100644
--- a/src/libs/Environment/Environment.ts
+++ b/src/libs/Environment/Environment.ts
@@ -2,6 +2,7 @@ import Config from 'react-native-config';
import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
import getEnvironment from './getEnvironment';
+import type Environment from './getEnvironment/types';
const ENVIRONMENT_URLS = {
[CONST.ENVIRONMENT.DEV]: CONST.DEV_NEW_EXPENSIFY_URL + CONFIG.DEV_PORT,
@@ -47,6 +48,13 @@ function getEnvironmentURL(): Promise {
});
}
+/**
+ * Given the environment get the corresponding oldDot URL
+ */
+function getOldDotURLFromEnvironment(environment: Environment): string {
+ return OLDDOT_ENVIRONMENT_URLS[environment];
+}
+
/**
* Get the corresponding oldDot URL based on the environment we are in
*/
@@ -54,4 +62,4 @@ function getOldDotEnvironmentURL(): Promise {
return getEnvironment().then((environment) => OLDDOT_ENVIRONMENT_URLS[environment]);
}
-export {getEnvironment, isInternalTestBuild, isDevelopment, isProduction, getEnvironmentURL, getOldDotEnvironmentURL};
+export {getEnvironment, isInternalTestBuild, isDevelopment, isProduction, getEnvironmentURL, getOldDotEnvironmentURL, getOldDotURLFromEnvironment};
diff --git a/src/libs/Middleware/Reauthentication.ts b/src/libs/Middleware/Reauthentication.ts
index 09a01e821cb2..9d95fa8af873 100644
--- a/src/libs/Middleware/Reauthentication.ts
+++ b/src/libs/Middleware/Reauthentication.ts
@@ -1,33 +1,61 @@
+import redirectToSignIn from '@libs/actions/SignInRedirect';
import * as Authentication from '@libs/Authentication';
import Log from '@libs/Log';
import * as MainQueue from '@libs/Network/MainQueue';
import * as NetworkStore from '@libs/Network/NetworkStore';
+import type {RequestError} from '@libs/Network/SequentialQueue';
import NetworkConnection from '@libs/NetworkConnection';
import * as Request from '@libs/Request';
+import RequestThrottle from '@libs/RequestThrottle';
import CONST from '@src/CONST';
import type Middleware from './types';
// We store a reference to the active authentication request so that we are only ever making one request to authenticate at a time.
let isAuthenticating: Promise | null = null;
+const reauthThrottle = new RequestThrottle('Re-authentication');
+
function reauthenticate(commandName?: string): Promise {
if (isAuthenticating) {
return isAuthenticating;
}
- isAuthenticating = Authentication.reauthenticate(commandName)
+ isAuthenticating = retryReauthenticate(commandName)
.then((response) => {
- isAuthenticating = null;
return response;
})
.catch((error) => {
- isAuthenticating = null;
throw error;
+ })
+ .finally(() => {
+ isAuthenticating = null;
});
return isAuthenticating;
}
+function retryReauthenticate(commandName?: string): Promise {
+ return Authentication.reauthenticate(commandName).catch((error: RequestError) => {
+ return reauthThrottle
+ .sleep(error, 'Authenticate')
+ .then(() => retryReauthenticate(commandName))
+ .catch(() => {
+ NetworkStore.setIsAuthenticating(false);
+ Log.hmmm('Redirecting to Sign In because we failed to reauthenticate after multiple attempts', {error});
+ redirectToSignIn('passwordForm.error.fallback');
+ });
+ });
+}
+
+// Used in tests to reset the reauthentication state
+function resetReauthentication(): void {
+ // Resets the authentication state flag to allow new reauthentication flows to start fresh
+ isAuthenticating = null;
+
+ // Clears any pending reauth timeouts set by reauthThrottle.sleep()
+ reauthThrottle.clear();
+}
+
const Reauthentication: Middleware = (response, request, isFromSequentialQueue) =>
response
.then((data) => {
@@ -118,3 +146,4 @@ const Reauthentication: Middleware = (response, request, isFromSequentialQueue)
});
export default Reauthentication;
+export {reauthenticate, resetReauthentication, reauthThrottle};
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index 8c0d45e8c313..9295281755e5 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -570,6 +570,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/RulesMaxExpenseAmountPage').default,
[SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE]: () => require('../../../../pages/workspace/rules/RulesMaxExpenseAgePage').default,
[SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT]: () => require('../../../../pages/workspace/rules/RulesBillableDefaultPage').default,
+ [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemSettingsPage').default,
});
const EnablePaymentsStackNavigator = createModalStackNavigator({
@@ -666,6 +667,9 @@ const DebugModalStackNavigator = createModalStackNavigator({
[SCREENS.DEBUG.REPORT_ACTION_CREATE]: () => require('../../../../pages/Debug/ReportAction/DebugReportActionCreatePage').default,
[SCREENS.DEBUG.DETAILS_CONSTANT_PICKER_PAGE]: () => require('../../../../pages/Debug/DebugDetailsConstantPickerPage').default,
[SCREENS.DEBUG.DETAILS_DATE_TIME_PICKER_PAGE]: () => require('../../../../pages/Debug/DebugDetailsDateTimePickerPage').default,
+ [SCREENS.DEBUG.TRANSACTION]: () => require('../../../../pages/Debug/Transaction/DebugTransactionPage').default,
+ [SCREENS.DEBUG.TRANSACTION_VIOLATION_CREATE]: () => require('../../../../pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage').default,
+ [SCREENS.DEBUG.TRANSACTION_VIOLATION]: () => require('../../../../pages/Debug/TransactionViolation/DebugTransactionViolationPage').default,
});
export {
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 d19b23f5c00c..7ae8fb43178a 100755
--- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
@@ -245,6 +245,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE,
SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT,
],
+ [SCREENS.WORKSPACE.PER_DIEM]: [SCREENS.WORKSPACE.PER_DIEM_SETTINGS],
};
export default FULL_SCREEN_TO_RHP_MAPPING;
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index e0cd018086bd..476711c7c116 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -944,6 +944,9 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT]: {
path: ROUTES.RULES_BILLABLE_DEFAULT.route,
},
+ [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: {
+ path: ROUTES.WORKSPACE_PER_DIEM_SETTINGS.route,
+ },
},
},
[SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: {
@@ -1410,6 +1413,42 @@ const config: LinkingOptions['config'] = {
path: ROUTES.DETAILS_DATE_TIME_PICKER_PAGE.route,
exact: true,
},
+ [SCREENS.DEBUG.TRANSACTION]: {
+ path: ROUTES.DEBUG_TRANSACTION.route,
+ exact: true,
+ screens: {
+ details: {
+ path: ROUTES.DEBUG_TRANSACTION_TAB_DETAILS.route,
+ exact: true,
+ },
+ json: {
+ path: ROUTES.DEBUG_TRANSACTION_TAB_JSON.route,
+ exact: true,
+ },
+ violations: {
+ path: ROUTES.DEBUG_TRANSACTION_TAB_VIOLATIONS.route,
+ exact: true,
+ },
+ },
+ },
+ [SCREENS.DEBUG.TRANSACTION_VIOLATION_CREATE]: {
+ path: ROUTES.DEBUG_TRANSACTION_VIOLATION_CREATE.route,
+ exact: true,
+ },
+ [SCREENS.DEBUG.TRANSACTION_VIOLATION]: {
+ path: ROUTES.DEBUG_TRANSACTION_VIOLATION.route,
+ exact: true,
+ screens: {
+ details: {
+ path: ROUTES.DEBUG_TRANSACTION_VIOLATION_TAB_DETAILS.route,
+ exact: true,
+ },
+ json: {
+ path: ROUTES.DEBUG_TRANSACTION_VIOLATION_TAB_JSON.route,
+ exact: true,
+ },
+ },
+ },
},
},
},
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 798e77d86ecc..5877c9c10218 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -898,6 +898,9 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: {
+ policyID: string;
+ };
} & ReimbursementAccountNavigatorParamList;
type NewChatNavigatorParamList = {
@@ -1626,8 +1629,10 @@ type DebugParamList = {
reportID: string;
};
[SCREENS.DEBUG.DETAILS_CONSTANT_PICKER_PAGE]: {
+ formType: string;
fieldName: string;
fieldValue?: string;
+ policyID?: string;
backTo?: string;
};
[SCREENS.DEBUG.DETAILS_DATE_TIME_PICKER_PAGE]: {
@@ -1635,6 +1640,16 @@ type DebugParamList = {
fieldValue?: string;
backTo?: string;
};
+ [SCREENS.DEBUG.TRANSACTION]: {
+ transactionID: string;
+ };
+ [SCREENS.DEBUG.TRANSACTION_VIOLATION_CREATE]: {
+ transactionID: string;
+ };
+ [SCREENS.DEBUG.TRANSACTION_VIOLATION]: {
+ transactionID: string;
+ index: string;
+ };
};
type RootStackParamList = PublicScreensParamList & AuthScreensParamList & LeftModalNavigatorParamList;
diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts
index 3f4da20c16e1..b57eb2c8cecc 100644
--- a/src/libs/Network/SequentialQueue.ts
+++ b/src/libs/Network/SequentialQueue.ts
@@ -2,7 +2,7 @@ import Onyx from 'react-native-onyx';
import * as ActiveClientManager from '@libs/ActiveClientManager';
import Log from '@libs/Log';
import * as Request from '@libs/Request';
-import * as RequestThrottle from '@libs/RequestThrottle';
+import RequestThrottle from '@libs/RequestThrottle';
import * as PersistedRequests from '@userActions/PersistedRequests';
import * as QueuedOnyxUpdates from '@userActions/QueuedOnyxUpdates';
import CONST from '@src/CONST';
@@ -28,6 +28,7 @@ resolveIsReadyPromise?.();
let isSequentialQueueRunning = false;
let currentRequestPromise: Promise | null = null;
let isQueuePaused = false;
+const sequentialQueueRequestThrottle = new RequestThrottle('SequentialQueue');
/**
* Puts the queue into a paused state so that no requests will be processed
@@ -99,7 +100,7 @@ function process(): Promise {
Log.info('[SequentialQueue] Removing persisted request because it was processed successfully.', false, {request: requestToProcess});
PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess);
- RequestThrottle.clear();
+ sequentialQueueRequestThrottle.clear();
return process();
})
.catch((error: RequestError) => {
@@ -108,17 +109,18 @@ function process(): Promise {
if (error.name === CONST.ERROR.REQUEST_CANCELLED || error.message === CONST.ERROR.DUPLICATE_RECORD) {
Log.info("[SequentialQueue] Removing persisted request because it failed and doesn't need to be retried.", false, {error, request: requestToProcess});
PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess);
- RequestThrottle.clear();
+ sequentialQueueRequestThrottle.clear();
return process();
}
PersistedRequests.rollbackOngoingRequest();
- return RequestThrottle.sleep(error, requestToProcess.command)
+ return sequentialQueueRequestThrottle
+ .sleep(error, requestToProcess.command)
.then(process)
.catch(() => {
Onyx.update(requestToProcess.failureData ?? []);
Log.info('[SequentialQueue] Removing persisted request because it failed too many times.', false, {error, request: requestToProcess});
PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess);
- RequestThrottle.clear();
+ sequentialQueueRequestThrottle.clear();
return process();
});
});
@@ -271,5 +273,19 @@ function waitForIdle(): Promise {
return isReadyPromise;
}
-export {flush, getCurrentRequest, isRunning, isPaused, push, waitForIdle, pause, unpause, process};
+/**
+ * Clear any pending requests during test runs
+ * This is to prevent previous requests interfering with other tests
+ */
+function resetQueue(): void {
+ isSequentialQueueRunning = false;
+ currentRequestPromise = null;
+ isQueuePaused = false;
+ isReadyPromise = new Promise((resolve) => {
+ resolveIsReadyPromise = resolve;
+ });
+ resolveIsReadyPromise?.();
+}
+
+export {flush, getCurrentRequest, isRunning, isPaused, push, waitForIdle, pause, unpause, process, resetQueue, sequentialQueueRequestThrottle};
export type {RequestError};
diff --git a/src/libs/Network/index.ts b/src/libs/Network/index.ts
index 2adb4a2da4c2..4d27f75ab1a7 100644
--- a/src/libs/Network/index.ts
+++ b/src/libs/Network/index.ts
@@ -6,14 +6,28 @@ import pkg from '../../../package.json';
import * as MainQueue from './MainQueue';
import * as SequentialQueue from './SequentialQueue';
+// React Native uses a number for the timer id, but Web/NodeJS uses a Timeout object
+let processQueueInterval: NodeJS.Timeout | number;
+
// We must wait until the ActiveClientManager is ready so that we ensure only the "leader" tab processes any persisted requests
ActiveClientManager.isReady().then(() => {
SequentialQueue.flush();
// Start main queue and process once every n ms delay
- setInterval(MainQueue.process, CONST.NETWORK.PROCESS_REQUEST_DELAY_MS);
+ processQueueInterval = setInterval(MainQueue.process, CONST.NETWORK.PROCESS_REQUEST_DELAY_MS);
});
+/**
+ * Clear any existing intervals during test runs
+ * This is to prevent previous intervals interfering with other tests
+ */
+function clearProcessQueueInterval() {
+ if (!processQueueInterval) {
+ return;
+ }
+ clearInterval(processQueueInterval);
+}
+
/**
* Perform a queued post request
*/
@@ -55,7 +69,4 @@ function post(command: string, data: Record = {}, type = CONST.
});
}
-export {
- // eslint-disable-next-line import/prefer-default-export
- post,
-};
+export {post, clearProcessQueueInterval};
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index bdf760728576..dc1c07388293 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -29,6 +29,7 @@ import type {
Tenant,
} from '@src/types/onyx/Policy';
import type PolicyEmployee from '@src/types/onyx/PolicyEmployee';
+import type {SearchPolicy} from '@src/types/onyx/SearchResults';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import {hasSynchronizationErrorMessage} from './actions/connections';
import {getCategoryApproverRule} from './CategoryUtils';
@@ -184,7 +185,7 @@ function getPolicyBrickRoadIndicatorStatus(policy: OnyxEntry, isConnecti
return undefined;
}
-function getPolicyRole(policy: OnyxInputOrEntry, currentUserLogin: string | undefined) {
+function getPolicyRole(policy: OnyxInputOrEntry | SearchPolicy, currentUserLogin: string | undefined) {
return policy?.role ?? policy?.employeeList?.[currentUserLogin ?? '-1']?.role;
}
@@ -218,7 +219,7 @@ const isUserPolicyAdmin = (policy: OnyxInputOrEntry, login?: string) =>
/**
* Checks if the current user is an admin of the policy.
*/
-const isPolicyAdmin = (policy: OnyxInputOrEntry, currentUserLogin?: string): boolean => getPolicyRole(policy, currentUserLogin) === CONST.POLICY.ROLE.ADMIN;
+const isPolicyAdmin = (policy: OnyxInputOrEntry | SearchPolicy, currentUserLogin?: string): boolean => getPolicyRole(policy, currentUserLogin) === CONST.POLICY.ROLE.ADMIN;
/**
* Checks if the current user is of the role "user" on the policy.
@@ -379,7 +380,7 @@ function isPendingDeletePolicy(policy: OnyxEntry): boolean {
return policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
}
-function isPaidGroupPolicy(policy: OnyxEntry): boolean {
+function isPaidGroupPolicy(policy: OnyxEntry | SearchPolicy): boolean {
return policy?.type === CONST.POLICY.TYPE.TEAM || policy?.type === CONST.POLICY.TYPE.CORPORATE;
}
@@ -404,7 +405,7 @@ function isTaxTrackingEnabled(isPolicyExpenseChat: boolean, policy: OnyxEntry): boolean {
+function isInstantSubmitEnabled(policy: OnyxInputOrEntry | SearchPolicy): boolean {
return policy?.autoReporting === true && policy?.autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT;
}
@@ -434,7 +435,7 @@ function getCorrectedAutoReportingFrequency(policy: OnyxInputOrEntry): V
/**
* Checks if policy's approval mode is "optional", a.k.a. "Submit & Close"
*/
-function isSubmitAndClose(policy: OnyxInputOrEntry): boolean {
+function isSubmitAndClose(policy: OnyxInputOrEntry | SearchPolicy): boolean {
return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL;
}
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 2acfde557d5d..40749c525a38 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -54,6 +54,7 @@ import type {Status} from '@src/types/onyx/PersonalDetails';
import type {ConnectionName} from '@src/types/onyx/Policy';
import type {NotificationPreference, Participants, PendingChatMember, Participant as ReportParticipant} from '@src/types/onyx/Report';
import type {Message, OldDotReportAction, ReportActions} from '@src/types/onyx/ReportAction';
+import type {SearchPolicy, SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults';
import type {Comment, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type IconAsset from '@src/types/utils/IconAsset';
@@ -854,21 +855,21 @@ function isChatReport(report: OnyxEntry): boolean {
return report?.type === CONST.REPORT.TYPE.CHAT;
}
-function isInvoiceReport(report: OnyxInputOrEntry): boolean {
+function isInvoiceReport(report: OnyxInputOrEntry | SearchReport): boolean {
return report?.type === CONST.REPORT.TYPE.INVOICE;
}
/**
* Checks if a report is an Expense report.
*/
-function isExpenseReport(report: OnyxInputOrEntry): boolean {
+function isExpenseReport(report: OnyxInputOrEntry | SearchReport): boolean {
return report?.type === CONST.REPORT.TYPE.EXPENSE;
}
/**
* Checks if a report is an IOU report using report or reportID
*/
-function isIOUReport(reportOrID: OnyxInputOrEntry | string): boolean {
+function isIOUReport(reportOrID: OnyxInputOrEntry | SearchReport | string): boolean {
const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID;
return report?.type === CONST.REPORT.TYPE.IOU;
}
@@ -977,12 +978,15 @@ function hasParticipantInArray(report: OnyxEntry, memberAccountIDs: numb
/**
* Whether the Money Request report is settled
*/
-function isSettled(reportID: string | undefined): boolean {
- const allReports = ReportConnection.getAllReports();
- if (!allReports || !reportID) {
+function isSettled(reportOrID: OnyxInputOrEntry | SearchReport | string | undefined): boolean {
+ if (!reportOrID) {
+ return false;
+ }
+ const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID;
+ if (!report) {
return false;
}
- const report = allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? null;
+
if (isEmptyObject(report) || report.isWaitingOnBankAccount) {
return false;
}
@@ -1459,7 +1463,7 @@ function isClosedExpenseReportWithNoExpenses(report: OnyxEntry): boolean
* Whether the provided report is an archived room
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
-function isArchivedRoom(report: OnyxInputOrEntry, reportNameValuePairs?: OnyxInputOrEntry): boolean {
+function isArchivedRoom(report: OnyxInputOrEntry | SearchReport, reportNameValuePairs?: OnyxInputOrEntry): boolean {
return !!report?.private_isArchived;
}
@@ -1475,7 +1479,7 @@ function isArchivedRoomWithID(reportID?: string) {
/**
* Whether the provided report is a closed report
*/
-function isClosedReport(report: OnyxEntry): boolean {
+function isClosedReport(report: OnyxEntry | SearchReport): boolean {
return report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED;
}
@@ -1599,13 +1603,6 @@ function isWorkspaceThread(report: OnyxEntry): boolean {
return isThread(report) && isChatReport(report) && CONST.WORKSPACE_ROOM_TYPES.some((type) => chatType === type);
}
-/**
- * Returns true if reportAction is the first chat preview of a Thread
- */
-function isThreadFirstChat(reportAction: OnyxInputOrEntry, reportID: string): boolean {
- return reportAction?.childReportID?.toString() === reportID;
-}
-
/**
* Checks if a report is a child report.
*/
@@ -1663,7 +1660,7 @@ function isMoneyRequest(reportOrID: OnyxEntry | string): boolean {
/**
* Checks if a report is an IOU or expense report.
*/
-function isMoneyRequestReport(reportOrID: OnyxInputOrEntry | string): boolean {
+function isMoneyRequestReport(reportOrID: OnyxInputOrEntry | SearchReport | string): boolean {
const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID;
return isIOUReport(report) || isExpenseReport(report);
}
@@ -1740,9 +1737,9 @@ function isOneOnOneChat(report: OnyxEntry): boolean {
* Checks if the current user is a payer of the expense
*/
-function isPayer(session: OnyxEntry, iouReport: OnyxEntry, onlyShowPayElsewhere = false) {
+function isPayer(session: OnyxEntry, iouReport: OnyxEntry, onlyShowPayElsewhere = false, reportPolicy?: OnyxInputOrEntry | SearchPolicy) {
const isApproved = isReportApproved(iouReport);
- const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport?.policyID}`] ?? null;
+ const policy = reportPolicy ?? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport?.policyID}`] ?? null;
const policyType = policy?.type;
const isAdmin = policyType !== CONST.POLICY.TYPE.PERSONAL && policy?.role === CONST.POLICY.ROLE.ADMIN;
const isManager = iouReport?.managerID === session?.accountID;
@@ -1851,10 +1848,9 @@ function canDeleteReportAction(reportAction: OnyxInputOrEntry, rep
return false;
}
- const linkedReport = isThreadFirstChat(reportAction, reportID) ? getReportOrDraftReport(report?.parentReportID) : report;
if (isActionOwner) {
- if (!isEmptyObject(linkedReport) && (isMoneyRequestReport(linkedReport) || isInvoiceReport(linkedReport))) {
- return canDeleteTransaction(linkedReport);
+ if (!isEmptyObject(report) && (isMoneyRequestReport(report) || isInvoiceReport(report))) {
+ return canDeleteTransaction(report);
}
return true;
}
@@ -7270,10 +7266,9 @@ function getOriginalReportID(reportID: string, reportAction: OnyxInputOrEntry TransactionUtils.isOnHold(transaction));
}
/**
* Check if all expenses in the Report are on hold
*/
-function hasOnlyHeldExpenses(iouReportID: string): boolean {
- const reportTransactions = reportsTransactions[iouReportID ?? ''] ?? [];
+function hasOnlyHeldExpenses(iouReportID: string, allReportTransactions?: SearchTransaction[]): boolean {
+ const reportTransactions = allReportTransactions ?? reportsTransactions[iouReportID ?? ''] ?? [];
return reportTransactions.length > 0 && !reportTransactions.some((transaction) => !TransactionUtils.isOnHold(transaction));
}
/**
* Checks if thread replies should be displayed
*/
-function shouldDisplayThreadReplies(reportAction: OnyxInputOrEntry, reportID: string): boolean {
+function shouldDisplayThreadReplies(reportAction: OnyxInputOrEntry, isThreadReportParentAction: boolean): boolean {
const hasReplies = (reportAction?.childVisibleActionCount ?? 0) > 0;
- return hasReplies && !!reportAction?.childCommenterCount && !isThreadFirstChat(reportAction, reportID);
+ return hasReplies && !!reportAction?.childCommenterCount && !isThreadReportParentAction;
}
/**
@@ -7801,7 +7796,7 @@ function getNonHeldAndFullAmount(iouReport: OnyxEntry, policy: OnyxEntry
* - The action is a whisper action and it's neither a report preview nor IOU action
* - The action is the thread's first chat
*/
-function shouldDisableThread(reportAction: OnyxInputOrEntry, reportID: string): boolean {
+function shouldDisableThread(reportAction: OnyxInputOrEntry, reportID: string, isThreadReportParentAction: boolean): boolean {
const isSplitBillAction = ReportActionsUtils.isSplitBillAction(reportAction);
const isDeletedAction = ReportActionsUtils.isDeletedAction(reportAction);
const isReportPreviewAction = ReportActionsUtils.isReportPreviewAction(reportAction);
@@ -7816,7 +7811,7 @@ function shouldDisableThread(reportAction: OnyxInputOrEntry, repor
(isDeletedAction && !reportAction?.childVisibleActionCount) ||
(isArchivedReport && !reportAction?.childVisibleActionCount) ||
(isWhisperAction && !isReportPreviewAction && !isIOUAction) ||
- isThreadFirstChat(reportAction, reportID)
+ isThreadReportParentAction
);
}
@@ -7960,7 +7955,7 @@ function getQuickActionDetails(
};
}
-function canBeAutoReimbursed(report: OnyxInputOrEntry, policy: OnyxInputOrEntry): boolean {
+function canBeAutoReimbursed(report: OnyxInputOrEntry, policy: OnyxInputOrEntry | SearchPolicy): boolean {
if (isEmptyObject(policy)) {
return false;
}
@@ -7981,8 +7976,8 @@ function isReportOwner(report: OnyxInputOrEntry): boolean {
return report?.ownerAccountID === currentUserPersonalDetails?.accountID;
}
-function isAllowedToApproveExpenseReport(report: OnyxEntry, approverAccountID?: number): boolean {
- const policy = getPolicy(report?.policyID);
+function isAllowedToApproveExpenseReport(report: OnyxEntry, approverAccountID?: number, reportPolicy?: OnyxEntry | SearchPolicy): boolean {
+ const policy = reportPolicy ?? getPolicy(report?.policyID);
const isOwner = (approverAccountID ?? currentUserAccountID) === report?.ownerAccountID;
return !(policy?.preventSelfApproval && isOwner);
}
@@ -8672,7 +8667,6 @@ export {
isSystemChat,
isTaskReport,
isThread,
- isThreadFirstChat,
isTrackExpenseReport,
isUnread,
isUnreadWithMention,
diff --git a/src/libs/RequestThrottle.ts b/src/libs/RequestThrottle.ts
index 3bbc82ff5b45..c4589bb07afa 100644
--- a/src/libs/RequestThrottle.ts
+++ b/src/libs/RequestThrottle.ts
@@ -3,41 +3,57 @@ import Log from './Log';
import type {RequestError} from './Network/SequentialQueue';
import {generateRandomInt} from './NumberUtils';
-let requestWaitTime = 0;
-let requestRetryCount = 0;
+class RequestThrottle {
+ private requestWaitTime = 0;
-function clear() {
- requestWaitTime = 0;
- requestRetryCount = 0;
- Log.info(`[RequestThrottle] in clear()`);
-}
+ private requestRetryCount = 0;
+
+ private timeoutID?: NodeJS.Timeout;
+
+ private name: string;
-function getRequestWaitTime() {
- if (requestWaitTime) {
- requestWaitTime = Math.min(requestWaitTime * 2, CONST.NETWORK.MAX_RETRY_WAIT_TIME_MS);
- } else {
- requestWaitTime = generateRandomInt(CONST.NETWORK.MIN_RETRY_WAIT_TIME_MS, CONST.NETWORK.MAX_RANDOM_RETRY_WAIT_TIME_MS);
+ constructor(name: string) {
+ this.name = name;
}
- return requestWaitTime;
-}
-function getLastRequestWaitTime() {
- return requestWaitTime;
-}
+ clear() {
+ this.requestWaitTime = 0;
+ this.requestRetryCount = 0;
+ if (this.timeoutID) {
+ Log.info(`[RequestThrottle - ${this.name}] clearing timeoutID: ${String(this.timeoutID)}`);
+ clearTimeout(this.timeoutID);
+ this.timeoutID = undefined;
+ }
+ Log.info(`[RequestThrottle - ${this.name}] cleared`);
+ }
-function sleep(error: RequestError, command: string): Promise {
- requestRetryCount++;
- return new Promise((resolve, reject) => {
- if (requestRetryCount <= CONST.NETWORK.MAX_REQUEST_RETRIES) {
- const currentRequestWaitTime = getRequestWaitTime();
- Log.info(
- `[RequestThrottle] Retrying request after error: '${error.name}', '${error.message}', '${error.status}'. Command: ${command}. Retry count: ${requestRetryCount}. Wait time: ${currentRequestWaitTime}`,
- );
- setTimeout(resolve, currentRequestWaitTime);
- return;
+ getRequestWaitTime() {
+ if (this.requestWaitTime) {
+ this.requestWaitTime = Math.min(this.requestWaitTime * 2, CONST.NETWORK.MAX_RETRY_WAIT_TIME_MS);
+ } else {
+ this.requestWaitTime = generateRandomInt(CONST.NETWORK.MIN_RETRY_WAIT_TIME_MS, CONST.NETWORK.MAX_RANDOM_RETRY_WAIT_TIME_MS);
}
- reject();
- });
+ return this.requestWaitTime;
+ }
+
+ getLastRequestWaitTime() {
+ return this.requestWaitTime;
+ }
+
+ sleep(error: RequestError, command: string): Promise {
+ this.requestRetryCount++;
+ return new Promise((resolve, reject) => {
+ if (this.requestRetryCount <= CONST.NETWORK.MAX_REQUEST_RETRIES) {
+ const currentRequestWaitTime = this.getRequestWaitTime();
+ Log.info(
+ `[RequestThrottle - ${this.name}] Retrying request after error: '${error.name}', '${error.message}', '${error.status}'. Command: ${command}. Retry count: ${this.requestRetryCount}. Wait time: ${currentRequestWaitTime}`,
+ );
+ this.timeoutID = setTimeout(resolve, currentRequestWaitTime);
+ } else {
+ reject();
+ }
+ });
+ }
}
-export {clear, getRequestWaitTime, sleep, getLastRequestWaitTime};
+export default RequestThrottle;
diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts
index a7ce065a6d23..d9f63a0c1094 100644
--- a/src/libs/SearchUIUtils.ts
+++ b/src/libs/SearchUIUtils.ts
@@ -11,12 +11,14 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type * as OnyxTypes from '@src/types/onyx';
import type SearchResults from '@src/types/onyx/SearchResults';
-import type {ListItemDataType, ListItemType, SearchDataTypes, SearchPersonalDetails, SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults';
+import type {ListItemDataType, ListItemType, SearchDataTypes, SearchPersonalDetails, SearchReport, SearchTransaction, SearchTransactionAction} from '@src/types/onyx/SearchResults';
+import * as IOU from './actions/IOU';
import * as CurrencyUtils from './CurrencyUtils';
import DateUtils from './DateUtils';
import {translateLocal} from './Localize';
import Navigation from './Navigation/Navigation';
import * as ReportActionsUtils from './ReportActionsUtils';
+import * as ReportUtils from './ReportUtils';
import * as TransactionUtils from './TransactionUtils';
const columnNamesToSortingProperty = {
@@ -209,7 +211,6 @@ function getIOUReportName(data: OnyxTypes.SearchResults['data'], reportItem: Sea
*/
function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata: OnyxTypes.SearchResults['search']): TransactionListItemType[] {
const shouldShowMerchant = getShouldShowMerchant(data);
-
const doesDataContainAPastYearTransaction = shouldShowYear(data);
return Object.keys(data)
@@ -223,6 +224,7 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata
return {
...transactionItem,
+ action: getAction(data, key),
from,
to,
formattedFrom,
@@ -240,6 +242,66 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata
});
}
+/**
+ * @private
+ * Returns the action that can be taken on a given transaction or report
+ *
+ * Do not use directly, use only via `getSections()` facade.
+ */
+function getAction(data: OnyxTypes.SearchResults['data'], key: string): SearchTransactionAction {
+ const isTransaction = isTransactionEntry(key);
+ if (!isTransaction && !isReportEntry(key)) {
+ return CONST.SEARCH.ACTION_TYPES.VIEW;
+ }
+
+ const transaction = isTransaction ? data[key] : undefined;
+ const report = isTransaction ? data[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`] : data[key];
+
+ if (ReportUtils.isSettled(report)) {
+ return CONST.SEARCH.ACTION_TYPES.PAID;
+ }
+
+ if (ReportUtils.isClosedReport(report)) {
+ return CONST.SEARCH.ACTION_TYPES.DONE;
+ }
+
+ // We don't need to run the logic if this is not a transaction or iou/expense report, so let's shortcircuit the logic for performance reasons
+ if (!ReportUtils.isMoneyRequestReport(report) || (isTransaction && !data[key].isFromOneTransactionReport)) {
+ return CONST.SEARCH.ACTION_TYPES.VIEW;
+ }
+
+ const policy = data[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`] ?? {};
+
+ const invoiceReceiverPolicy =
+ ReportUtils.isInvoiceReport(report) && report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS
+ ? data[`${ONYXKEYS.COLLECTION.POLICY}${report?.invoiceReceiver?.policyID}`]
+ : undefined;
+
+ const allReportTransactions = (
+ isReportEntry(key)
+ ? Object.entries(data)
+ .filter(([itemKey, value]) => isTransactionEntry(itemKey) && (value as SearchTransaction)?.reportID === report.reportID)
+ .map((item) => item[1])
+ : [transaction]
+ ) as SearchTransaction[];
+
+ const chatReport = data[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`] ?? {};
+ const chatReportRNVP = data[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.chatReportID}`] ?? undefined;
+
+ if (
+ IOU.canIOUBePaid(report, chatReport, policy, allReportTransactions, false, chatReportRNVP, invoiceReceiverPolicy) &&
+ !ReportUtils.hasOnlyHeldExpenses(report.reportID, allReportTransactions)
+ ) {
+ return CONST.SEARCH.ACTION_TYPES.PAY;
+ }
+
+ if (IOU.canApproveIOU(report, policy) && ReportUtils.isAllowedToApproveExpenseReport(report, undefined, policy)) {
+ return CONST.SEARCH.ACTION_TYPES.APPROVE;
+ }
+
+ return CONST.SEARCH.ACTION_TYPES.VIEW;
+}
+
/**
* @private
* Organizes data into List Sections for display, for the ReportActionListItemType of Search Results.
@@ -291,6 +353,7 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx
reportIDToTransactions[reportKey] = {
...reportItem,
+ action: getAction(data, key),
keyForList: reportItem.reportID,
from: data.personalDetailsList?.[reportItem.accountID ?? -1],
to: reportItem.managerID ? data.personalDetailsList?.[reportItem.managerID] : emptyPersonalDetails,
@@ -308,6 +371,7 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx
const transaction = {
...transactionItem,
+ action: getAction(data, key),
from,
to,
formattedFrom,
diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts
index cb2c1a52e2d2..1fc7cad2f456 100644
--- a/src/libs/TransactionUtils/index.ts
+++ b/src/libs/TransactionUtils/index.ts
@@ -27,6 +27,7 @@ import type {IOUType} from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Beta, OnyxInputOrEntry, Policy, RecentWaypoint, Report, ReviewDuplicates, TaxRate, TaxRates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx';
import type {Attendee} from '@src/types/onyx/IOU';
+import type {SearchPolicy, SearchReport} from '@src/types/onyx/SearchResults';
import type {Comment, Receipt, TransactionChanges, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction';
import type DeepValueOf from '@src/types/utils/DeepValueOf';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
@@ -715,7 +716,7 @@ function hasBrokenConnectionViolation(transactionID: string): boolean {
/**
* Check if user should see broken connection violation warning.
*/
-function shouldShowBrokenConnectionViolation(transactionID: string, report: OnyxEntry, policy: OnyxEntry): boolean {
+function shouldShowBrokenConnectionViolation(transactionID: string, report: OnyxEntry | SearchReport, policy: OnyxEntry | SearchPolicy): boolean {
return (
hasBrokenConnectionViolation(transactionID) &&
(!PolicyUtils.isPolicyAdmin(policy) || ReportUtils.isOpenExpenseReport(report) || (ReportUtils.isProcessingReport(report) && PolicyUtils.isInstantSubmitEnabled(policy)))
diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts
index f42c0252644d..fac02bd2b4ca 100644
--- a/src/libs/ValidationUtils.ts
+++ b/src/libs/ValidationUtils.ts
@@ -49,7 +49,7 @@ function isValidAddress(value: FormValue): boolean {
return false;
}
- if (!CONST.REGEX.ANY_VALUE.test(value) || value.match(CONST.REGEX.EMOJIS)) {
+ if (!CONST.REGEX.ANY_VALUE.test(value) || value.match(CONST.REGEX.ALL_EMOJIS)) {
return false;
}
@@ -343,7 +343,7 @@ function isValidRoutingNumber(routingNumber: string): boolean {
* Checks that the provided name doesn't contain any emojis
*/
function isValidCompanyName(name: string) {
- return !name.match(CONST.REGEX.EMOJIS);
+ return !name.match(CONST.REGEX.ALL_EMOJIS);
}
function isValidReportName(name: string) {
diff --git a/src/libs/actions/CompanyCards.ts b/src/libs/actions/CompanyCards.ts
index 4a102ab9bb72..8e83b9192a71 100644
--- a/src/libs/actions/CompanyCards.ts
+++ b/src/libs/actions/CompanyCards.ts
@@ -151,27 +151,33 @@ function setWorkspaceCompanyCardTransactionLiability(workspaceAccountID: number,
API.write(WRITE_COMMANDS.SET_COMPANY_CARD_TRANSACTION_LIABILITY, parameters, onyxData);
}
-function deleteWorkspaceCompanyCardFeed(policyID: string, workspaceAccountID: number, bankName: CompanyCardFeed) {
+function deleteWorkspaceCompanyCardFeed(policyID: string, workspaceAccountID: number, bankName: CompanyCardFeed, feedToOpen?: CompanyCardFeed) {
const authToken = NetworkStore.getAuthToken();
const isCustomFeed = CardUtils.isCustomFeed(bankName);
const feedUpdates = {[bankName]: null};
- const onyxData: OnyxData = {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
- value: {
- settings: {
- ...(isCustomFeed ? {companyCards: feedUpdates} : {oAuthAccountDetails: feedUpdates}),
- companyCardNicknames: {
- [bankName]: null,
- },
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
+ value: {
+ settings: {
+ ...(isCustomFeed ? {companyCards: feedUpdates} : {oAuthAccountDetails: feedUpdates}),
+ companyCardNicknames: {
+ [bankName]: null,
},
},
},
- ],
- };
+ },
+ ];
+
+ if (feedToOpen) {
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`,
+ value: feedToOpen,
+ });
+ }
const parameters = {
authToken,
@@ -179,7 +185,7 @@ function deleteWorkspaceCompanyCardFeed(policyID: string, workspaceAccountID: nu
bankName,
};
- API.write(WRITE_COMMANDS.DELETE_COMPANY_CARD_FEED, parameters, onyxData);
+ API.write(WRITE_COMMANDS.DELETE_COMPANY_CARD_FEED, parameters, {optimisticData});
}
function assignWorkspaceCompanyCard(policyID: string, data?: Partial) {
diff --git a/src/libs/actions/Debug.ts b/src/libs/actions/Debug.ts
index 5047ab063b7e..4c3479ee9741 100644
--- a/src/libs/actions/Debug.ts
+++ b/src/libs/actions/Debug.ts
@@ -7,11 +7,11 @@ function resetDebugDetailsDraftForm() {
Onyx.set(ONYXKEYS.FORMS.DEBUG_DETAILS_FORM_DRAFT, null);
}
-function mergeDebugData(onyxKey: TKey, onyxValue: OnyxMergeInput) {
- Onyx.merge(onyxKey, onyxValue);
+function setDebugData(onyxKey: TKey, onyxValue: OnyxMergeInput) {
+ Onyx.set(onyxKey, onyxValue);
}
export default {
resetDebugDetailsDraftForm,
- mergeDebugData,
+ setDebugData,
};
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 5c1affb89299..b72b95a0c44f 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -66,7 +66,7 @@ import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import type ReportAction from '@src/types/onyx/ReportAction';
import type {OnyxData} from '@src/types/onyx/Request';
-import type {SearchTransaction} from '@src/types/onyx/SearchResults';
+import type {SearchPolicy, SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults';
import type {Comment, Receipt, ReceiptSource, Routes, SplitShares, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import * as CachedPDFPaths from './CachedPDFPaths';
@@ -6781,7 +6781,11 @@ function sendMoneyWithWallet(report: OnyxEntry, amount: number
Report.notifyNewAction(params.chatReportID, managerID);
}
-function canApproveIOU(iouReport: OnyxTypes.OnyxInputOrEntry, policy: OnyxTypes.OnyxInputOrEntry) {
+function canApproveIOU(
+ iouReport: OnyxTypes.OnyxInputOrEntry | SearchReport,
+ policy: OnyxTypes.OnyxInputOrEntry | SearchPolicy,
+ chatReportRNVP?: OnyxTypes.ReportNameValuePairs,
+) {
// Only expense reports can be approved
const isPaidGroupPolicy = policy && PolicyUtils.isPaidGroupPolicy(policy);
if (!isPaidGroupPolicy) {
@@ -6798,7 +6802,7 @@ function canApproveIOU(iouReport: OnyxTypes.OnyxInputOrEntry,
const isOpenExpenseReport = ReportUtils.isOpenExpenseReport(iouReport);
const isApproved = ReportUtils.isReportApproved(iouReport);
const iouSettled = ReportUtils.isSettled(iouReport?.reportID);
- const reportNameValuePairs = ReportUtils.getReportNameValuePairs(iouReport?.reportID);
+ const reportNameValuePairs = chatReportRNVP ?? ReportUtils.getReportNameValuePairs(iouReport?.reportID);
const isArchivedReport = ReportUtils.isArchivedRoom(iouReport, reportNameValuePairs);
let isTransactionBeingScanned = false;
const reportTransactions = TransactionUtils.getAllReportTransactions(iouReport?.reportID);
@@ -6816,16 +6820,18 @@ function canApproveIOU(iouReport: OnyxTypes.OnyxInputOrEntry,
}
function canIOUBePaid(
- iouReport: OnyxTypes.OnyxInputOrEntry,
- chatReport: OnyxTypes.OnyxInputOrEntry,
- policy: OnyxTypes.OnyxInputOrEntry,
- transactions?: OnyxTypes.Transaction[],
+ iouReport: OnyxTypes.OnyxInputOrEntry | SearchReport,
+ chatReport: OnyxTypes.OnyxInputOrEntry | SearchReport,
+ policy: OnyxTypes.OnyxInputOrEntry | SearchPolicy,
+ transactions?: OnyxTypes.Transaction[] | SearchTransaction[],
onlyShowPayElsewhere = false,
+ chatReportRNVP?: OnyxTypes.ReportNameValuePairs,
+ invoiceReceiverPolicy?: SearchPolicy,
) {
const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport);
- const reportNameValuePairs = ReportUtils.getReportNameValuePairs(chatReport?.reportID);
+ const reportNameValuePairs = chatReportRNVP ?? ReportUtils.getReportNameValuePairs(chatReport?.reportID);
const isChatReportArchived = ReportUtils.isArchivedRoom(chatReport, reportNameValuePairs);
- const iouSettled = ReportUtils.isSettled(iouReport?.reportID);
+ const iouSettled = ReportUtils.isSettled(iouReport);
if (isEmptyObject(iouReport)) {
return false;
@@ -6847,7 +6853,7 @@ function canIOUBePaid(
if (chatReport?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) {
return chatReport?.invoiceReceiver?.accountID === userAccountID;
}
- return PolicyUtils.getPolicy(chatReport?.invoiceReceiver?.policyID)?.role === CONST.POLICY.ROLE.ADMIN;
+ return (invoiceReceiverPolicy ?? PolicyUtils.getPolicy(chatReport?.invoiceReceiver?.policyID))?.role === CONST.POLICY.ROLE.ADMIN;
}
const isPayer = ReportUtils.isPayer(
@@ -6857,6 +6863,7 @@ function canIOUBePaid(
},
iouReport,
onlyShowPayElsewhere,
+ policy,
);
const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport);
diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts
index dced49976c5a..2f663ac204d2 100644
--- a/src/libs/actions/Policy/Category.ts
+++ b/src/libs/actions/Policy/Category.ts
@@ -13,7 +13,6 @@ import type {
SetPolicyCategoryMaxAmountParams,
SetPolicyCategoryReceiptsRequiredParams,
SetPolicyCategoryTaxParams,
- SetPolicyDistanceRatesDefaultCategoryParams,
SetWorkspaceCategoryDescriptionHintParams,
UpdatePolicyCategoryGLCodeParams,
} from '@libs/API/parameters';
@@ -28,13 +27,13 @@ import {translateLocal} from '@libs/Localize';
import Log from '@libs/Log';
import enhanceParameters from '@libs/Network/enhanceParameters';
import * as OptionsListUtils from '@libs/OptionsListUtils';
-import {navigateWhenEnableFeature, removePendingFieldsFromCustomUnit} from '@libs/PolicyUtils';
+import {navigateWhenEnableFeature} from '@libs/PolicyUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, PolicyCategories, PolicyCategory, RecentlyUsedCategories, Report} from '@src/types/onyx';
-import type {ApprovalRule, CustomUnit, ExpenseRule} from '@src/types/onyx/Policy';
+import type {ApprovalRule, ExpenseRule} from '@src/types/onyx/Policy';
import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory';
import type {OnyxData} from '@src/types/onyx/Request';
@@ -1015,15 +1014,15 @@ function enablePolicyCategories(policyID: string, enabled: boolean) {
}
}
-function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit) {
+function setPolicyCustomUnitDefaultCategory(policyID: string, customUnitID: string, oldCategory: string | undefined, category: string) {
const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {
customUnits: {
- [newCustomUnit.customUnitID]: {
- ...newCustomUnit,
+ [customUnitID]: {
+ defaultCategory: category,
pendingFields: {defaultCategory: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
},
},
@@ -1037,7 +1036,7 @@ function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUn
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {
customUnits: {
- [newCustomUnit.customUnitID]: {
+ [customUnitID]: {
pendingFields: {defaultCategory: null},
},
},
@@ -1051,8 +1050,8 @@ function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUn
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {
customUnits: {
- [currentCustomUnit.customUnitID]: {
- ...currentCustomUnit,
+ [customUnitID]: {
+ defaultCategory: oldCategory,
errorFields: {defaultCategory: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')},
pendingFields: {defaultCategory: null},
},
@@ -1061,12 +1060,13 @@ function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUn
},
];
- const params: SetPolicyDistanceRatesDefaultCategoryParams = {
+ const params = {
policyID,
- customUnit: JSON.stringify(removePendingFieldsFromCustomUnit(newCustomUnit)),
+ customUnitID,
+ category,
};
- API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY, params, {optimisticData, successData, failureData});
+ API.write(WRITE_COMMANDS.SET_CUSTOM_UNIT_DEFAULT_CATEGORY, params, {optimisticData, successData, failureData});
}
function downloadCategoriesCSV(policyID: string, onDownloadFailed: () => void) {
@@ -1364,7 +1364,7 @@ export {
setPolicyCategoryGLCode,
clearCategoryErrors,
enablePolicyCategories,
- setPolicyDistanceRatesDefaultCategory,
+ setPolicyCustomUnitDefaultCategory,
deleteWorkspaceCategories,
buildOptimisticPolicyCategories,
setPolicyCategoryReceiptsRequired,
diff --git a/src/libs/actions/Policy/PerDiem.ts b/src/libs/actions/Policy/PerDiem.ts
index 1f6f0cf3dc9a..9dfb78b11564 100644
--- a/src/libs/actions/Policy/PerDiem.ts
+++ b/src/libs/actions/Policy/PerDiem.ts
@@ -9,6 +9,7 @@ import * as ReportUtils from '@libs/ReportUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, Report} from '@src/types/onyx';
+import type {ErrorFields} from '@src/types/onyx/OnyxCommon';
import type {OnyxData} from '@src/types/onyx/Request';
const allPolicies: OnyxCollection = {};
@@ -119,4 +120,14 @@ function openPolicyPerDiemPage(policyID?: string) {
API.read(READ_COMMANDS.OPEN_POLICY_PER_DIEM_RATES_PAGE, params);
}
-export {enablePerDiem, openPolicyPerDiemPage};
+function clearPolicyPerDiemRatesErrorFields(policyID: string, customUnitID: string, updatedErrorFields: ErrorFields) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ customUnits: {
+ [customUnitID]: {
+ errorFields: updatedErrorFields,
+ },
+ },
+ });
+}
+
+export {enablePerDiem, openPolicyPerDiemPage, clearPolicyPerDiemRatesErrorFields};
diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts
index caef871571ed..44b5bb7f7ce9 100644
--- a/src/libs/actions/Search.ts
+++ b/src/libs/actions/Search.ts
@@ -1,18 +1,22 @@
import Onyx from 'react-native-onyx';
-import type {OnyxUpdate} from 'react-native-onyx';
+import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import type {FormOnyxValues} from '@components/Form/types';
-import type {SearchQueryJSON} from '@components/Search/types';
+import type {PaymentData, SearchQueryJSON} from '@components/Search/types';
+import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types';
import * as API from '@libs/API';
import type {ExportSearchItemsToCSVParams} from '@libs/API/parameters';
import {WRITE_COMMANDS} from '@libs/API/types';
import * as ApiUtils from '@libs/ApiUtils';
import fileDownload from '@libs/fileDownload';
import enhanceParameters from '@libs/Network/enhanceParameters';
+import * as ReportUtils from '@libs/ReportUtils';
+import {isReportListItemType, isTransactionListItemType} from '@libs/SearchUIUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm';
-import type {SearchTransaction} from '@src/types/onyx/SearchResults';
+import type {LastPaymentMethod, SearchResults} from '@src/types/onyx';
+import type {SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults';
import * as Report from './Report';
let currentUserEmail: string;
@@ -23,6 +27,80 @@ Onyx.connect({
},
});
+let lastPaymentMethod: OnyxEntry;
+Onyx.connect({
+ key: ONYXKEYS.NVP_LAST_PAYMENT_METHOD,
+ callback: (val) => {
+ lastPaymentMethod = val;
+ },
+});
+
+let allSnapshots: OnyxCollection;
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.SNAPSHOT,
+ callback: (val) => {
+ allSnapshots = val;
+ },
+ waitForCollectionCallback: true,
+});
+
+function handleActionButtonPress(hash: number, item: TransactionListItemType | ReportListItemType, goToItem: () => void) {
+ // The transactionID is needed to handle actions taken on `status:all` where transactions on single expense reports can be approved/paid.
+ // We need the transactionID to display the loading indicator for that list item's action.
+ const transactionID = isTransactionListItemType(item) ? item.transactionID : undefined;
+ const data = (allSnapshots?.[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`]?.data ?? {}) as SearchResults['data'];
+ const allReportTransactions = (
+ isReportListItemType(item)
+ ? Object.entries(data)
+ .filter(([itemKey, value]) => itemKey.startsWith(ONYXKEYS.COLLECTION.REPORT) && (value as SearchTransaction)?.reportID === item.reportID)
+ .map((report) => report[1])
+ : [data[`${ONYXKEYS.COLLECTION.TRANSACTION}${item.transactionID}`]]
+ ) as SearchTransaction[];
+
+ const hasHeldExpense = ReportUtils.hasHeldExpenses('', allReportTransactions);
+ if (hasHeldExpense) {
+ goToItem();
+ return;
+ }
+
+ switch (item.action) {
+ case CONST.SEARCH.ACTION_TYPES.PAY:
+ getPayActionCallback(hash, item, goToItem);
+ return;
+ case CONST.SEARCH.ACTION_TYPES.APPROVE:
+ approveMoneyRequestOnSearch(hash, [item.reportID], transactionID);
+ return;
+ default:
+ goToItem();
+ }
+}
+
+function getPayActionCallback(hash: number, item: TransactionListItemType | ReportListItemType, goToItem: () => void) {
+ const lastPolicyPaymentMethod = item.policyID ? (lastPaymentMethod?.[item.policyID] as ValueOf) : null;
+
+ if (!lastPolicyPaymentMethod) {
+ goToItem();
+ return;
+ }
+
+ const report = (allSnapshots?.[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`]?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`] ?? {}) as SearchReport;
+ const amount = Math.abs((report?.total ?? 0) - (report?.nonReimbursableTotal ?? 0));
+ const transactionID = isTransactionListItemType(item) ? item.transactionID : undefined;
+
+ if (lastPolicyPaymentMethod === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) {
+ payMoneyRequestOnSearch(hash, [{reportID: item.reportID, amount, paymentType: lastPolicyPaymentMethod}], transactionID);
+ return;
+ }
+
+ const hasVBBA = !!allSnapshots?.[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`]?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`]?.achAccount?.bankAccountID;
+ if (hasVBBA) {
+ payMoneyRequestOnSearch(hash, [{reportID: item.reportID, amount, paymentType: lastPolicyPaymentMethod}], transactionID);
+ return;
+ }
+
+ goToItem();
+}
+
function getOnyxLoadingData(hash: number): {optimisticData: OnyxUpdate[]; finallyData: OnyxUpdate[]} {
const optimisticData: OnyxUpdate[] = [
{
@@ -164,20 +242,42 @@ function holdMoneyRequestOnSearch(hash: number, transactionIDList: string[], com
API.write(WRITE_COMMANDS.HOLD_MONEY_REQUEST_ON_SEARCH, {hash, transactionIDList, comment}, {optimisticData, finallyData});
}
-// this function will be used once https://github.com/Expensify/App/pull/51445 is merged
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-function approveMoneyRequestOnSearch(hash: number, reportIDList: string[]) {
- const {optimisticData, finallyData} = getOnyxLoadingData(hash);
+function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], transactionID?: string) {
+ const createActionLoadingData = (isLoading: boolean): OnyxUpdate[] => [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`,
+ value: {
+ data: transactionID
+ ? {[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: {isActionLoading: isLoading}}
+ : (Object.fromEntries(reportIDList.map((reportID) => [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {isActionLoading: isLoading}])) as Partial),
+ },
+ },
+ ];
+
+ const optimisticData: OnyxUpdate[] = createActionLoadingData(true);
+ const finallyData: OnyxUpdate[] = createActionLoadingData(false);
API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST_ON_SEARCH, {hash, reportIDList}, {optimisticData, finallyData});
}
-// this function will be used once https://github.com/Expensify/App/pull/51445 is merged
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-function payMoneyRequestOnSearch(hash: number, paymentType: string, reportsAndAmounts: string) {
- const {optimisticData, finallyData} = getOnyxLoadingData(hash);
+function payMoneyRequestOnSearch(hash: number, paymentData: PaymentData[], transactionID?: string) {
+ const createActionLoadingData = (isLoading: boolean): OnyxUpdate[] => [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`,
+ value: {
+ data: transactionID
+ ? {[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: {isActionLoading: isLoading}}
+ : (Object.fromEntries(paymentData.map((item) => [`${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`, {isActionLoading: isLoading}])) as Partial),
+ },
+ },
+ ];
+
+ const optimisticData: OnyxUpdate[] = createActionLoadingData(true);
+ const finallyData: OnyxUpdate[] = createActionLoadingData(false);
- API.write(WRITE_COMMANDS.PAY_MONEY_REQUEST_ON_SEARCH, {hash, paymentType, reportsAndAmounts}, {optimisticData, finallyData});
+ API.write(WRITE_COMMANDS.PAY_MONEY_REQUEST_ON_SEARCH, {hash, paymentData: JSON.stringify(paymentData)}, {optimisticData, finallyData});
}
function unholdMoneyRequestOnSearch(hash: number, transactionIDList: string[]) {
@@ -261,4 +361,7 @@ export {
deleteSavedSearch,
dismissSavedSearchRenameTooltip,
showSavedSearchRenameTooltip,
+ payMoneyRequestOnSearch,
+ approveMoneyRequestOnSearch,
+ handleActionButtonPress,
};
diff --git a/src/pages/Debug/ConstantPicker.tsx b/src/pages/Debug/ConstantPicker.tsx
new file mode 100644
index 000000000000..564b2ea3d710
--- /dev/null
+++ b/src/pages/Debug/ConstantPicker.tsx
@@ -0,0 +1,69 @@
+import isObject from 'lodash/isObject';
+import React, {useMemo, useState} from 'react';
+import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
+import type {ListItem} from '@components/SelectionList/types';
+import useLocalize from '@hooks/useLocalize';
+import type {DebugForms} from './const';
+import {DETAILS_CONSTANT_FIELDS} from './const';
+
+type ConstantPickerProps = {
+ formType: string;
+ /** The form to object the constant list of options */
+
+ /** Constant name to get list of options */
+ fieldName: string;
+
+ /** Current selected constant */
+ fieldValue?: string;
+
+ /** Callback to submit the selected constant */
+ onSubmit: (item: ListItem) => void;
+};
+
+function ConstantPicker({formType, fieldName, fieldValue, onSubmit}: ConstantPickerProps) {
+ const {translate} = useLocalize();
+ const [searchValue, setSearchValue] = useState('');
+ const sections: ListItem[] = useMemo(
+ () =>
+ Object.entries(DETAILS_CONSTANT_FIELDS[formType as DebugForms].find((field) => field.fieldName === fieldName)?.options ?? {})
+ .reduce((acc: Array<[string, string]>, [key, value]) => {
+ // Option has multiple constants, so we need to flatten these into separate options
+ if (isObject(value)) {
+ acc.push(...Object.entries(value));
+ return acc;
+ }
+ acc.push([key, String(value)]);
+ return acc;
+ }, [])
+ .map(
+ ([key, value]) =>
+ ({
+ text: value,
+ keyForList: key,
+ isSelected: value === fieldValue,
+ searchText: value,
+ } satisfies ListItem),
+ )
+ .filter(({searchText}) => searchText.toLowerCase().includes(searchValue.toLowerCase())),
+ [fieldName, fieldValue, formType, searchValue],
+ );
+ const selectedOptionKey = useMemo(() => sections.filter((option) => option.searchText === fieldValue).at(0)?.keyForList, [sections, fieldValue]);
+
+ return (
+
+ );
+}
+
+ConstantPicker.default = 'ConstantPicker';
+
+export default ConstantPicker;
diff --git a/src/pages/Debug/ConstantSelector.tsx b/src/pages/Debug/ConstantSelector.tsx
index d6a3c0cfb4b1..c2df1f3e3e2a 100644
--- a/src/pages/Debug/ConstantSelector.tsx
+++ b/src/pages/Debug/ConstantSelector.tsx
@@ -1,5 +1,6 @@
import {useRoute} from '@react-navigation/native';
import React, {useEffect} from 'react';
+import type {ValueOf} from 'type-fest';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import Navigation from '@libs/Navigation/Navigation';
import CONST from '@src/CONST';
@@ -21,9 +22,14 @@ type ConstantSelectorProps = {
/** inputID used by the Form component */
// eslint-disable-next-line react/no-unused-prop-types
inputID: string;
+
+ /** Type of debug form - required to access constant field options for a specific form */
+ formType: ValueOf;
+
+ policyID?: string;
};
-function ConstantSelector({errorText = '', name, value, onInputChange}: ConstantSelectorProps) {
+function ConstantSelector({formType, policyID, errorText = '', name, value, onInputChange}: ConstantSelectorProps) {
const fieldValue = (useRoute().params as Record | undefined)?.[name];
useEffect(() => {
@@ -49,7 +55,7 @@ function ConstantSelector({errorText = '', name, value, onInputChange}: Constant
brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
errorText={errorText}
onPress={() => {
- Navigation.navigate(ROUTES.DETAILS_CONSTANT_PICKER_PAGE.getRoute(name, value, Navigation.getActiveRoute()));
+ Navigation.navigate(ROUTES.DETAILS_CONSTANT_PICKER_PAGE.getRoute(formType, name, value, policyID, Navigation.getActiveRoute()));
}}
shouldShowRightIcon
/>
diff --git a/src/pages/Debug/DebugDetails.tsx b/src/pages/Debug/DebugDetails.tsx
index 6ee14660dbe9..60126ef1937a 100644
--- a/src/pages/Debug/DebugDetails.tsx
+++ b/src/pages/Debug/DebugDetails.tsx
@@ -2,6 +2,7 @@ import React, {useCallback, useEffect, useMemo} from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
import Button from '@components/Button';
import CheckboxWithLabel from '@components/CheckboxWithLabel';
import FormProvider from '@components/Form/FormProvider';
@@ -14,18 +15,24 @@ import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import type {ObjectType, OnyxDataType} from '@libs/DebugUtils';
import DebugUtils from '@libs/DebugUtils';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import * as TagsOptionsListUtils from '@libs/TagsOptionsListUtils';
import Debug from '@userActions/Debug';
+import type CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {Report, ReportAction} from '@src/types/onyx';
-import type {DetailsConstantFieldsKeys, DetailsDatetimeFieldsKeys, DetailsDisabledKeys} from './const';
+import TRANSACTION_FORM_INPUT_IDS from '@src/types/form/DebugTransactionForm';
+import type {Report, ReportAction, Transaction, TransactionViolation} from '@src/types/onyx';
import {DETAILS_CONSTANT_FIELDS, DETAILS_DATETIME_FIELDS, DETAILS_DISABLED_KEYS} from './const';
import ConstantSelector from './ConstantSelector';
import DateTimeSelector from './DateTimeSelector';
type DebugDetailsProps = {
+ /** Type of debug form - required to access constant field options for a specific form */
+ formType: ValueOf;
+
/** The report or report action data to be displayed and editted. */
- data: OnyxEntry | OnyxEntry;
+ data: OnyxEntry | OnyxEntry | OnyxEntry | OnyxEntry;
children?: React.ReactNode;
@@ -40,10 +47,13 @@ type DebugDetailsProps = {
validate: (key: any, value: string) => void;
};
-function DebugDetails({data, children, onSave, onDelete, validate}: DebugDetailsProps) {
+function DebugDetails({formType, data, children, onSave, onDelete, validate}: DebugDetailsProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
const [formDraftData] = useOnyx(ONYXKEYS.FORMS.DEBUG_DETAILS_FORM_DRAFT);
+ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${(data as OnyxEntry)?.reportID ?? ''}`);
+ const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`);
+ const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]);
const booleanFields = useMemo(
() =>
Object.entries(data ?? {})
@@ -54,9 +64,15 @@ function DebugDetails({data, children, onSave, onDelete, validate}: DebugDetails
const constantFields = useMemo(
() =>
Object.entries(data ?? {})
- .filter((entry): entry is [string, string] => DETAILS_CONSTANT_FIELDS.includes(entry[0] as DetailsConstantFieldsKeys))
+ .filter((entry): entry is [string, string] => {
+ // Tag picker needs to be hidden when the policy has no tags available to pick
+ if (entry[0] === TRANSACTION_FORM_INPUT_IDS.TAG && !TagsOptionsListUtils.hasEnabledTags(policyTagLists)) {
+ return false;
+ }
+ return DETAILS_CONSTANT_FIELDS[formType].some(({fieldName}) => fieldName === entry[0]);
+ })
.sort((a, b) => a[0].localeCompare(b[0])),
- [data],
+ [data, formType, policyTagLists],
);
const numberFields = useMemo(
() =>
@@ -69,19 +85,16 @@ function DebugDetails({data, children, onSave, onDelete, validate}: DebugDetails
() =>
Object.entries(data ?? {})
.filter(
- (entry): entry is [string, string | ObjectType] =>
+ (entry): entry is [string, string | ObjectType>] =>
(typeof entry[1] === 'string' || typeof entry[1] === 'object') &&
- !DETAILS_CONSTANT_FIELDS.includes(entry[0] as DetailsConstantFieldsKeys) &&
- !DETAILS_DATETIME_FIELDS.includes(entry[0] as DetailsDatetimeFieldsKeys),
+ !DETAILS_CONSTANT_FIELDS[formType].some(({fieldName}) => fieldName === entry[0]) &&
+ !DETAILS_DATETIME_FIELDS.includes(entry[0]),
)
.map(([key, value]) => [key, DebugUtils.onyxDataToString(value)])
.sort((a, b) => (a.at(0) ?? '').localeCompare(b.at(0) ?? '')),
- [data],
- );
- const dateTimeFields = useMemo(
- () => Object.entries(data ?? {}).filter((entry): entry is [string, string] => DETAILS_DATETIME_FIELDS.includes(entry[0] as DetailsDatetimeFieldsKeys)),
- [data],
+ [data, formType],
);
+ const dateTimeFields = useMemo(() => Object.entries(data ?? {}).filter((entry): entry is [string, string] => DETAILS_DATETIME_FIELDS.includes(entry[0])), [data]);
const validator = useCallback(
(values: FormOnyxValues): FormInputErrors => {
@@ -161,7 +174,7 @@ function DebugDetails({data, children, onSave, onDelete, validate}: DebugDetails
numberOfLines={numberOfLines}
multiline={numberOfLines > 1}
defaultValue={value}
- disabled={DETAILS_DISABLED_KEYS.includes(key as DetailsDisabledKeys)}
+ disabled={DETAILS_DISABLED_KEYS.includes(key)}
shouldInterceptSwipe
/>
);
@@ -179,11 +192,11 @@ function DebugDetails({data, children, onSave, onDelete, validate}: DebugDetails
forceActiveLabel
label={key}
defaultValue={String(value)}
- disabled={DETAILS_DISABLED_KEYS.includes(key as DetailsDisabledKeys)}
+ disabled={DETAILS_DISABLED_KEYS.includes(key)}
shouldInterceptSwipe
/>
))}
- {numberFields.length === 0 && {translate('debug.none')}}
+ {numberFields.length === 0 && {translate('debug.none')}}
{translate('debug.constantFields')}
@@ -192,9 +205,11 @@ function DebugDetails({data, children, onSave, onDelete, validate}: DebugDetails
key={key}
InputComponent={ConstantSelector}
inputID={key}
+ formType={formType}
name={key}
shouldSaveDraft
defaultValue={String(value)}
+ policyID={report?.policyID}
/>
))}
{constantFields.length === 0 && {translate('debug.none')}}
@@ -225,7 +240,7 @@ function DebugDetails({data, children, onSave, onDelete, validate}: DebugDetails
defaultValue={value}
/>
))}
- {booleanFields.length === 0 && {translate('debug.none')}}
+ {booleanFields.length === 0 && {translate('debug.none')}}
{translate('debug.hint')}
diff --git a/src/pages/Debug/DebugDetailsConstantPickerPage.tsx b/src/pages/Debug/DebugDetailsConstantPickerPage.tsx
index a98ef9963542..fca11799fd5d 100644
--- a/src/pages/Debug/DebugDetailsConstantPickerPage.tsx
+++ b/src/pages/Debug/DebugDetailsConstantPickerPage.tsx
@@ -1,86 +1,98 @@
import type {StackScreenProps} from '@react-navigation/stack';
-import isObject from 'lodash/isObject';
-import React, {useMemo, useState} from 'react';
+import React, {useCallback} from 'react';
import {View} from 'react-native';
+import CategoryPicker from '@components/CategoryPicker';
+import CurrencySelectionList from '@components/CurrencySelectionList';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
-import SelectionList from '@components/SelectionList';
-import RadioListItem from '@components/SelectionList/RadioListItem';
import type {ListItem} from '@components/SelectionList/types';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import type {DebugParamList} from '@libs/Navigation/types';
import {appendParam} from '@libs/Url';
+import CONST from '@src/CONST';
import type SCREENS from '@src/SCREENS';
-import {DETAILS_CONSTANT_OPTIONS} from './const';
+import TRANSACTION_FORM_INPUT_IDS from '@src/types/form/DebugTransactionForm';
+import ConstantPicker from './ConstantPicker';
+import DebugTagPicker from './DebugTagPicker';
type DebugDetailsConstantPickerPageProps = StackScreenProps;
function DebugDetailsConstantPickerPage({
route: {
- params: {fieldName, fieldValue, backTo = ''},
+ params: {formType, fieldName, fieldValue, policyID = '', backTo = ''},
},
navigation,
}: DebugDetailsConstantPickerPageProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
- const [searchValue, setSearchValue] = useState('');
- const sections: ListItem[] = useMemo(
- () =>
- Object.entries(DETAILS_CONSTANT_OPTIONS[fieldName as keyof typeof DETAILS_CONSTANT_OPTIONS])
- .reduce((acc: Array<[string, string]>, [key, value]) => {
- // Option has multiple constants, so we need to flatten these into separate options
- if (isObject(value)) {
- acc.push(...Object.entries(value));
- return acc;
- }
- acc.push([key, value as string]);
- return acc;
- }, [])
- .map(
- ([key, value]) =>
- ({
- text: value,
- keyForList: key,
- isSelected: value === fieldValue,
- searchText: value,
- } satisfies ListItem),
- )
- .filter(({searchText}) => searchText.toLowerCase().includes(searchValue.toLowerCase())),
- [fieldName, fieldValue, searchValue],
+ const onSubmit = useCallback(
+ (item: ListItem) => {
+ const value = item.text === fieldValue ? '' : item.text ?? '';
+ // Check the navigation state and "backTo" parameter to decide navigation behavior
+ if (navigation.getState().routes.length === 1 && !backTo) {
+ // If there is only one route and "backTo" is empty, go back in navigation
+ Navigation.goBack();
+ } else if (!!backTo && navigation.getState().routes.length === 1) {
+ // If "backTo" is not empty and there is only one route, go back to the specific route defined in "backTo" with a country parameter
+ Navigation.goBack(appendParam(backTo, fieldName, value));
+ } else {
+ // Otherwise, navigate to the specific route defined in "backTo" with a country parameter
+ Navigation.navigate(appendParam(backTo, fieldName, value));
+ }
+ },
+ [backTo, fieldName, fieldValue, navigation],
);
- const onSubmit = (item: ListItem) => {
- const value = item.text === fieldValue ? '' : item.text ?? '';
- // Check the navigation state and "backTo" parameter to decide navigation behavior
- if (navigation.getState().routes.length === 1 && !backTo) {
- // If there is only one route and "backTo" is empty, go back in navigation
- Navigation.goBack();
- } else if (!!backTo && navigation.getState().routes.length === 1) {
- // If "backTo" is not empty and there is only one route, go back to the specific route defined in "backTo" with a country parameter
- Navigation.goBack(appendParam(backTo, fieldName, value));
- } else {
- // Otherwise, navigate to the specific route defined in "backTo" with a country parameter
- Navigation.navigate(appendParam(backTo, fieldName, value));
+
+ const renderPicker = useCallback(() => {
+ if (([TRANSACTION_FORM_INPUT_IDS.CURRENCY, TRANSACTION_FORM_INPUT_IDS.MODIFIED_CURRENCY, TRANSACTION_FORM_INPUT_IDS.ORIGINAL_CURRENCY] as string[]).includes(fieldName)) {
+ return (
+
+ onSubmit({
+ text: currencyCode,
+ })
+ }
+ searchInputLabel={translate('common.search')}
+ />
+ );
}
- };
- const selectedOptionKey = useMemo(() => sections.filter((option) => option.searchText === fieldValue).at(0)?.keyForList, [sections, fieldValue]);
+ if (formType === CONST.DEBUG.FORMS.TRANSACTION) {
+ if (fieldName === TRANSACTION_FORM_INPUT_IDS.CATEGORY) {
+ return (
+
+ );
+ }
+ if (fieldName === TRANSACTION_FORM_INPUT_IDS.TAG) {
+ return (
+
+ );
+ }
+ }
+
+ return (
+
+ );
+ }, [fieldName, fieldValue, formType, onSubmit, policyID, translate]);
return (
-
-
-
+ {renderPicker()}
);
}
diff --git a/src/pages/Debug/DebugTagPicker.tsx b/src/pages/Debug/DebugTagPicker.tsx
new file mode 100644
index 000000000000..1aa24d359a3a
--- /dev/null
+++ b/src/pages/Debug/DebugTagPicker.tsx
@@ -0,0 +1,82 @@
+import React, {useCallback, useMemo, useState} from 'react';
+import {View} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
+import Button from '@components/Button';
+import type {ListItem} from '@components/SelectionList/types';
+import TagPicker from '@components/TagPicker';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as IOUUtils from '@libs/IOUUtils';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import * as TransactionUtils from '@libs/TransactionUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+type DebugTagPickerProps = {
+ /** The policyID we are getting tags for */
+ policyID: string;
+
+ /** Current tag name */
+ tagName?: string;
+
+ /** Callback to submit the selected tag */
+ onSubmit: (item: ListItem) => void;
+};
+
+function DebugTagPicker({policyID, tagName = '', onSubmit}: DebugTagPickerProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const [newTagName, setNewTagName] = useState(tagName);
+ const selectedTags = useMemo(() => TransactionUtils.getTagArrayFromName(newTagName), [newTagName]);
+ const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`);
+ const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]);
+
+ const updateTagName = useCallback(
+ (index: number) =>
+ ({text}: ListItem) => {
+ const newTag = text === selectedTags.at(index) ? undefined : text;
+ const updatedTagName = IOUUtils.insertTagIntoTransactionTagsString(newTagName, newTag ?? '', index);
+ if (policyTagLists.length === 1) {
+ return onSubmit({text: updatedTagName});
+ }
+ setNewTagName(updatedTagName);
+ },
+ [newTagName, onSubmit, policyTagLists.length, selectedTags],
+ );
+
+ const submitTag = useCallback(() => {
+ onSubmit({text: newTagName});
+ }, [newTagName, onSubmit]);
+
+ return (
+
+
+ {policyTagLists.map(({name}, index) => (
+
+ {policyTagLists.length > 1 && {name}}
+
+
+ ))}
+
+ {policyTagLists.length > 1 && (
+
+
+
+ )}
+
+ );
+}
+
+export default DebugTagPicker;
diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx
index 67fa0a6c5113..59f2e8ddf3c5 100644
--- a/src/pages/Debug/Report/DebugReportPage.tsx
+++ b/src/pages/Debug/Report/DebugReportPage.tsx
@@ -53,6 +53,7 @@ function DebugReportPage({
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`);
const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
+ const transactionID = DebugUtils.getTransactionID(report, reportActions);
const metadata = useMemo(() => {
if (!report) {
@@ -137,12 +138,13 @@ function DebugReportPage({
{() => (
{
- Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, data);
+ Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, data);
}}
onDelete={() => {
- Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null);
+ Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null);
navigateToConciergeChatAndDeleteReport(reportID, true, true);
}}
validate={DebugUtils.validateReportDraftProperty}
@@ -170,6 +172,14 @@ function DebugReportPage({
}}
icon={Expensicons.Eye}
/>
+ {!!transactionID && (
+
)}
diff --git a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx
index 0b260a8bc93e..c1ef0e0fdfec 100644
--- a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx
+++ b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx
@@ -114,7 +114,7 @@ function DebugReportActionCreatePage({
isDisabled={!draftReportAction || !!error}
onPress={() => {
const parsedReportAction = JSON.parse(draftReportAction.replaceAll('\n', '')) as ReportAction;
- Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[parsedReportAction.reportActionID]: parsedReportAction});
+ Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[parsedReportAction.reportActionID]: parsedReportAction});
Navigation.navigate(ROUTES.DEBUG_REPORT_TAB_ACTIONS.getRoute(reportID));
}}
/>
diff --git a/src/pages/Debug/ReportAction/DebugReportActionPage.tsx b/src/pages/Debug/ReportAction/DebugReportActionPage.tsx
index 68743278d871..c2c0b07440f1 100644
--- a/src/pages/Debug/ReportAction/DebugReportActionPage.tsx
+++ b/src/pages/Debug/ReportAction/DebugReportActionPage.tsx
@@ -2,6 +2,7 @@ import type {StackScreenProps} from '@react-navigation/stack';
import React from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
+import Button from '@components/Button';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import TabSelector from '@components/TabSelector/TabSelector';
@@ -12,11 +13,13 @@ import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import Navigation from '@libs/Navigation/Navigation';
import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator';
import type {DebugParamList} from '@libs/Navigation/types';
+import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import DebugDetails from '@pages/Debug/DebugDetails';
import DebugJSON from '@pages/Debug/DebugJSON';
import Debug from '@userActions/Debug';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import DebugReportActionPreview from './DebugReportActionPreview';
@@ -33,6 +36,7 @@ function DebugReportActionPage({
canEvict: false,
selector: (reportActions) => reportActions?.[reportActionID],
});
+ const transactionID = ReportActionsUtils.getLinkedTransactionID(reportAction);
return (
{() => (
{
- Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: data});
+ Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: data});
}}
onDelete={() => {
- Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: null});
+ Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: null});
Navigation.goBack();
}}
validate={DebugUtils.validateReportActionDraftProperty}
- />
+ >
+ {!!transactionID && (
+
+
+ )}
+
)}
{() => }
diff --git a/src/pages/Debug/Transaction/DebugTransactionPage.tsx b/src/pages/Debug/Transaction/DebugTransactionPage.tsx
new file mode 100644
index 000000000000..ed9e945799d3
--- /dev/null
+++ b/src/pages/Debug/Transaction/DebugTransactionPage.tsx
@@ -0,0 +1,93 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React from 'react';
+import {View} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
+import Button from '@components/Button';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import TabSelector from '@components/TabSelector/TabSelector';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Debug from '@libs/actions/Debug';
+import DebugUtils from '@libs/DebugUtils';
+import * as DeviceCapabilities from '@libs/DeviceCapabilities';
+import Navigation from '@libs/Navigation/Navigation';
+import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator';
+import type {DebugParamList} from '@libs/Navigation/types';
+import DebugDetails from '@pages/Debug/DebugDetails';
+import DebugJSON from '@pages/Debug/DebugJSON';
+import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import DebugTransactionViolations from './DebugTransactionViolations';
+
+type DebugTransactionPageProps = StackScreenProps;
+
+function DebugTransactionPage({
+ route: {
+ params: {transactionID},
+ },
+}: DebugTransactionPageProps) {
+ const {translate} = useLocalize();
+ const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`);
+ const styles = useThemeStyles();
+
+ if (!transaction) {
+ return ;
+ }
+
+ return (
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+
+
+
+ {() => (
+ {
+ Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, data);
+ }}
+ onDelete={() => {
+ Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, null);
+ }}
+ validate={DebugUtils.validateTransactionDraftProperty}
+ >
+
+
+
+ )}
+
+ {() => }
+ {() => }
+
+
+ )}
+
+ );
+}
+
+DebugTransactionPage.displayName = 'DebugTransactionPage';
+
+export default DebugTransactionPage;
diff --git a/src/pages/Debug/Transaction/DebugTransactionViolations.tsx b/src/pages/Debug/Transaction/DebugTransactionViolations.tsx
new file mode 100644
index 000000000000..d3e37f726a96
--- /dev/null
+++ b/src/pages/Debug/Transaction/DebugTransactionViolations.tsx
@@ -0,0 +1,58 @@
+import React from 'react';
+import type {ListRenderItemInfo} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
+import Button from '@components/Button';
+import FlatList from '@components/FlatList';
+import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import ScrollView from '@components/ScrollView';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type {TransactionViolation} from '@src/types/onyx';
+
+type DebugTransactionViolationsProps = {
+ /** The transactionID we are gettings the transaction violations for */
+ transactionID: string;
+};
+
+function DebugTransactionViolations({transactionID}: DebugTransactionViolationsProps) {
+ const [transactionViolations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`);
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const renderItem = ({item, index}: ListRenderItemInfo) => (
+ Navigation.navigate(ROUTES.DEBUG_TRANSACTION_VIOLATION.getRoute(transactionID, String(index)))}
+ style={({pressed}) => [styles.flexRow, styles.justifyContentBetween, pressed && styles.hoveredComponentBG, styles.p4]}
+ hoverStyle={styles.hoveredComponentBG}
+ >
+ {item.type}
+ {item.name}
+
+ );
+
+ return (
+
+
+ );
+}
+
+DebugTransactionViolations.displayName = 'DebugTransactionViolations';
+
+export default DebugTransactionViolations;
diff --git a/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx b/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx
new file mode 100644
index 000000000000..4e36ab8fcd12
--- /dev/null
+++ b/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx
@@ -0,0 +1,137 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useCallback, useState} from 'react';
+import {View} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
+import Button from '@components/Button';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import ScrollView from '@components/ScrollView';
+import Text from '@components/Text';
+import TextInput from '@components/TextInput';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import DebugUtils from '@libs/DebugUtils';
+import * as DeviceCapabilities from '@libs/DeviceCapabilities';
+import Navigation from '@libs/Navigation/Navigation';
+import type {DebugParamList} from '@libs/Navigation/types';
+import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
+import Debug from '@userActions/Debug';
+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 {TransactionViolation} from '@src/types/onyx';
+
+type DebugTransactionViolationCreatePageProps = StackScreenProps;
+
+const getInitialTransactionViolation = () =>
+ DebugUtils.stringifyJSON({
+ type: CONST.VIOLATION_TYPES.VIOLATION,
+ name: CONST.VIOLATIONS.MISSING_CATEGORY,
+ data: {
+ rejectedBy: undefined,
+ rejectReason: undefined,
+ formattedLimit: undefined,
+ surcharge: undefined,
+ invoiceMarkup: undefined,
+ maxAge: undefined,
+ tagName: undefined,
+ category: undefined,
+ brokenBankConnection: undefined,
+ isAdmin: undefined,
+ email: undefined,
+ isTransactionOlderThan7Days: false,
+ member: undefined,
+ taxName: undefined,
+ tagListIndex: undefined,
+ tagListName: undefined,
+ errorIndexes: [],
+ pendingPattern: undefined,
+ type: undefined,
+ displayPercentVariance: undefined,
+ duplicates: [],
+ rterType: undefined,
+ },
+ } satisfies TransactionViolation);
+
+function DebugTransactionViolationCreatePage({
+ route: {
+ params: {transactionID},
+ },
+}: DebugTransactionViolationCreatePageProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const [transactionViolations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`);
+ const [draftTransactionViolation, setDraftTransactionViolation] = useState(getInitialTransactionViolation());
+ const [error, setError] = useState();
+
+ const editJSON = useCallback(
+ (updatedJSON: string) => {
+ try {
+ DebugUtils.validateTransactionViolationJSON(updatedJSON);
+ setError('');
+ } catch (e) {
+ const {cause, message} = e as SyntaxError;
+ setError(cause ? translate(message as TranslationPaths, cause as never) : message);
+ } finally {
+ setDraftTransactionViolation(updatedJSON);
+ }
+ },
+ [translate],
+ );
+
+ const createTransactionViolation = useCallback(() => {
+ const parsedTransactionViolation = DebugUtils.stringToOnyxData(draftTransactionViolation, 'object') as TransactionViolation;
+ Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [...(transactionViolations ?? []), parsedTransactionViolation]);
+ Navigation.navigate(ROUTES.DEBUG_TRANSACTION_TAB_VIOLATIONS.getRoute(transactionID));
+ }, [draftTransactionViolation, transactionID, transactionViolations]);
+
+ if (!transactionID) {
+ return ;
+ }
+
+ return (
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+
+
+
+ {translate('debug.editJson')}
+
+
+ {translate('debug.hint')}
+
+
+
+ )}
+
+ );
+}
+
+DebugTransactionViolationCreatePage.displayName = 'DebugTransactionViolationCreatePage';
+
+export default DebugTransactionViolationCreatePage;
diff --git a/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx b/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx
new file mode 100644
index 000000000000..6750004f3347
--- /dev/null
+++ b/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx
@@ -0,0 +1,93 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useCallback, useMemo} from 'react';
+import {View} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import TabSelector from '@components/TabSelector/TabSelector';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Debug from '@libs/actions/Debug';
+import DebugUtils from '@libs/DebugUtils';
+import * as DeviceCapabilities from '@libs/DeviceCapabilities';
+import Navigation from '@libs/Navigation/Navigation';
+import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator';
+import type {DebugParamList} from '@libs/Navigation/types';
+import DebugDetails from '@pages/Debug/DebugDetails';
+import DebugJSON from '@pages/Debug/DebugJSON';
+import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
+import type {TransactionViolation} from '@src/types/onyx';
+
+type DebugTransactionViolationPageProps = StackScreenProps;
+
+function DebugTransactionViolationPage({
+ route: {
+ params: {transactionID, index},
+ },
+}: DebugTransactionViolationPageProps) {
+ const {translate} = useLocalize();
+ const [transactionViolations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`);
+ const transactionViolation = useMemo(() => transactionViolations?.[Number(index)], [index, transactionViolations]);
+ const styles = useThemeStyles();
+
+ const saveChanges = useCallback(
+ (data: Record) => {
+ const updatedTransactionViolations = [...(transactionViolations ?? [])];
+ updatedTransactionViolations.splice(Number(index), 1, data as TransactionViolation);
+ Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, updatedTransactionViolations);
+ },
+ [index, transactionID, transactionViolations],
+ );
+
+ const deleteTransactionViolation = useCallback(() => {
+ const updatedTransactionViolations = [...(transactionViolations ?? [])];
+ updatedTransactionViolations.splice(Number(index), 1);
+ Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, updatedTransactionViolations);
+ }, [index, transactionID, transactionViolations]);
+
+ if (!transactionViolation) {
+ return ;
+ }
+
+ return (
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+
+
+
+ {() => (
+
+ )}
+
+ {() => }
+
+
+ )}
+
+ );
+}
+
+DebugTransactionViolationPage.displayName = 'DebugTransactionViolationPage';
+
+export default DebugTransactionViolationPage;
diff --git a/src/pages/Debug/const.ts b/src/pages/Debug/const.ts
index 907a31f03ebf..b98741228d6b 100644
--- a/src/pages/Debug/const.ts
+++ b/src/pages/Debug/const.ts
@@ -1,30 +1,123 @@
-import type {TupleToUnion} from 'type-fest';
+import type {ValueOf} from 'type-fest';
import CONST from '@src/CONST';
import ACTION_FORM_INPUT_IDS from '@src/types/form/DebugReportActionForm';
import REPORT_FORM_INPUT_IDS from '@src/types/form/DebugReportForm';
+import TRANSACTION_FORM_INPUT_IDS from '@src/types/form/DebugTransactionForm';
+import TRANSACTION_VIOLATION_FORM_INPUT_IDS from '@src/types/form/DebugTransactionViolationForm';
-const DETAILS_CONSTANT_OPTIONS = {
- chatType: CONST.REPORT.CHAT_TYPE,
- currency: CONST.CURRENCY,
- notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE,
- type: CONST.REPORT.TYPE,
- writeCapability: CONST.REPORT.WRITE_CAPABILITIES,
- lastActionType: CONST.REPORT.ACTIONS.TYPE,
- actionName: CONST.REPORT.ACTIONS.TYPE,
+type DebugForms = ValueOf;
+
+type ConstantField = {
+ fieldName: string;
+ options?: Record>;
};
-const LAST_ACTION_TYPE = 'lastActionType';
-const ACTION_NAME = 'actionName';
+type DetailsConstantFields = Record;
-const DETAILS_CONSTANT_FIELDS = [
- ACTION_FORM_INPUT_IDS.ACTION_NAME,
- REPORT_FORM_INPUT_IDS.CHAT_TYPE,
- REPORT_FORM_INPUT_IDS.CURRENCY,
- REPORT_FORM_INPUT_IDS.NOTIFICATION_PREFERENCE,
- REPORT_FORM_INPUT_IDS.TYPE,
- REPORT_FORM_INPUT_IDS.LAST_ACTION_TYPE,
- REPORT_FORM_INPUT_IDS.WRITE_CAPABILITY,
-];
+const DETAILS_CONSTANT_FIELDS: DetailsConstantFields = {
+ [CONST.DEBUG.FORMS.REPORT]: [
+ {
+ fieldName: REPORT_FORM_INPUT_IDS.CHAT_TYPE,
+ options: CONST.REPORT.CHAT_TYPE,
+ },
+ {
+ fieldName: REPORT_FORM_INPUT_IDS.CURRENCY,
+ options: CONST.CURRENCY,
+ },
+ {
+ fieldName: REPORT_FORM_INPUT_IDS.NOTIFICATION_PREFERENCE,
+ options: CONST.REPORT.NOTIFICATION_PREFERENCE,
+ },
+ {
+ fieldName: REPORT_FORM_INPUT_IDS.TYPE,
+ options: CONST.REPORT.TYPE,
+ },
+ {
+ fieldName: REPORT_FORM_INPUT_IDS.LAST_ACTION_TYPE,
+ options: CONST.REPORT.ACTIONS.TYPE,
+ },
+ {
+ fieldName: REPORT_FORM_INPUT_IDS.WRITE_CAPABILITY,
+ options: CONST.REPORT.WRITE_CAPABILITIES,
+ },
+ {
+ fieldName: REPORT_FORM_INPUT_IDS.VISIBILITY,
+ options: CONST.REPORT.VISIBILITY,
+ },
+ {
+ fieldName: REPORT_FORM_INPUT_IDS.STATE_NUM,
+ options: CONST.REPORT.STATE_NUM,
+ },
+ {
+ fieldName: REPORT_FORM_INPUT_IDS.STATUS_NUM,
+ options: CONST.REPORT.STATUS_NUM,
+ },
+ ],
+ [CONST.DEBUG.FORMS.REPORT_ACTION]: [
+ {
+ fieldName: ACTION_FORM_INPUT_IDS.ACTION_NAME,
+ options: CONST.REPORT.ACTIONS.TYPE,
+ },
+ {
+ fieldName: ACTION_FORM_INPUT_IDS.CHILD_STATUS_NUM,
+ options: CONST.REPORT.STATUS_NUM,
+ },
+ {
+ fieldName: ACTION_FORM_INPUT_IDS.CHILD_STATE_NUM,
+ options: CONST.REPORT.STATE_NUM,
+ },
+ {
+ fieldName: ACTION_FORM_INPUT_IDS.CHILD_REPORT_NOTIFICATION_PREFERENCE,
+ options: CONST.REPORT.NOTIFICATION_PREFERENCE,
+ },
+ ],
+ [CONST.DEBUG.FORMS.TRANSACTION]: [
+ {
+ fieldName: TRANSACTION_FORM_INPUT_IDS.IOU_REQUEST_TYPE,
+ options: CONST.IOU.REQUEST_TYPE,
+ },
+ {
+ fieldName: TRANSACTION_FORM_INPUT_IDS.MODIFIED_CURRENCY,
+ options: CONST.CURRENCY,
+ },
+ {
+ fieldName: TRANSACTION_FORM_INPUT_IDS.CURRENCY,
+ options: CONST.CURRENCY,
+ },
+ {
+ fieldName: TRANSACTION_FORM_INPUT_IDS.ORIGINAL_CURRENCY,
+ options: CONST.CURRENCY,
+ },
+ {
+ fieldName: TRANSACTION_FORM_INPUT_IDS.STATUS,
+ options: CONST.TRANSACTION.STATUS,
+ },
+ {
+ fieldName: TRANSACTION_FORM_INPUT_IDS.MCC_GROUP,
+ options: CONST.MCC_GROUPS,
+ },
+ {
+ fieldName: TRANSACTION_FORM_INPUT_IDS.MODIFIED_MCC_GROUP,
+ options: CONST.MCC_GROUPS,
+ },
+ {
+ fieldName: TRANSACTION_FORM_INPUT_IDS.CATEGORY,
+ },
+ {
+ fieldName: TRANSACTION_FORM_INPUT_IDS.TAG,
+ },
+ ],
+ [CONST.DEBUG.FORMS.TRANSACTION_VIOLATION]: [
+ {
+ fieldName: TRANSACTION_VIOLATION_FORM_INPUT_IDS.NAME,
+ options: CONST.VIOLATIONS,
+ },
+ {
+ fieldName: TRANSACTION_VIOLATION_FORM_INPUT_IDS.TYPE,
+ options: CONST.VIOLATION_TYPES,
+ },
+ ],
+};
const DETAILS_DATETIME_FIELDS = [
ACTION_FORM_INPUT_IDS.CREATED,
@@ -32,14 +125,15 @@ const DETAILS_DATETIME_FIELDS = [
REPORT_FORM_INPUT_IDS.LAST_READ_TIME,
REPORT_FORM_INPUT_IDS.LAST_VISIBLE_ACTION_CREATED,
REPORT_FORM_INPUT_IDS.LAST_VISIBLE_ACTION_LAST_MODIFIED,
-];
-
-const DETAILS_DISABLED_KEYS = [ACTION_FORM_INPUT_IDS.REPORT_ACTION_ID, REPORT_FORM_INPUT_IDS.REPORT_ID, REPORT_FORM_INPUT_IDS.POLICY_ID];
-
-type DetailsConstantFieldsKeys = TupleToUnion;
+ TRANSACTION_FORM_INPUT_IDS.MODIFIED_CREATED,
+] as string[];
-type DetailsDatetimeFieldsKeys = TupleToUnion;
-type DetailsDisabledKeys = TupleToUnion;
+const DETAILS_DISABLED_KEYS = [
+ ACTION_FORM_INPUT_IDS.REPORT_ACTION_ID,
+ REPORT_FORM_INPUT_IDS.REPORT_ID,
+ REPORT_FORM_INPUT_IDS.POLICY_ID,
+ TRANSACTION_FORM_INPUT_IDS.TRANSACTION_ID,
+] as string[];
-export type {DetailsConstantFieldsKeys, DetailsDatetimeFieldsKeys, DetailsDisabledKeys};
-export {DETAILS_CONSTANT_OPTIONS, LAST_ACTION_TYPE, ACTION_NAME, DETAILS_CONSTANT_FIELDS, DETAILS_DATETIME_FIELDS, DETAILS_DISABLED_KEYS};
+export {DETAILS_CONSTANT_FIELDS, DETAILS_DATETIME_FIELDS, DETAILS_DISABLED_KEYS};
+export type {DebugForms};
diff --git a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx
index faf531765edd..d9f0005ff4a0 100644
--- a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx
+++ b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx
@@ -28,6 +28,7 @@ import CONST from '@src/CONST';
import type {OnboardingAccounting} from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {} from '@src/types/onyx/Bank';
+import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
import type {BaseOnboardingAccountingProps} from './types';
type OnboardingListItem = ListItem & {
@@ -45,7 +46,7 @@ function BaseOnboardingAccounting({shouldUseNativeStyles, route}: BaseOnboarding
const {onboardingIsMediumOrLargerScreenWidth, isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
const [onboardingValues] = useOnyx(ONYXKEYS.NVP_ONBOARDING);
const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED);
- const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID);
+ const [onboardingPolicyID, onboardingPolicyIDResult] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID);
const [onboardingAdminsChatReportID] = useOnyx(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID);
const [onboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE);
const {canUseDefaultRooms} = usePermissions();
@@ -58,14 +59,14 @@ function BaseOnboardingAccounting({shouldUseNativeStyles, route}: BaseOnboarding
// If the signupQualifier is VSB, the company size step is skip.
// So we need to create the new workspace in the accounting step
useEffect(() => {
- if (!isVsb || !!onboardingPolicyID) {
+ if (!isVsb || !!onboardingPolicyID || isLoadingOnyxValue(onboardingPolicyIDResult)) {
return;
}
const {adminsChatReportID, policyID} = Policy.createWorkspace(undefined, true, '', Policy.generatePolicyID(), CONST.ONBOARDING_CHOICES.MANAGE_TEAM);
Welcome.setOnboardingAdminsChatReportID(adminsChatReportID);
Welcome.setOnboardingPolicyID(policyID);
- }, [isVsb, onboardingPolicyID]);
+ }, [isVsb, onboardingPolicyID, onboardingPolicyIDResult]);
const accountingOptions: OnboardingListItem[] = useMemo(() => {
const policyAccountingOptions = Object.values(CONST.POLICY.CONNECTIONS.NAME)
diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx
index f5a0e47c5de6..8bf3da6e33e0 100644
--- a/src/pages/ReportDetailsPage.tsx
+++ b/src/pages/ReportDetailsPage.tsx
@@ -769,7 +769,7 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
const nameSectionTitleField = !!titleField && (
{
setIsBannerVisible(false);
diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx
index 2e92669aa8c5..1d7deea43a04 100755
--- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx
+++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx
@@ -76,6 +76,12 @@ type BaseReportActionContextMenuProps = {
/** Flag to check if the chat is unread in the LHN. Used for the Mark as Read/Unread action */
isUnreadChat?: boolean;
+ /**
+ * Is the action a thread's parent reportAction viewed from within the thread report?
+ * It will be false if we're viewing the same parent report action from the report it belongs to rather than the thread.
+ */
+ isThreadReportParentAction?: boolean;
+
/** Content Ref */
contentRef?: RefObject;
@@ -101,6 +107,7 @@ function BaseReportActionContextMenu({
isVisible = false,
isPinnedChat = false,
isUnreadChat = false,
+ isThreadReportParentAction = false,
selection = '',
draftMessage = '',
reportActionID,
@@ -194,6 +201,7 @@ function BaseReportActionContextMenu({
reportID,
isPinnedChat,
isUnreadChat,
+ isThreadReportParentAction,
isOffline: !!isOffline,
isMini,
isProduction,
@@ -284,6 +292,7 @@ function BaseReportActionContextMenu({
true,
() => {},
true,
+ isThreadReportParentAction,
);
};
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
index a3dc9cbba25f..705b85d4c3fc 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
@@ -64,6 +64,7 @@ type ShouldShow = (args: {
reportID: string;
isPinnedChat: boolean;
isUnreadChat: boolean;
+ isThreadReportParentAction: boolean;
isOffline: boolean;
isMini: boolean;
isProduction: boolean;
@@ -178,11 +179,11 @@ const ContextMenuActions: ContextMenuAction[] = [
isAnonymousAction: false,
textTranslateKey: 'reportActionContextMenu.replyInThread',
icon: Expensicons.ChatBubbleReply,
- shouldShow: ({type, reportAction, reportID}) => {
+ shouldShow: ({type, reportAction, reportID, isThreadReportParentAction}) => {
if (type !== CONST.CONTEXT_MENU_TYPES.REPORT_ACTION) {
return false;
}
- return !ReportUtils.shouldDisableThread(reportAction, reportID);
+ return !ReportUtils.shouldDisableThread(reportAction, reportID, isThreadReportParentAction);
},
onPress: (closePopover, {reportAction, reportID}) => {
const originalReportID = ReportUtils.getOriginalReportID(reportID, reportAction);
@@ -301,16 +302,22 @@ const ContextMenuActions: ContextMenuAction[] = [
isAnonymousAction: false,
textTranslateKey: 'reportActionContextMenu.joinThread',
icon: Expensicons.Bell,
- shouldShow: ({reportAction, isArchivedRoom, reportID}) => {
+ shouldShow: ({reportAction, isArchivedRoom, isThreadReportParentAction}) => {
const childReportNotificationPreference = ReportUtils.getChildReportNotificationPreference(reportAction);
const isDeletedAction = ReportActionsUtils.isDeletedAction(reportAction);
- const shouldDisplayThreadReplies = ReportUtils.shouldDisplayThreadReplies(reportAction, reportID);
+ const shouldDisplayThreadReplies = ReportUtils.shouldDisplayThreadReplies(reportAction, isThreadReportParentAction);
const subscribed = childReportNotificationPreference !== 'hidden';
- const isThreadFirstChat = ReportUtils.isThreadFirstChat(reportAction, reportID);
const isWhisperAction = ReportActionsUtils.isWhisperAction(reportAction) || ReportActionsUtils.isActionableTrackExpense(reportAction);
const isExpenseReportAction = ReportActionsUtils.isMoneyRequestAction(reportAction) || ReportActionsUtils.isReportPreviewAction(reportAction);
const isTaskAction = ReportActionsUtils.isCreatedTaskReportAction(reportAction);
- return !subscribed && !isWhisperAction && !isTaskAction && !isExpenseReportAction && !isThreadFirstChat && (shouldDisplayThreadReplies || (!isDeletedAction && !isArchivedRoom));
+ return (
+ !subscribed &&
+ !isWhisperAction &&
+ !isTaskAction &&
+ !isExpenseReportAction &&
+ !isThreadReportParentAction &&
+ (shouldDisplayThreadReplies || (!isDeletedAction && !isArchivedRoom))
+ );
},
onPress: (closePopover, {reportAction, reportID}) => {
const childReportNotificationPreference = ReportUtils.getChildReportNotificationPreference(reportAction);
diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx
index f387f09d3880..1d7311ef526f 100644
--- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx
+++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx
@@ -62,6 +62,7 @@ function PopoverReportActionContextMenu(_props: unknown, ref: ForwardedRef([]);
const [shoudSwitchPositionIfOverflow, setShoudSwitchPositionIfOverflow] = useState(false);
@@ -172,6 +173,7 @@ function PopoverReportActionContextMenu(_props: unknown, ref: ForwardedRef {},
isOverflowMenu = false,
+ isThreadReportParentActionParam = false,
) => {
const {pageX = 0, pageY = 0} = extractPointerEvent(event);
contextMenuAnchorRef.current = contextMenuAnchor;
@@ -220,6 +222,7 @@ function PopoverReportActionContextMenu(_props: unknown, ref: ForwardedRef void,
isOverflowMenu?: boolean,
+ isThreadReportParentAction?: boolean,
) => void;
type ReportActionContextMenu = {
@@ -118,6 +119,7 @@ function showContextMenu(
shouldCloseOnTarget = false,
setIsEmojiPickerActive = () => {},
isOverflowMenu = false,
+ isThreadReportParentAction = false,
) {
if (!contextMenuRef.current) {
return;
@@ -142,6 +144,7 @@ function showContextMenu(
shouldCloseOnTarget,
setIsEmojiPickerActive,
isOverflowMenu,
+ isThreadReportParentAction,
);
};
diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx
index 559d635f73fe..399550069c0a 100644
--- a/src/pages/home/report/ReportActionItem.tsx
+++ b/src/pages/home/report/ReportActionItem.tsx
@@ -131,11 +131,15 @@ type ReportActionItemProps = {
/** If this is the first visible report action */
isFirstVisibleReportAction: boolean;
+ /**
+ * Is the action a thread's parent reportAction viewed from within the thread report?
+ * It will be false if we're viewing the same parent report action from the report it belongs to rather than the thread.
+ */
+ isThreadReportParentAction?: boolean;
+
/** IF the thread divider line will be used */
shouldUseThreadDividerLine?: boolean;
- hideThreadReplies?: boolean;
-
/** Whether context menu should be displayed */
shouldDisplayContextMenu?: boolean;
};
@@ -154,8 +158,8 @@ function ReportActionItem({
shouldShowSubscriptAvatar = false,
onPress = undefined,
isFirstVisibleReportAction = false,
+ isThreadReportParentAction = false,
shouldUseThreadDividerLine = false,
- hideThreadReplies = false,
shouldDisplayContextMenu = true,
parentReportActionForTransactionThread,
}: ReportActionItemProps) {
@@ -356,9 +360,22 @@ function ReportActionItem({
disabledActions,
false,
setIsEmojiPickerActive as () => void,
+ undefined,
+ isThreadReportParentAction,
);
},
- [draftMessage, action, reportID, toggleContextMenuFromActiveReportAction, originalReportID, shouldDisplayContextMenu, disabledActions, isArchivedRoom, isChronosReport],
+ [
+ draftMessage,
+ action,
+ reportID,
+ toggleContextMenuFromActiveReportAction,
+ originalReportID,
+ shouldDisplayContextMenu,
+ disabledActions,
+ isArchivedRoom,
+ isChronosReport,
+ isThreadReportParentAction,
+ ],
);
// Handles manual scrolling to the bottom of the chat when the last message is an actionable whisper and it's resolved.
@@ -784,7 +801,7 @@ function ReportActionItem({
}
const numberOfThreadReplies = action.childVisibleActionCount ?? 0;
- const shouldDisplayThreadReplies = !hideThreadReplies && ReportUtils.shouldDisplayThreadReplies(action, reportID);
+ const shouldDisplayThreadReplies = ReportUtils.shouldDisplayThreadReplies(action, isThreadReportParentAction);
const oldestFourAccountIDs =
action.childOldestFourAccountIDs
?.split(',')
@@ -970,6 +987,7 @@ function ReportActionItem({
displayAsGroup={displayAsGroup}
disabledActions={disabledActions}
isVisible={hovered && draftMessage === undefined && !hasErrors}
+ isThreadReportParentAction={isThreadReportParentAction}
draftMessage={draftMessage}
isChronosReport={isChronosReport}
checkIfContextMenuActive={toggleContextMenuFromActiveReportAction}
diff --git a/src/pages/home/report/ReportActionItemFragment.tsx b/src/pages/home/report/ReportActionItemFragment.tsx
index 787904d72b81..05cb657b1e54 100644
--- a/src/pages/home/report/ReportActionItemFragment.tsx
+++ b/src/pages/home/report/ReportActionItemFragment.tsx
@@ -2,7 +2,6 @@ import React, {memo} from 'react';
import type {StyleProp, TextStyle} from 'react-native';
import RenderHTML from '@components/RenderHTML';
import Text from '@components/Text';
-import UserDetailsTooltip from '@components/UserDetailsTooltip';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -15,6 +14,7 @@ import type {Message} from '@src/types/onyx/ReportAction';
import type ReportActionName from '@src/types/onyx/ReportActionName';
import AttachmentCommentFragment from './comment/AttachmentCommentFragment';
import TextCommentFragment from './comment/TextCommentFragment';
+import ReportActionItemMessageHeaderSender from './ReportActionItemMessageHeaderSender';
type ReportActionItemFragmentProps = {
/** Users accountID */
@@ -160,18 +160,13 @@ function ReportActionItemFragment({
}
return (
-
-
- {fragment?.text}
-
-
+ fragmentText={fragment.text}
+ actorIcon={actorIcon}
+ isSingleLine={isSingleLine}
+ />
);
}
case 'LINK':
diff --git a/src/pages/home/report/ReportActionItemMessageHeaderSender/index.native.tsx b/src/pages/home/report/ReportActionItemMessageHeaderSender/index.native.tsx
new file mode 100644
index 000000000000..9a752c3a9007
--- /dev/null
+++ b/src/pages/home/report/ReportActionItemMessageHeaderSender/index.native.tsx
@@ -0,0 +1,30 @@
+import React, {useMemo} from 'react';
+import Text from '@components/Text';
+import UserDetailsTooltip from '@components/UserDetailsTooltip';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as EmojiUtils from '@libs/EmojiUtils';
+import type ReportActionItemMessageHeaderSenderProps from './types';
+
+function ReportActionItemMessageHeaderSender({fragmentText, accountID, delegateAccountID, actorIcon, isSingleLine}: ReportActionItemMessageHeaderSenderProps) {
+ const styles = useThemeStyles();
+ const processedTextArray = useMemo(() => EmojiUtils.splitTextWithEmojis(fragmentText), [fragmentText]);
+
+ return (
+
+
+ {processedTextArray.length !== 0 ? EmojiUtils.getProcessedText(processedTextArray, [styles.emojisWithTextFontSize, styles.emojisWithTextFontFamily]) : fragmentText}
+
+
+ );
+}
+
+ReportActionItemMessageHeaderSender.displayName = 'ReportActionItemMessageHeaderSender';
+
+export default ReportActionItemMessageHeaderSender;
diff --git a/src/pages/home/report/ReportActionItemMessageHeaderSender/index.tsx b/src/pages/home/report/ReportActionItemMessageHeaderSender/index.tsx
new file mode 100644
index 000000000000..d5602dbedfae
--- /dev/null
+++ b/src/pages/home/report/ReportActionItemMessageHeaderSender/index.tsx
@@ -0,0 +1,30 @@
+import React, {useMemo} from 'react';
+import Text from '@components/Text';
+import UserDetailsTooltip from '@components/UserDetailsTooltip';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as EmojiUtils from '@libs/EmojiUtils';
+import type ReportActionItemMessageHeaderSenderProps from './types';
+
+function ReportActionItemMessageHeaderSender({fragmentText, accountID, delegateAccountID, actorIcon, isSingleLine}: ReportActionItemMessageHeaderSenderProps) {
+ const styles = useThemeStyles();
+ const processedTextArray = useMemo(() => EmojiUtils.splitTextWithEmojis(fragmentText), [fragmentText]);
+
+ return (
+
+
+ {processedTextArray.length !== 0 ? EmojiUtils.getProcessedText(processedTextArray, styles.emojisWithTextFontSize) : fragmentText}
+
+
+ );
+}
+
+ReportActionItemMessageHeaderSender.displayName = 'ReportActionItemMessageHeaderSender';
+
+export default ReportActionItemMessageHeaderSender;
diff --git a/src/pages/home/report/ReportActionItemMessageHeaderSender/types.ts b/src/pages/home/report/ReportActionItemMessageHeaderSender/types.ts
new file mode 100644
index 000000000000..44a27de119e6
--- /dev/null
+++ b/src/pages/home/report/ReportActionItemMessageHeaderSender/types.ts
@@ -0,0 +1,20 @@
+import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
+
+type ReportActionItemMessageHeaderSenderProps = {
+ /** Text to display */
+ fragmentText: string;
+
+ /** Users accountID */
+ accountID: number;
+
+ /** Should this fragment be contained in a single line? */
+ isSingleLine?: boolean;
+
+ /** The accountID of the copilot who took this action on behalf of the user */
+ delegateAccountID?: number;
+
+ /** Actor icon */
+ actorIcon?: OnyxCommon.Icon;
+};
+
+export default ReportActionItemMessageHeaderSenderProps;
diff --git a/src/pages/home/report/ReportActionItemParentAction.tsx b/src/pages/home/report/ReportActionItemParentAction.tsx
index d66b91436b79..3f4ae35717de 100644
--- a/src/pages/home/report/ReportActionItemParentAction.tsx
+++ b/src/pages/home/report/ReportActionItemParentAction.tsx
@@ -159,7 +159,7 @@ function ReportActionItemParentAction({
index={index}
isFirstVisibleReportAction={isFirstVisibleReportAction}
shouldUseThreadDividerLine={shouldUseThreadDividerLine}
- hideThreadReplies
+ isThreadReportParentAction
/>
)}
diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx
index f8b58cffd225..57d3c48e3b61 100644
--- a/src/pages/home/report/ReportActionsList.tsx
+++ b/src/pages/home/report/ReportActionsList.tsx
@@ -182,6 +182,8 @@ function ReportActionsList({
const hasFooterRendered = useRef(false);
const linkedReportActionID = route?.params?.reportActionID ?? '-1';
+ const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report);
+
const sortedVisibleReportActions = useMemo(
() =>
sortedReportActions.filter(
@@ -190,9 +192,9 @@ function ReportActionsList({
ReportActionsUtils.isDeletedParentAction(reportAction) ||
reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ||
reportAction.errors) &&
- ReportActionsUtils.shouldReportActionBeVisible(reportAction, reportAction.reportActionID, ReportUtils.canUserPerformWriteAction(report)),
+ ReportActionsUtils.shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction),
),
- [sortedReportActions, isOffline, report],
+ [sortedReportActions, isOffline, canUserPerformWriteAction],
);
const lastAction = sortedVisibleReportActions.at(0);
const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = useMemo(
diff --git a/src/pages/home/report/comment/TextCommentFragment.tsx b/src/pages/home/report/comment/TextCommentFragment.tsx
index ab06a594a17f..5dc8c6a85b85 100644
--- a/src/pages/home/report/comment/TextCommentFragment.tsx
+++ b/src/pages/home/report/comment/TextCommentFragment.tsx
@@ -1,6 +1,6 @@
import {Str} from 'expensify-common';
import isEmpty from 'lodash/isEmpty';
-import React, {memo, useEffect} from 'react';
+import React, {memo, useEffect, useMemo} from 'react';
import type {StyleProp, TextStyle} from 'react-native';
import Text from '@components/Text';
import ZeroWidthView from '@components/ZeroWidthView';
@@ -20,6 +20,7 @@ import type {OriginalMessageSource} from '@src/types/onyx/OriginalMessage';
import type {Message} from '@src/types/onyx/ReportAction';
import RenderCommentHTML from './RenderCommentHTML';
import shouldRenderAsText from './shouldRenderAsText';
+import TextWithEmojiFragment from './TextWithEmojiFragment';
type TextCommentFragmentProps = {
/** The reportAction's source */
@@ -52,6 +53,10 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so
const {translate} = useLocalize();
const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const message = isEmpty(iouMessage) ? text : iouMessage;
+
+ const processedTextArray = useMemo(() => EmojiUtils.splitTextWithEmojis(message), [message]);
+
useEffect(() => {
Performance.markEnd(CONST.TIMING.SEND_MESSAGE, {message: text});
Timing.end(CONST.TIMING.SEND_MESSAGE);
@@ -61,6 +66,7 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so
// on native, we render it as text, not as html
// on other device, only render it as text if the only difference is
tag
const containsOnlyEmojis = EmojiUtils.containsOnlyEmojis(text ?? '');
+ const containsEmojis = CONST.REGEX.ALL_EMOJIS.test(text ?? '');
if (!shouldRenderAsText(html, text ?? '') && !(containsOnlyEmojis && styleAsDeleted)) {
const editedTag = fragment?.isEdited ? `` : '';
const htmlWithDeletedTag = styleAsDeleted ? `${html}` : html;
@@ -69,7 +75,10 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so
if (containsOnlyEmojis) {
htmlContent = Str.replaceAll(htmlContent, '', '');
htmlContent = Str.replaceAll(htmlContent, '', '');
+ } else if (containsEmojis) {
+ htmlContent = Str.replaceAll(htmlWithDeletedTag, '', '');
}
+
let htmlWithTag = editedTag ? `${htmlContent}${editedTag}` : htmlContent;
if (styleAsMuted) {
@@ -84,26 +93,37 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so
);
}
- const message = isEmpty(iouMessage) ? text : iouMessage;
-
return (
-
- {convertToLTR(message ?? '')}
-
+ {processedTextArray.length !== 0 && !containsOnlyEmojis ? (
+
+ ) : (
+
+ {convertToLTR(message ?? '')}
+
+ )}
{!!fragment?.isEdited && (
<>
EmojiUtils.splitTextWithEmojis(message), [message]);
+
+ return (
+
+ {processedTextArray.map(({text, isEmoji}, index) =>
+ isEmoji ? (
+
+ {text}
+
+ ) : (
+ convertToLTR(text)
+ ),
+ )}
+
+ );
+}
+
+TextWithEmojiFragment.displayName = 'TextWithEmojiFragment';
+
+export default TextWithEmojiFragment;
diff --git a/src/pages/home/report/comment/TextWithEmojiFragment/index.tsx b/src/pages/home/report/comment/TextWithEmojiFragment/index.tsx
new file mode 100644
index 000000000000..d19725da766d
--- /dev/null
+++ b/src/pages/home/report/comment/TextWithEmojiFragment/index.tsx
@@ -0,0 +1,33 @@
+import React, {useMemo} from 'react';
+import Text from '@components/Text';
+import useThemeStyles from '@hooks/useThemeStyles';
+import convertToLTR from '@libs/convertToLTR';
+import * as EmojiUtils from '@libs/EmojiUtils';
+import type TextWithEmojiFragmentProps from './types';
+
+function TextWithEmojiFragment({message = '', style}: TextWithEmojiFragmentProps) {
+ const styles = useThemeStyles();
+ const processedTextArray = useMemo(() => EmojiUtils.splitTextWithEmojis(message), [message]);
+
+ return (
+
+ {processedTextArray.map(({text, isEmoji}, index) =>
+ isEmoji ? (
+
+ {text}
+
+ ) : (
+ convertToLTR(text)
+ ),
+ )}
+
+ );
+}
+
+TextWithEmojiFragment.displayName = 'TextWithEmojiFragment';
+
+export default TextWithEmojiFragment;
diff --git a/src/pages/home/report/comment/TextWithEmojiFragment/types.ts b/src/pages/home/report/comment/TextWithEmojiFragment/types.ts
new file mode 100644
index 000000000000..243b02f1fd76
--- /dev/null
+++ b/src/pages/home/report/comment/TextWithEmojiFragment/types.ts
@@ -0,0 +1,11 @@
+import type {StyleProp, TextStyle} from 'react-native';
+
+type TextWithEmojiFragmentProps = {
+ /** The message to be displayed */
+ message?: string;
+
+ /** Any additional styles to apply */
+ style: StyleProp;
+};
+
+export default TextWithEmojiFragmentProps;
diff --git a/src/pages/settings/Profile/DisplayNamePage.tsx b/src/pages/settings/Profile/DisplayNamePage.tsx
index 90f7ca3abbd6..4c6211bc3e37 100644
--- a/src/pages/settings/Profile/DisplayNamePage.tsx
+++ b/src/pages/settings/Profile/DisplayNamePage.tsx
@@ -1,7 +1,6 @@
import React from 'react';
import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
@@ -22,11 +21,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import INPUT_IDS from '@src/types/form/DisplayNameForm';
-type DisplayNamePageOnyxProps = {
- isLoadingApp: OnyxEntry;
-};
-
-type DisplayNamePageProps = DisplayNamePageOnyxProps & WithCurrentUserPersonalDetailsProps;
+type DisplayNamePageProps = WithCurrentUserPersonalDetailsProps;
/**
* Submit form to update user's first and last name (and display name)
@@ -36,9 +31,10 @@ const updateDisplayName = (values: FormOnyxValues
@@ -114,6 +111,7 @@ function DisplayNamePage({isLoadingApp = true, currentUserPersonalDetails}: Disp
role={CONST.ROLE.PRESENTATION}
defaultValue={currentUserDetails.lastName ?? ''}
spellCheck={false}
+ isMarkdownEnabled
/>
@@ -124,10 +122,4 @@ function DisplayNamePage({isLoadingApp = true, currentUserPersonalDetails}: Disp
DisplayNamePage.displayName = 'DisplayNamePage';
-export default withCurrentUserPersonalDetails(
- withOnyx({
- isLoadingApp: {
- key: ONYXKEYS.IS_LOADING_APP,
- },
- })(DisplayNamePage),
-);
+export default withCurrentUserPersonalDetails(DisplayNamePage);
diff --git a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.tsx b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.tsx
index e91093731c03..07d65c710429 100644
--- a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.tsx
+++ b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.tsx
@@ -1,7 +1,6 @@
import {subYears} from 'date-fns';
import React, {useCallback} from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import DatePicker from '@components/DatePicker';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
@@ -17,17 +16,10 @@ import * as PersonalDetails from '@userActions/PersonalDetails';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import INPUT_IDS from '@src/types/form/DateOfBirthForm';
-import type {PrivatePersonalDetails} from '@src/types/onyx';
-type DateOfBirthPageOnyxProps = {
- /** User's private personal details */
- privatePersonalDetails: OnyxEntry;
- /** Whether app is loading */
- isLoadingApp: OnyxEntry;
-};
-type DateOfBirthPageProps = DateOfBirthPageOnyxProps;
-
-function DateOfBirthPage({privatePersonalDetails, isLoadingApp = true}: DateOfBirthPageProps) {
+function DateOfBirthPage() {
+ const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS);
+ const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {initialValue: true});
const {translate} = useLocalize();
const styles = useThemeStyles();
@@ -85,11 +77,4 @@ function DateOfBirthPage({privatePersonalDetails, isLoadingApp = true}: DateOfBi
DateOfBirthPage.displayName = 'DateOfBirthPage';
-export default withOnyx({
- privatePersonalDetails: {
- key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
- },
- isLoadingApp: {
- key: ONYXKEYS.IS_LOADING_APP,
- },
-})(DateOfBirthPage);
+export default DateOfBirthPage;
diff --git a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.tsx b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.tsx
index 99e9c910cbdf..505ee55a1ec5 100644
--- a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.tsx
+++ b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.tsx
@@ -1,7 +1,6 @@
import React, {useCallback} from 'react';
import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
import type {FormOnyxValues} from '@components/Form/types';
@@ -21,20 +20,14 @@ import INPUT_IDS from '@src/types/form/LegalNameForm';
import type {PrivatePersonalDetails} from '@src/types/onyx';
import type {Errors} from '@src/types/onyx/OnyxCommon';
-type LegalNamePageOnyxProps = {
- /** User's private personal details */
- privatePersonalDetails: OnyxEntry;
- /** Whether app is loading */
- isLoadingApp: OnyxEntry;
-};
-
-type LegalNamePageProps = LegalNamePageOnyxProps;
-
const updateLegalName = (values: PrivatePersonalDetails) => {
PersonalDetails.updateLegalName(values.legalFirstName?.trim() ?? '', values.legalLastName?.trim() ?? '');
};
-function LegalNamePage({privatePersonalDetails, isLoadingApp = true}: LegalNamePageProps) {
+function LegalNamePage() {
+ const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS);
+ const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {initialValue: true});
+
const styles = useThemeStyles();
const {translate} = useLocalize();
const legalFirstName = privatePersonalDetails?.legalFirstName ?? '';
@@ -136,11 +129,4 @@ function LegalNamePage({privatePersonalDetails, isLoadingApp = true}: LegalNameP
LegalNamePage.displayName = 'LegalNamePage';
-export default withOnyx({
- privatePersonalDetails: {
- key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
- },
- isLoadingApp: {
- key: ONYXKEYS.IS_LOADING_APP,
- },
-})(LegalNamePage);
+export default LegalNamePage;
diff --git a/src/pages/settings/Security/CloseAccountPage.tsx b/src/pages/settings/Security/CloseAccountPage.tsx
index 1da4436ca810..6036512df169 100644
--- a/src/pages/settings/Security/CloseAccountPage.tsx
+++ b/src/pages/settings/Security/CloseAccountPage.tsx
@@ -1,9 +1,7 @@
-import type {StackScreenProps} from '@react-navigation/stack';
import {Str} from 'expensify-common';
import React, {useEffect, useState} from 'react';
import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import ConfirmModal from '@components/ConfirmModal';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
@@ -16,24 +14,16 @@ import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import * as ValidationUtils from '@libs/ValidationUtils';
-import type {SettingsNavigatorParamList} from '@navigation/types';
import variables from '@styles/variables';
import * as CloseAccount from '@userActions/CloseAccount';
import * as User from '@userActions/User';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type SCREENS from '@src/SCREENS';
import INPUT_IDS from '@src/types/form/CloseAccountForm';
-import type {Session} from '@src/types/onyx';
-type CloseAccountPageOnyxProps = {
- /** Session of currently logged in user */
- session: OnyxEntry;
-};
+function CloseAccountPage() {
+ const [session] = useOnyx(ONYXKEYS.SESSION);
-type CloseAccountPageProps = CloseAccountPageOnyxProps & StackScreenProps;
-
-function CloseAccountPage({session}: CloseAccountPageProps) {
const styles = useThemeStyles();
const {translate, formatPhoneNumber} = useLocalize();
@@ -142,8 +132,4 @@ function CloseAccountPage({session}: CloseAccountPageProps) {
CloseAccountPage.displayName = 'CloseAccountPage';
-export default withOnyx({
- session: {
- key: ONYXKEYS.SESSION,
- },
-})(CloseAccountPage);
+export default CloseAccountPage;
diff --git a/src/pages/wallet/WalletStatementPage.tsx b/src/pages/wallet/WalletStatementPage.tsx
index fb10261336b1..7d02a260de78 100644
--- a/src/pages/wallet/WalletStatementPage.tsx
+++ b/src/pages/wallet/WalletStatementPage.tsx
@@ -7,16 +7,18 @@ import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOffli
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import WalletStatementModal from '@components/WalletStatementModal';
+import useEnvironment from '@hooks/useEnvironment';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import usePrevious from '@hooks/usePrevious';
import useThemePreference from '@hooks/useThemePreference';
import DateUtils from '@libs/DateUtils';
+import {getOldDotURLFromEnvironment} from '@libs/Environment/Environment';
import fileDownload from '@libs/fileDownload';
import Navigation from '@libs/Navigation/Navigation';
+import {addTrailingForwardSlash} from '@libs/Url';
import type {WalletStatementNavigatorParamList} from '@navigation/types';
import * as User from '@userActions/User';
-import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
@@ -31,8 +33,11 @@ function WalletStatementPage({route}: WalletStatementPageProps) {
const prevIsWalletStatementGenerating = usePrevious(isWalletStatementGenerating);
const [isDownloading, setIsDownloading] = useState(isWalletStatementGenerating);
const {translate, preferredLocale} = useLocalize();
+ const {environment} = useEnvironment();
const {isOffline} = useNetwork();
+ const baseURL = addTrailingForwardSlash(getOldDotURLFromEnvironment(environment));
+
useEffect(() => {
const currentYearMonth = format(new Date(), CONST.DATE.YEAR_MONTH_FORMAT);
if (!yearMonth || yearMonth.length !== 6 || yearMonth > currentYearMonth) {
@@ -55,13 +60,13 @@ function WalletStatementPage({route}: WalletStatementPageProps) {
// We already have a file URL for this statement, so we can download it immediately
const downloadFileName = `Expensify_Statement_${yearMonth}.pdf`;
const fileName = walletStatement[yearMonth];
- const pdfURL = `${CONFIG.EXPENSIFY.EXPENSIFY_URL}secure?secureType=pdfreport&filename=${fileName}&downloadName=${downloadFileName}`;
+ const pdfURL = `${baseURL}secure?secureType=pdfreport&filename=${fileName}&downloadName=${downloadFileName}`;
fileDownload(pdfURL, downloadFileName).finally(() => setIsDownloading(false));
return;
}
User.generateStatementPDF(yearMonth);
- }, [isWalletStatementGenerating, walletStatement, yearMonth]);
+ }, [baseURL, isWalletStatementGenerating, walletStatement, yearMonth]);
// eslint-disable-next-line rulesdir/prefer-early-return
useEffect(() => {
@@ -79,7 +84,7 @@ function WalletStatementPage({route}: WalletStatementPageProps) {
const month = yearMonth?.substring(4) || getMonth(new Date());
const monthName = format(new Date(Number(year), Number(month) - 1), CONST.DATE.MONTH_FORMAT);
const title = translate('statementPage.title', {year, monthName});
- const url = `${CONFIG.EXPENSIFY.EXPENSIFY_URL}statement.php?period=${yearMonth}${themePreference === CONST.THEME.DARK ? '&isDarkMode=true' : ''}`;
+ const url = `${baseURL}statement.php?period=${yearMonth}${themePreference === CONST.THEME.DARK ? '&isDarkMode=true' : ''}`;
return (
([]);
const [personalDetails, setPersonalDetails] = useState([]);
@@ -68,15 +65,6 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) {
shouldInitialize: didScreenTransitionEnd,
});
- useEffect(() => {
- const unsubscribe = navigation.addListener('beforeRemove', () => {
- Member.setWorkspaceInviteMembersDraft(route.params.policyID, {});
- FormActions.clearDraftValues(ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM);
- });
-
- return unsubscribe;
- }, [navigation, route.params.policyID]);
-
useEffect(() => {
Policy.clearErrors(route.params.policyID);
openWorkspaceInvitePage();
diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx
index cb914591a59d..6034e7d493cd 100644
--- a/src/pages/workspace/WorkspaceMembersPage.tsx
+++ b/src/pages/workspace/WorkspaceMembersPage.tsx
@@ -195,10 +195,16 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
getWorkspaceMembers();
}, [isOffline, prevIsOffline, getWorkspaceMembers]);
+ const clearInviteDraft = useCallback(() => {
+ Member.setWorkspaceInviteMembersDraft(route.params.policyID, {});
+ FormActions.clearDraftValues(ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM);
+ }, [route.params.policyID]);
+
/**
* Open the modal to invite a user
*/
const inviteUser = () => {
+ clearInviteDraft();
Navigation.navigate(ROUTES.WORKSPACE_INVITE.getRoute(route.params.policyID));
};
@@ -416,10 +422,9 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
return;
}
const invitedEmails = Object.values(invitedEmailsToAccountIDsDraft).map(String);
- selectionListRef.current?.scrollAndHighlightItem?.(invitedEmails, 1500);
- Member.setWorkspaceInviteMembersDraft(route.params.policyID, {});
- FormActions.clearDraftValues(ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM);
- }, [invitedEmailsToAccountIDsDraft, route.params.policyID, isFocused, accountIDs, prevAccountIDs]);
+ selectionListRef.current?.scrollAndHighlightItem?.(invitedEmails);
+ clearInviteDraft();
+ }, [invitedEmailsToAccountIDsDraft, isFocused, accountIDs, prevAccountIDs, clearInviteDraft]);
const getHeaderMessage = () => {
if (isOfflineAndNoMemberDataAvailable) {
diff --git a/src/pages/workspace/WorkspaceProfilePage.tsx b/src/pages/workspace/WorkspaceProfilePage.tsx
index 979df0099d82..482d66846e66 100644
--- a/src/pages/workspace/WorkspaceProfilePage.tsx
+++ b/src/pages/workspace/WorkspaceProfilePage.tsx
@@ -61,7 +61,9 @@ function WorkspaceProfilePage({policyDraft, policy: policyProp, route}: Workspac
const workspaceAccountID = PolicyUtils.getWorkspaceAccountID(policy?.id ?? '-1');
const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`);
const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`);
- const hasCardFeedOrExpensifyCard = !isEmptyObject(cardFeeds) || !isEmptyObject(cardsList);
+ const hasCardFeedOrExpensifyCard =
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ !isEmptyObject(cardFeeds) || !isEmptyObject(cardsList) || ((policy?.areExpensifyCardsEnabled || policy?.areCompanyCardsEnabled) && policy?.workspaceAccountID);
const [street1, street2] = (policy?.address?.addressStreet ?? '').split('\n');
const formattedAddress =
diff --git a/src/pages/workspace/WorkspacesListRow.tsx b/src/pages/workspace/WorkspacesListRow.tsx
index e202baf9b39d..5569d4fb3d70 100644
--- a/src/pages/workspace/WorkspacesListRow.tsx
+++ b/src/pages/workspace/WorkspacesListRow.tsx
@@ -12,6 +12,7 @@ import Text from '@components/Text';
import ThreeDotsMenu from '@components/ThreeDotsMenu';
import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
+import WorkspacesListRowDisplayName from '@components/WorkspacesListRowDisplayName';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
@@ -213,12 +214,10 @@ function WorkspacesListRow({
containerStyles={styles.workspaceOwnerAvatarWrapper}
/>
-
- {PersonalDetailsUtils.getDisplayNameOrDefault(ownerDetails)}
-
+
getQBDReimbursableAccounts(policy?.connections?.quickbooksDesktop, nonReimbursable).find(({id}) => nonReimbursableAccount === id)?.name,
- [policy?.connections?.quickbooksDesktop, nonReimbursable, nonReimbursableAccount],
- );
+ const accountName = useMemo(() => {
+ const qbdReimbursableAccounts = getQBDReimbursableAccounts(policy?.connections?.quickbooksDesktop, nonReimbursable);
+ // We use the logical OR (||) here instead of ?? because `nonReimbursableAccount` can be an empty string
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ return qbdReimbursableAccounts.find(({id}) => nonReimbursableAccount === id)?.name || qbdReimbursableAccounts.at(0)?.name || translate('workspace.qbd.notConfigured');
+ }, [policy?.connections?.quickbooksDesktop, nonReimbursable, translate, nonReimbursableAccount]);
const sections = [
{
@@ -42,7 +44,7 @@ function QuickbooksDesktopCompanyCardExpenseAccountPage({policy}: WithPolicyConn
keyForList: translate('workspace.accounting.exportAs'),
},
{
- title: accountName ?? translate('workspace.qbd.notConfigured'),
+ title: accountName,
description: ConnectionUtils.getQBDNonReimbursableExportAccountType(nonReimbursable),
onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT.getRoute(policyID)),
subscribedSettings: [CONST.QUICKBOOKS_DESKTOP_CONFIG.NON_REIMBURSABLE_ACCOUNT],
diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopCompanyCardExpenseAccountSelectPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopCompanyCardExpenseAccountSelectPage.tsx
index d7cbf62c6d8b..c184f13f08c0 100644
--- a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopCompanyCardExpenseAccountSelectPage.tsx
+++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopCompanyCardExpenseAccountSelectPage.tsx
@@ -39,7 +39,9 @@ function QuickbooksDesktopCompanyCardExpenseAccountSelectPage({policy}: WithPoli
value: card,
text: card.name,
keyForList: card.name,
- isSelected: card.id === nonReimbursableAccount,
+ // We use the logical OR (||) here instead of ?? because `nonReimbursableAccount` can be an empty string
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ isSelected: card.id === (nonReimbursableAccount || accounts.at(0)?.id),
}));
}, [policy?.connections?.quickbooksDesktop, nonReimbursable, nonReimbursableAccount]);
diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx
index bac01ce70ef1..edf97bd6f3eb 100644
--- a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx
+++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx
@@ -35,7 +35,9 @@ function QuickbooksDesktopNonReimbursableDefaultVendorSelectPage({policy}: WithP
value: vendor.id,
text: vendor.name,
keyForList: vendor.name,
- isSelected: vendor.id === nonReimbursableBillDefaultVendor,
+ // We use the logical OR (||) here instead of ?? because `nonReimbursableBillDefaultVendor` can be an empty string
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ isSelected: vendor.id === (nonReimbursableBillDefaultVendor || vendors.at(0)?.id),
})) ?? [];
return data.length ? [{data}] : [];
}, [nonReimbursableBillDefaultVendor, vendors]);
diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopOutOfPocketExpenseAccountSelectPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopOutOfPocketExpenseAccountSelectPage.tsx
index 86b30b52142f..936ce29fe2a1 100644
--- a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopOutOfPocketExpenseAccountSelectPage.tsx
+++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopOutOfPocketExpenseAccountSelectPage.tsx
@@ -58,7 +58,9 @@ function QuickbooksDesktopOutOfPocketExpenseAccountSelectPage({policy}: WithPoli
value: account,
text: account.name,
keyForList: account.name,
- isSelected: account.id === qbdConfig?.export?.reimbursableAccount,
+ // We use the logical OR (||) here instead of ?? because `reimbursableAccount` can be an empty string
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ isSelected: account.id === (qbdConfig?.export?.reimbursableAccount || accounts.at(0)?.id),
}));
}, [policy?.connections?.quickbooksDesktop, qbdConfig?.export?.reimbursableAccount]);
diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx
index 08092465159b..1c0372aa8715 100644
--- a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx
+++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx
@@ -74,7 +74,9 @@ function QuickbooksDesktopOutOfPocketExpenseConfigurationPage({policy}: WithPoli
brickRoadIndicator: PolicyUtils.areSettingsInErrorFields(accountOrExportDestination, qbdConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined,
},
{
- title: accountsList.find(({id}) => qbdConfig?.export.reimbursableAccount === id)?.name ?? translate('workspace.qbd.notConfigured'),
+ // We use the logical OR (||) here instead of ?? because `reimbursableAccount` can be an empty string
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ title: accountsList.find(({id}) => qbdConfig?.export.reimbursableAccount === id)?.name || accountsList.at(0)?.name || translate('workspace.qbd.notConfigured'),
description: accountDescription,
onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT.getRoute(policyID)),
subscribedSettings: account,
diff --git a/src/pages/workspace/categories/ImportCategoriesPage.tsx b/src/pages/workspace/categories/ImportCategoriesPage.tsx
index 82afe6dfc812..930096d0dd1b 100644
--- a/src/pages/workspace/categories/ImportCategoriesPage.tsx
+++ b/src/pages/workspace/categories/ImportCategoriesPage.tsx
@@ -5,8 +5,11 @@ import usePolicy from '@hooks/usePolicy';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
import * as PolicyUtils from '@libs/PolicyUtils';
import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
+import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
+import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
type ImportCategoriesPageProps = StackScreenProps;
@@ -22,10 +25,16 @@ function ImportCategoriesPage({route}: ImportCategoriesPageProps) {
}
return (
-
+
+
+
);
}
diff --git a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx
index aea6f596badd..12502787d9df 100644
--- a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx
+++ b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx
@@ -1,6 +1,7 @@
import React, {useMemo, useState} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
+import CategorySelectorModal from '@components/CategorySelector/CategorySelectorModal';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
@@ -12,7 +13,6 @@ import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
-import CategorySelectorModal from '@pages/workspace/distanceRates/CategorySelector/CategorySelectorModal';
import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections';
import withPolicyConnections from '@pages/workspace/withPolicyConnections';
import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow';
diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx
index 75b4f44fc843..7959879609e7 100644
--- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx
+++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx
@@ -36,7 +36,6 @@ function WorkspaceCompanyCardsList({cardsList, policyID}: WorkspaceCompanyCardsL
const renderItem = useCallback(
({item, index}: ListRenderItemInfo) => {
const cardID = Object.keys(cardsList ?? {}).find((id) => cardsList?.[id].cardID === item.cardID);
- const cardName = CardUtils.getCompanyCardNumber(cardsList?.cardList ?? {}, item.lastFourPAN);
const isCardDeleted = item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
return (
diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx
index 031ac309e155..22b17496040e 100644
--- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx
+++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx
@@ -13,12 +13,16 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as CardUtils from '@libs/CardUtils';
+import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import Navigation from '@navigation/Navigation';
import variables from '@styles/variables';
+import * as CompanyCards from '@userActions/CompanyCards';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {CompanyCardFeed} from '@src/types/onyx';
+import type {AssignCardData, AssignCardStep} from '@src/types/onyx/AssignCard';
type WorkspaceCompanyCardsListHeaderButtonsProps = {
/** Current policy id */
@@ -41,6 +45,36 @@ function WorkspaceCompanyCardsListHeaderButtons({policyID, selectedFeed}: Worksp
const isCustomFeed = CardUtils.isCustomFeed(selectedFeed);
const companyFeeds = CardUtils.getCompanyFeeds(cardFeeds);
const currentFeedData = companyFeeds?.[selectedFeed];
+ const policy = PolicyUtils.getPolicy(policyID);
+
+ const [list] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${selectedFeed}`);
+ const filteredCardList = CardUtils.getFilteredCardList(list);
+
+ const handleAssignCard = () => {
+ const data: Partial = {
+ bankName: selectedFeed,
+ };
+
+ let currentStep: AssignCardStep = CONST.COMPANY_CARD.STEP.ASSIGNEE;
+
+ if (Object.keys(policy?.employeeList ?? {}).length === 1) {
+ const userEmail = Object.keys(policy?.employeeList ?? {}).at(0) ?? '';
+ data.email = userEmail;
+ const personalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(userEmail);
+ const memberName = personalDetails?.firstName ? personalDetails.firstName : personalDetails?.login;
+ data.cardName = `${memberName}'s card`;
+ currentStep = CONST.COMPANY_CARD.STEP.CARD;
+
+ if (CardUtils.hasOnlyOneCardToAssign(filteredCardList)) {
+ currentStep = CONST.COMPANY_CARD.STEP.TRANSACTION_START_DATE;
+ data.cardNumber = Object.keys(filteredCardList).at(0);
+ data.encryptedCardNumber = Object.values(filteredCardList).at(0);
+ }
+ }
+
+ CompanyCards.setAssignCardStepAndData({data, currentStep});
+ Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD.getRoute(policyID, selectedFeed)));
+ };
return (
Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD.getRoute(policyID, selectedFeed))}
+ onPress={handleAssignCard}
icon={Expensicons.Plus}
text={translate('workspace.companyCards.assignCard')}
style={shouldChangeLayout && styles.flex1}
diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx
index 41f698f61dab..8215a3d0bf40 100644
--- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx
+++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx
@@ -1,5 +1,5 @@
import type {StackScreenProps} from '@react-navigation/stack';
-import React, {useState} from 'react';
+import React, {useMemo, useState} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import ConfirmModal from '@components/ConfirmModal';
@@ -23,6 +23,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
+import type {CompanyCardFeed} from '@src/types/onyx';
type WorkspaceCompanyCardsSettingsPageProps = StackScreenProps;
@@ -39,7 +40,8 @@ function WorkspaceCompanyCardsSettingsPage({
const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`);
const [lastSelectedFeed] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`);
- const selectedFeed = CardUtils.getSelectedFeed(lastSelectedFeed, cardFeeds);
+ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we want to run the hook only once to escape unexpected feed change
+ const selectedFeed = useMemo(() => CardUtils.getSelectedFeed(lastSelectedFeed, cardFeeds), []);
const feedName = CardUtils.getCustomOrFormattedFeedName(selectedFeed, cardFeeds?.settings?.companyCardNicknames);
const companyFeeds = CardUtils.getCompanyFeeds(cardFeeds);
const liabilityType = selectedFeed && companyFeeds[selectedFeed]?.liabilityType;
@@ -51,7 +53,8 @@ function WorkspaceCompanyCardsSettingsPage({
const deleteCompanyCardFeed = () => {
if (selectedFeed) {
- CompanyCards.deleteWorkspaceCompanyCardFeed(policyID, workspaceAccountID, selectedFeed);
+ const feedToOpen = (Object.keys(companyFeeds) as CompanyCardFeed[]).filter((feed) => feed !== selectedFeed).at(0);
+ CompanyCards.deleteWorkspaceCompanyCardFeed(policyID, workspaceAccountID, selectedFeed, feedToOpen);
}
setDeleteCompanyCardConfirmModalVisible(false);
Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack);
diff --git a/src/pages/workspace/companyCards/addNew/AmexCustomFeed.tsx b/src/pages/workspace/companyCards/addNew/AmexCustomFeed.tsx
index 765b42997573..3457da78fc41 100644
--- a/src/pages/workspace/companyCards/addNew/AmexCustomFeed.tsx
+++ b/src/pages/workspace/companyCards/addNew/AmexCustomFeed.tsx
@@ -59,6 +59,13 @@ function AmexCustomFeed() {
keyForList: CONST.COMPANY_CARDS.AMEX_CUSTOM_FEED.BUSINESS,
isSelected: typeSelected === CONST.COMPANY_CARDS.AMEX_CUSTOM_FEED.BUSINESS,
},
+ {
+ value: CONST.COMPANY_CARDS.AMEX_CUSTOM_FEED.PERSONAL,
+ text: CONST.COMPANY_CARDS.AMEX_CUSTOM_FEED.PERSONAL,
+ alternateText: translate('workspace.companyCards.addNewCard.amexPersonal'),
+ keyForList: CONST.COMPANY_CARDS.AMEX_CUSTOM_FEED.PERSONAL,
+ isSelected: typeSelected === CONST.COMPANY_CARDS.AMEX_CUSTOM_FEED.PERSONAL,
+ },
];
return (
diff --git a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx
index 20c51b882054..2fe757c4e36f 100644
--- a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx
+++ b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx
@@ -25,8 +25,10 @@ function AssignCardFeedPage({route, policy}: AssignCardFeedPageProps) {
const policyID = policy?.id ?? '-1';
useEffect(() => {
- CompanyCards.setAssignCardStepAndData({data: {bankName: feed}});
- }, [feed]);
+ return () => {
+ CompanyCards.clearAssignCardStepAndData();
+ };
+ }, []);
switch (currentStep) {
case CONST.COMPANY_CARD.STEP.ASSIGNEE:
@@ -52,8 +54,6 @@ function AssignCardFeedPage({route, policy}: AssignCardFeedPageProps) {
default:
return ;
}
-
- return ;
}
export default withPolicyAndFullscreenLoading(AssignCardFeedPage);
diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx
index e8e8c81cba07..1b2819fc380c 100644
--- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx
+++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx
@@ -13,6 +13,7 @@ import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as CardUtils from '@libs/CardUtils';
import {formatPhoneNumber} from '@libs/LocalePhoneNumber';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
@@ -35,6 +36,10 @@ function AssigneeStep({policy}: AssigneeStepProps) {
const styles = useThemeStyles();
const {isOffline} = useNetwork();
const [assignCard] = useOnyx(ONYXKEYS.ASSIGN_CARD);
+ const workspaceAccountID = PolicyUtils.getWorkspaceAccountID(policy?.id ?? '-1');
+
+ const [list] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${assignCard?.data?.bankName ?? ''}`);
+ const filteredCardList = CardUtils.getFilteredCardList(list);
const isEditing = assignCard?.isEditing;
@@ -57,8 +62,10 @@ function AssigneeStep({policy}: AssigneeStepProps) {
const personalDetail = PersonalDetailsUtils.getPersonalDetailByEmail(selectedMember);
const memberName = personalDetail?.firstName ? personalDetail.firstName : personalDetail?.login;
+ const nextStep = CardUtils.hasOnlyOneCardToAssign(filteredCardList) ? CONST.COMPANY_CARD.STEP.TRANSACTION_START_DATE : CONST.COMPANY_CARD.STEP.CARD;
+
CompanyCards.setAssignCardStepAndData({
- currentStep: isEditing ? CONST.COMPANY_CARD.STEP.CONFIRMATION : CONST.COMPANY_CARD.STEP.CARD,
+ currentStep: isEditing ? CONST.COMPANY_CARD.STEP.CONFIRMATION : nextStep,
data: {
email: selectedMember,
cardName: `${memberName}'s card`,
@@ -69,7 +76,10 @@ function AssigneeStep({policy}: AssigneeStepProps) {
const handleBackButtonPress = () => {
if (isEditing) {
- CompanyCards.setAssignCardStepAndData({currentStep: CONST.COMPANY_CARD.STEP.CONFIRMATION, isEditing: false});
+ CompanyCards.setAssignCardStepAndData({
+ currentStep: CONST.COMPANY_CARD.STEP.CONFIRMATION,
+ isEditing: false,
+ });
return;
}
Navigation.goBack();
diff --git a/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx b/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx
index 4b07e7a220b8..47bcbbd3ed6d 100644
--- a/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx
+++ b/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx
@@ -45,11 +45,8 @@ function CardSelectionStep({feed, policyID}: CardSelectionStepProps) {
const isEditing = assignCard?.isEditing;
const assigneeDisplayName = PersonalDetailsUtils.getPersonalDetailByEmail(assignCard?.data?.email ?? '')?.displayName ?? '';
- const {cardList, ...cards} = list ?? {};
- // We need to filter out cards which already has been assigned
- const filteredCardList = Object.fromEntries(
- Object.entries(cardList ?? {}).filter(([cardNumber]) => !Object.values(cards).find((card) => card.lastFourPAN && cardNumber.endsWith(card.lastFourPAN))),
- );
+ const filteredCardList = CardUtils.getFilteredCardList(list);
+
const [cardSelected, setCardSelected] = useState(assignCard?.data?.encryptedCardNumber ?? '');
const [shouldShowError, setShouldShowError] = useState(false);
diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx
index eed24a4ea13f..fbbdf5ee382f 100644
--- a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx
+++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx
@@ -3,6 +3,7 @@ import React, {useState} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
+import CategorySelector from '@components/CategorySelector';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import ScreenWrapper from '@components/ScreenWrapper';
@@ -28,7 +29,6 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import type {CustomUnit} from '@src/types/onyx/Policy';
-import CategorySelector from './CategorySelector';
import UnitSelector from './UnitSelector';
type PolicyDistanceRatesSettingsPageProps = StackScreenProps;
@@ -61,14 +61,11 @@ function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPag
};
const setNewCategory = (category: ListItem) => {
- if (!category.searchText || !customUnit) {
+ if (!category.searchText || !customUnit || defaultCategory === category.searchText) {
return;
}
- Category.setPolicyDistanceRatesDefaultCategory(policyID, customUnit, {
- ...customUnit,
- defaultCategory: defaultCategory === category.searchText ? '' : category.searchText,
- });
+ Category.setPolicyCustomUnitDefaultCategory(policyID, customUnitID, customUnit.defaultCategory, category.searchText);
};
const clearErrorFields = (fieldName: keyof CustomUnit) => {
@@ -125,7 +122,7 @@ function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPag
>
;
+type ImportMembersPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
-function ImportMembersPage({route}: ImportMembersPageProps) {
- const policyID = route.params.policyID;
+function ImportMembersPage({policy}: ImportMembersPageProps) {
+ const policyID = policy?.id ?? '';
return (
-
+
+
+
);
}
-export default ImportMembersPage;
+export default withPolicyAndFullscreenLoading(ImportMembersPage);
diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
index b0066187e6f1..3ffe224dd50a 100644
--- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
+++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
@@ -326,7 +326,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM
>