diff --git a/.github/actions/composite/buildAndroidAPK/action.yml b/.github/actions/composite/buildAndroidAPK/action.yml
index 819234df0bc3..fc280ab2a223 100644
--- a/.github/actions/composite/buildAndroidAPK/action.yml
+++ b/.github/actions/composite/buildAndroidAPK/action.yml
@@ -13,7 +13,7 @@ runs:
- uses: ruby/setup-ruby@eae47962baca661befdfd24e4d6c34ade04858f7
with:
- ruby-version: '2.7'
+ ruby-version: "2.7"
bundler-cache: true
- uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef
@@ -26,4 +26,4 @@ runs:
uses: actions/upload-artifact@65d862660abb392b8c4a3d1195a2108db131dd05
with:
name: ${{ inputs.ARTIFACT_NAME }}
- path: android/app/build/outputs/apk/e2eRelease/app-e2eRelease.apk
+ path: android/app/build/outputs/apk/e2e/release/app-e2e-release.apk
diff --git a/.github/actions/javascript/getPullRequestDetails/action.yml b/.github/actions/javascript/getPullRequestDetails/action.yml
index a59cf55bdf9f..ed2c60f018a1 100644
--- a/.github/actions/javascript/getPullRequestDetails/action.yml
+++ b/.github/actions/javascript/getPullRequestDetails/action.yml
@@ -13,8 +13,14 @@ inputs:
outputs:
MERGE_COMMIT_SHA:
description: 'The merge_commit_sha of the given pull request'
+ HEAD_COMMIT_SHA:
+ description: 'The head_commit_sha of the given pull request'
MERGE_ACTOR:
description: 'The actor who merged the pull request'
+ IS_MERGED:
+ description: 'True if the pull request is merged'
+ FORKED_REPO_URL:
+ description: 'Output forked repo URL if PR includes changes from a fork'
runs:
using: 'node16'
main: './index.js'
diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml
index d8f9cad138d9..f7f1e5fc7ac7 100644
--- a/.github/workflows/e2ePerformanceTests.yml
+++ b/.github/workflows/e2ePerformanceTests.yml
@@ -84,12 +84,7 @@ jobs:
- name: Unmerged PR - Fetch head ref of unmerged PR
if: ${{ !fromJSON(steps.getPullRequestDetails.outputs.IS_MERGED) }}
run: |
- if [[ ${{ steps.getPullRequestDetails.outputs.FORKED_REPO_URL }} != '' ]]; then
- git remote add pr_remote ${{ steps.getPullRequestDetails.outputs.FORKED_REPO_URL }}
- git fetch pr_remote ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} --no-tags --depth=1
- else
- git fetch origin ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} --no-tags --depth=1
- fi
+ git fetch origin ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} --no-tags --depth=1
- name: Unmerged PR - Set dummy git credentials before merging
if: ${{ !fromJSON(steps.getPullRequestDetails.outputs.IS_MERGED) }}
@@ -101,7 +96,7 @@ jobs:
if: ${{ !fromJSON(steps.getPullRequestDetails.outputs.IS_MERGED) }}
id: getMergeCommitShaIfUnmergedPR
run: |
- git merge --no-commit ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }}
+ git merge --allow-unrelated-histories --no-commit ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }}
git checkout ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }}
env:
GITHUB_TOKEN: ${{ github.token }}
@@ -140,18 +135,19 @@ jobs:
name: baseline-apk-${{ needs.buildBaseline.outputs.VERSION }}
path: zip
- # The downloaded artifact will be a file named "app-e2eRelease.apk" so we have to rename it
+ # The downloaded artifact will be a file named "app-e2e-release.apk" so we have to rename it
- name: Rename baseline APK
- run: mv "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease-baseline.apk"
+ run: mv "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2e-release.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease-baseline.apk"
- name: Download delta APK
uses: actions/download-artifact@e9ef242655d12993efdcda9058dee2db83a2cb9b
+ id: downloadDeltaAPK
with:
name: delta-apk-${{ needs.buildDelta.outputs.DELTA_REF }}
path: zip
- name: Rename delta APK
- run: mv "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease-compare.apk"
+ run: mv "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2e-release.apk" "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2eRelease-compare.apk"
- name: Copy e2e code into zip folder
run: cp -r tests/e2e zip
diff --git a/.storybook/preview.js b/.storybook/preview.js
index b198c0d2d626..a989960794f2 100644
--- a/.storybook/preview.js
+++ b/.storybook/preview.js
@@ -6,7 +6,7 @@ import './fonts.css';
import ComposeProviders from '../src/components/ComposeProviders';
import HTMLEngineProvider from '../src/components/HTMLEngineProvider';
import OnyxProvider from '../src/components/OnyxProvider';
-import {LocaleContextProvider} from '../src/components/withLocalize';
+import {LocaleContextProvider} from '../src/components/LocaleContextProvider';
import {KeyboardStateProvider} from '../src/components/withKeyboardState';
import {EnvironmentProvider} from '../src/components/withEnvironment';
import {WindowDimensionsProvider} from '../src/components/withWindowDimensions';
diff --git a/.well-known/apple-app-site-association b/.well-known/apple-app-site-association
index a9e2b0383691..d6da0232f2fc 100644
--- a/.well-known/apple-app-site-association
+++ b/.well-known/apple-app-site-association
@@ -75,6 +75,10 @@
{
"/": "/teachersunite/*",
"comment": "Teachers Unite!"
+ },
+ {
+ "/": "/search/*",
+ "comment": "Search"
}
]
}
diff --git a/android/app/build.gradle b/android/app/build.gradle
index afe24fc37700..f8696ceb7032 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -58,7 +58,7 @@ project.ext.envConfigFiles = [
adhocRelease: ".env.adhoc",
developmentRelease: ".env",
developmentDebug: ".env",
- e2eRelease: ".env.production"
+ e2eRelease: "tests/e2e/.env.e2e"
]
/**
@@ -90,8 +90,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001037403
- versionName "1.3.74-3"
+ versionCode 1001037510
+ versionName "1.3.75-10"
}
flavorDimensions "default"
@@ -136,10 +136,20 @@ android {
signingConfig signingConfigs.debug
}
release {
- signingConfig signingConfigs.release
productFlavors.production.signingConfig signingConfigs.release
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
+
+ signingConfig null
+ // buildTypes take precedence over productFlavors when it comes to the signing configuration,
+ // thus we need to manually set the signing config, so that the e2e uses the debug config again.
+ // In other words, the signingConfig setting above will be ignored when we build the flavor in release mode.
+ productFlavors.all { flavor ->
+ // All release builds should be signed with the release config ...
+ flavor.signingConfig signingConfigs.release
+ }
+ // ... except for the e2e flavor, which we maybe want to build locally:
+ productFlavors.e2e.signingConfig signingConfigs.debug
}
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 8d69c62bfd1f..dc135fa9834e 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -67,6 +67,7 @@
+
@@ -83,6 +84,7 @@
+
diff --git a/assets/images/chatbubbles.svg b/assets/images/chatbubbles.svg
new file mode 100644
index 000000000000..6194c43e631e
--- /dev/null
+++ b/assets/images/chatbubbles.svg
@@ -0,0 +1,12 @@
+
+
+
diff --git a/assets/images/google-meet.svg b/assets/images/google-meet.svg
index 138a11859321..980cd102f67a 100644
--- a/assets/images/google-meet.svg
+++ b/assets/images/google-meet.svg
@@ -1,8 +1,7 @@
-
\ No newline at end of file
+
+
+
diff --git a/assets/images/zoom-icon.svg b/assets/images/zoom-icon.svg
index 6c6ed03cb2f3..24d019654795 100644
--- a/assets/images/zoom-icon.svg
+++ b/assets/images/zoom-icon.svg
@@ -1 +1,7 @@
-
\ No newline at end of file
+
+
+
diff --git a/docs/Gemfile b/docs/Gemfile
index 7cad729ee45b..701ae50ca381 100644
--- a/docs/Gemfile
+++ b/docs/Gemfile
@@ -32,3 +32,6 @@ gem "http_parser.rb", "~> 0.6.0", :platforms => [:jruby]
gem "webrick", "~> 1.7"
gem 'jekyll-seo-tag'
+
+gem 'jekyll-redirect-from'
+
diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock
index 1a5b26e2dc23..0963d3c73e6c 100644
--- a/docs/Gemfile.lock
+++ b/docs/Gemfile.lock
@@ -263,6 +263,7 @@ DEPENDENCIES
github-pages
http_parser.rb (~> 0.6.0)
jekyll-feed (~> 0.12)
+ jekyll-redirect-from
jekyll-seo-tag
tzinfo (~> 1.2)
tzinfo-data
@@ -270,4 +271,4 @@ DEPENDENCIES
webrick (~> 1.7)
BUNDLED WITH
- 2.4.3
+ 2.4.19
diff --git a/docs/_config.yml b/docs/_config.yml
index 114e562cae04..4a0ce8c053c5 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -17,3 +17,7 @@ exclude: [README.md, TEMPLATE.md, vendor]
plugins:
- jekyll-seo-tag
+ - jekyll-redirect-from
+
+whitelist:
+ - jekyll-redirect-from
diff --git a/docs/articles/expensify-classic/account-settings/Account-Access.md b/docs/articles/expensify-classic/account-settings/Account-Access.md
deleted file mode 100644
index b3126201715f..000000000000
--- a/docs/articles/expensify-classic/account-settings/Account-Access.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Account Access
-description: Account Access
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/account-settings/Account-Details.md b/docs/articles/expensify-classic/account-settings/Account-Details.md
new file mode 100644
index 000000000000..46a6c6ba0c25
--- /dev/null
+++ b/docs/articles/expensify-classic/account-settings/Account-Details.md
@@ -0,0 +1,5 @@
+---
+title: Account Details
+description: Account Details
+---
+## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md b/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md
index 85202835a0e4..9de47d6e5beb 100644
--- a/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md
+++ b/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md
@@ -1,5 +1,200 @@
---
-title: Auto-reconciliation
-description: Auto-reconciliation
+title: Expensify Card Auto-Reconciliation
+description: Everything you need to know about Expensify Card Auto-Reconciliation
---
-## Resource Coming Soon!
+
+
+# Overview
+If your company uses the Expensify Card, and connects to a direct accounting integration, you can auto-reconcile card spending each month.
+
+The integrations that auto-reconciliation are available on are:
+
+- QuickBooks Online
+- Xero
+- NetSuite
+- Sage Intacct
+
+# How-to Set Up Expensify Card Auto-Reconciliation
+
+## Auto-Reconciliation Prerequisites
+
+- Connection:
+1. A Preferred Workspace is set.
+2. A Reconciliation Account is set and matches the Expensify Card settlement account.
+- Automation:
+1. Auto-Sync is enabled on the Preferred Workspace above.
+2. Scheduled Submit is enabled on the Preferred Workspace above.
+- User:
+1. A Domain Admin is set as the Preferred Workspace’s Preferred Exporter.
+
+To set up your auto-reconciliation account with the Expensify Card, follow these steps:
+1. Navigate to your Settings.
+2. Choose "Domains," then select your specific domain name.
+3. Click on "Company Cards."
+4. From the dropdown menu, pick the Expensify Card.
+5. Head to the "Settings" tab.
+6. Select the account in your accounting solution that you want to use for reconciliation. Make sure this account matches the settlement business bank account.
+
+That's it! You've successfully set up your auto-reconciliation account.
+
+## How does Auto-Reconciliation work
+Once Auto-Reconciliation is enabled, there are a few things that happen. Let’s go over those!
+
+### Handling Purchases and Card Balance Payments
+**What happens**: When an Expensify Card is used to make purchases, the amount spent is automatically deducted from your company’s 'Settlement Account' (your business checking account). This deduction happens on a daily or monthly basis, depending on your chosen settlement frequency. Don't worry; this settlement account is pre-defined when you apply for the Expensify Card, and you can't accidentally change it.
+**Accounting treatment**: After your card balance is settled each day, we update your accounting system with a journal entry. This entry credits your bank account (referred to as the GL account) and debits the Expensify Card Clearing Account. To ensure accuracy, please make sure that the 'bank account' in your Expensify Card settings matches your real-life settlement account. You can easily verify this by navigating to **Settings > Account > Payments**, where you'll see 'Settlement Account' next to your business bank account. To keep track of settlement figures by date, use the Company Card Reconciliation Dashboard's Settlements tab:
+
+### Submitting, Approving, and Exporting Expenses
+**What happens**: Users submit their expenses on a report, which might occur after some time has passed since the initial purchase. Once the report is approved, it's then exported to your accounting software.
+**Accounting treatment**: When the report is exported, we create a journal entry in your accounting system. This entry credits the Clearing Account and debits the Liability Account for the purchase amount. The Liability Account functions as a bank account in your ledger, specifically for Expensify Card expenses:
+
+# Deep Dive
+## QuickBooks Online
+
+### Initial Setup
+1. Start by accessing your group workspace linked to QuickBooks Online. On the Export tab, make sure that the user chosen as the Preferred Exporter holds the role of a Workspace Admin and has an email address associated with your Expensify Cards' domain. For instance, if your domain is company.com, the Preferred Exporter's email should be email@company.com.
+2. Head over to the Advanced tab and ensure that Auto-Sync is enabled.
+3. Now, navigate to **Settings > Domains > *Domain Name* > Company Cards > Settings**. Use the dropdown menu next to "Preferred Workspace" to select the group workspace connected to QuickBooks Online and with Scheduled Submit enabled.
+4. In the dropdown menu next to "Expensify Card reconciliation account," choose your existing QuickBooks Online bank account for reconciliation. This should be the same account you use for Expensify Card settlements.
+5. In the dropdown menu next to "Expensify Card settlement account," select your business bank account used for settlements (found in Expensify under **Settings > Account > Payments**).
+
+### How This Works
+1. On the day of your first card settlement, we'll create the Expensify Card Liability account in your QuickBooks Online general ledger. If you've opted for Daily Settlement, we'll also create an Expensify Clearing Account.
+2. During your QuickBooks Online auto-sync on that same day, if there are unsettled transactions, we'll generate a journal entry totaling all posted transactions since the last settlement. This entry will credit the selected bank account and debit the new Expensify Clearing Account (for Daily Settlement) or the Expensify Liability Account (for Monthly Settlement).
+3. Once the transactions are posted and the expense report is approved in Expensify, the report will be exported to QuickBooks Online with each line as individual credit card expenses. For Daily Settlement, an additional journal entry will credit the Expensify Clearing Account and debit the Expensify Card Liability Account. For Monthly Settlement, the journal entry will credit the Liability account directly and debit the appropriate expense categories.
+
+### Example
+- We have card transactions for the day totaling $100, so we create the following journal entry upon sync:
+- The current balance of the Expensify Clearing Account is now $100:
+- After transactions are posted in Expensify and the report is approved and exported, a second journal entry is generated:
+- We reconcile the matching amounts automatically, clearing the balance of the Expensify Clearing Account:
+- Now, you'll have a debit on your credit card account (increasing the total spent) and a credit on the bank account (reducing the available amount). The Clearing Account balance is $0.
+- Each expense will also create a credit card expense, similar to how we do it today, exported upon final approval. This action debits the expense account (category) and includes any other line item data.
+- This process occurs daily during the QuickBooks Online Auto-Sync to ensure your card remains reconciled.
+
+**Note:** If Auto-Reconciliation is disabled for your company's Expensify Cards, a Domain Admin can set an export account for individual cards via **Settings > Domains > *Domain Name* > Company Cards > Edit Exports**. The Expensify Card transactions will always export as Credit Card charges in your accounting software, even if the non-reimbursable setting is configured differently, such as a Vendor Bill.
+
+## Xero
+
+### Initial Setup
+1. Begin by accessing your group workspace linked to Xero. On the Export tab, ensure that the Preferred Exporter is a Workspace Admin with an email address from your Expensify Cards domain (e.g. company.com).
+2. Head to the Advanced tab and confirm that Auto-Sync is enabled.
+3. Now, go to **Settings > Domains > *Domain Name* > Company Cards > Settings**. From the dropdown menu next to "Preferred Workspace," select the group workspace connected to Xero with Scheduled Submit enabled.
+4. In the dropdown menu for "Expensify Card settlement account," pick your settlement business bank account (found in Expensify under **Settings > Account > Payments**).
+5. In the dropdown menu for "Expensify Card reconciliation account," select the corresponding GL account from Xero for your settlement business bank account from step 4.
+
+### How This Works
+1. During the first overnight Auto Sync after enabling Continuous Reconciliation, Expensify will create a Liability Account (Bank Account) on your Xero Dashboard. If you've opted for Daily Settlement, an additional Clearing Account will be created in your General Ledger. Two Contacts —Expensify and Expensify Card— will also be generated:
+2. The bank account for Expensify Card transactions is tied to the Liability Account Expensify created. Note that this doesn't apply to other cards or non-reimbursable expenses, which follow your workspace settings.
+
+### Daily Settlement Reconciliation
+- If you've selected Daily Settlement, Expensify uses entries in the Clearing Account to reconcile the daily settlement. This is because Expensify bills on posted transactions, which you can review via **Settings > Domains > *Domain Name* > Company Cards > Reconciliation > Settlements**.
+- At the end of each day (or month on your settlement date), the settlement charge posts to your Business Bank Account. Expensify assigns the Clearing Account (or Liability Account for monthly settlement) as a Category to the transaction, posting it in your GL. The charge is successfully reconciled.
+
+### Bank Transaction Reconciliation
+- Expensify will pay off the Liability Account with the Clearing Account balance and reconcile bank transaction entries to the Liability Account with your Expense Accounts.
+- When transactions are approved and exported from Expensify, bank transactions (Receive Money) are added to the Liability Account, and coded to the Clearing Account. Simultaneously, Spend Money transactions are created and coded to the Category field. If you see many Credit Card Misc. entries, add commonly used merchants as Contacts in Xero to export with the original merchant name.
+- The Clearing Account balance is reduced, paying off the entries to the Liability Account created in Step 1. Each payment to and from the Liability Account should have a corresponding bank transaction referencing an expense account. Liability Account Receive Money payments appear with "EXPCARD-APPROVAL" and the corresponding Report ID from Expensify.
+- You can run a Bank Reconciliation Summary displaying entries in the Liability Account referencing individual payments, as well as entries that reduce the Clearing Account balance to unapproved expenses.
+- **Important**: To bring your Liability Account balance to 0, enable marking transactions as reconciled in Xero. When a Spend Money bank transaction in the Liability Account has a matching Receive Transaction, you can mark both as Reconciled using the provided hyperlink.
+
+**Note**: If Auto-Reconciliation is disabled for your company's Expensify Cards, a Domain Admin can set an export account for individual cards via **Settings > Domains > *Domain Name* > Company Cards > Edit Exports**. The Expensify Card transactions will always export as a Credit Card charge in your accounting software, regardless of the non-reimbursable setting in their accounting configuration.
+
+## NetSuite
+
+### Initial Setup
+1. Start by accessing your group workspace connected to NetSuite and click on "Configure" under **Connections > NetSuite**.
+2. On the Export tab, ensure that the Preferred Exporter is a Workspace Admin with an email address from your Expensify Cards' domain. For example, if your domain is company.com, the Preferred Exporter's email should be email@company.com.
+3. Head over to the Advanced tab and make sure Auto-Sync is enabled.
+4. Now, go to **Settings > Domains > *Domain Name* > Company Cards > Settings**. From the dropdown menu next to "Preferred Workspace," select the group workspace connected to NetSuite with Scheduled Submit enabled.
+5. In the dropdown menu next to "Expensify Card reconciliation account," choose your existing NetSuite bank account used for reconciliation. This account must match the one set in Step 3.
+6. In the dropdown menu next to "Expensify Card settlement account," select your daily settlement business bank account (found in Expensify under **Settings > Account > Payments**).
+
+### How This Works with Daily Settlement
+1. After setting up the card and running the first auto-sync, we'll create the Expensify Card Liability account and the Expensify Clearing Account within your NetSuite subsidiary general ledger.
+2. During the same sync, if there are newly posted transactions, we'll create a journal entry totaling all posted transactions for the day. This entry will credit the selected bank account and debit the new Expensify Clearing account.
+3. Once transactions are approved in Expensify, the report will be exported to NetSuite, with each line recorded as individual credit card expenses. Additionally, another journal entry will be generated, crediting the Expensify Clearing Account and debiting the Expensify Card Liability account.
+
+### How This Works with Monthly Settlement
+1. After the first monthly settlement, during Auto-Sync, Expensify creates a Liability Account in NetSuite (without a clearing account).
+2. Each time the monthly settlement occurs, Expensify calculates the total purchase amount since the last settlement and creates a Journal Entry that credits the settlement bank account (GL Account) and debits the Expensify Liability Account in NetSuite.
+3. As expenses are approved and exported to NetSuite, Expensify credits the Liability Account and debits the correct expense categories.
+
+**Note**: By default, the Journal Entries created by Expensify are set to the approval level "Approved for posting," so they will automatically credit and debit the appropriate accounts. If you have "Require approval on Journal Entries" enabled in your accounting preferences in NetSuite (**Setup > Accounting > Accounting Preferences**), this will override that default. Additionally, if you have set up Custom Workflows (**Customization > Workflow**), these can also override the default. In these cases, the Journal Entries created by Expensify will post as "Pending approval." You will need to approve these Journal Entries manually to complete the reconciliation process.
+
+### Example
+- Let's say you have card transactions totaling $100 for the day.
+- We create a journal entry:
+- After transactions are posted in Expensify, we create the second Journal Entry(ies):
+- We then reconcile the matching amounts automatically, clearing the balance of the Expensify Clearing Account.
+- Now, you'll have a debit on your Credit Card account (increasing the total spent) and a credit on the bank account (reducing the amount available). The clearing account has a $0 balance.
+- Each expense will also create a Journal Entry, just as we do today, exported upon final approval. This entry will debit the expense account (category) and contain any other line item data.
+- This process happens daily during the NetSuite Auto-Sync to keep your card reconciled.
+
+**Note**: Currently, only Journal Entry export is supported for auto-reconciliation. You can set other export options for all other non-reimbursable spend in the **Configure > Export** tab. Be on the lookout for Expense Report export in the future!
+
+If Auto-Reconciliation is disabled for your company's Expensify Cards, a Domain Admin can set an export account for individual Expensify Cards via **Settings > Domains > Company Cards > Edit Exports**. The Expensify Card transactions will always export as a Credit Card charge in your accounting software, regardless of the non-reimbursable setting in their accounting configuration.
+
+## Sage Intacct
+
+### Initial Setup
+1. Start by accessing your group workspace connected to Sage Intacct and click on "Configure" under **Connections > Sage Intacct**.
+2. On the Export tab, ensure that you've selected a specific entity. To enable Expensify to create the liability account, syncing at the entity level is crucial, especially for multi-entity environments.
+3. Still on the Export tab, confirm that the user chosen as the Preferred Exporter is a Workspace Admin, and their email address belongs to the domain used for Expensify Cards. For instance, if your domain is company.com, the Preferred Exporter's email should be email@company.com.
+4. Head over to the Advanced tab and make sure Auto-Sync is enabled.
+5. Now, go to **Settings > Domains > *Domain Name* > Company Cards > Settings**. From the dropdown menu next to "Preferred Workspace," select the group workspace connected to Sage Intacct with Scheduled Submit enabled.
+6. In the dropdown menu next to "Expensify Card reconciliation account" pick your existing Sage Intacct bank account used for daily settlement. This account must match the one set in the next step.
+7. In the dropdown menu next to "Expensify Card settlement account" select your daily settlement business bank account (found in Expensify under **Settings > Account > Payments**).
+8. Use the dropdown menus to select your cash-only and accrual-only journals. If your organization operates on a cash-only or accrual-only basis, choose "No Selection" for the journals as needed. If your organization uses both cash and accrual methods, please select both a cash-only and an accrual-only journal. Don't forget to save your settings!
+
+### How This Works with Daily Settlement
+1. After setting up the card and running the first auto-sync, we'll create the Expensify Card Expensify Clearing Account within your Sage Intacct general ledger. Once the first card transaction is exported, we'll create a Liability Account.
+2. In the same sync, if there are newly posted transactions from your Expensify Cards, we'll then create a journal entry totaling all posted transactions for the day. This entry will credit the business bank account (set in Step 4 above) and debit the new Expensify Clearing account.
+3. Once Expensify Card transactions are approved in Expensify, the report will be exported to Sage Intacct, with each line recorded as individual credit card expenses. Additionally, another journal entry will be generated, crediting the Expensify Clearing Account and debiting the Expensify Card Liability Account.
+
+### How This Works with Monthly Settlement
+1. After the initial export of a card transaction, Expensify establishes a Liability Account in Intacct (without a clearing account).
+2. Each time a monthly settlement occurs, Expensify calculates the total purchase amount since the last settlement and creates a Journal Entry. This entry credits the settlement bank account (GL Account) and debits the Expensify Liability Account in Intacct.
+3. As expenses are approved and exported to Intacct, Expensify credits the Liability Account and debits the appropriate expense categories.
+
+# FAQ
+
+## What are the timeframes for auto-reconciliation in Expensify?
+We offer either daily or monthly auto-reconciliation:
+- Daily Settlement: each day, as purchases are made on your Expensify Cards, the posted balance is withdrawn from your Expensify Card Settlement Account (your business bank account).
+- Monthly Settlement: each month, on the day of the month that you enabled Expensify Cards (or switched from Daily to Monthly Settlement), the posted balance of all purchases since the last settlement payment is withdrawn from your Expensify Card Settlement Account (your business bank account).
+
+## Why is my Expensify Card auto-reconciliation not working with Xero?
+When initially creating the Liability and Bank accounts to complete the auto-reconciliation process, we rely on the system to match and recognize those accounts created. You can't make any changes or we will not “find” those accounts.
+
+If you have changed the accounts. It's an easy fix, just rename them!
+- Internal Account Code: must be **ExpCardLbl**
+- Account Type: must be **Bank**
+
+## My accounting integration is not syncing. How will this affect the Expensify Card auto-reconciliation?
+When you receive a message that your accounting solution’s connection failed to sync, you will also receive an email or error message with the steps to correct the sync issue. If you do not, please contact Support for help. When your accounting solution’s sync reconnects and is successful, your auto-reconciliation will resume.
+
+If your company doesn't have auto-reconciliation enabled for its Expensify Cards, you can still set up individual export accounts. Here's how:
+
+1. Make sure you have Domain Admin privileges.
+2. Navigate to **Settings > Domains**
+3. Select 'Company Cards'
+4. Find the Expensify Card you want to configure and choose 'Edit Exports.'
+5. Pick the export account where you want the Expensify Card transactions to be recorded.
+6. Please note that these transactions will always be exported as Credit Card charges in your accounting software. This remains the case even if you've configured non-reimbursable settings as something else, such as a Vendor Bill.
+
+These simple steps will ensure your Expensify Card transactions are correctly exported to the designated account in your accounting software.
+
+## Why does my Expensify Card Liability Account have a balance?
+If you’re using the Expensify Card with auto-reconciliation, your Expensify Card Liability Account balance should always be $0 in your accounting system.
+
+If you see that your Expensify Card Liability Account balance isn’t $0, then you’ll need to take action to return that balance to $0.
+
+If you were using Expensify Cards before auto-reconciliation was enabled for your accounting system, then any expenses that occurred prior will not be cleared from the Liability Account.
+You will need to prepare a manual journal entry for the approved amount to bring the balance to $0.
+
+To address this, please follow these steps:
+1. Identify the earliest date of a transaction entry in the Liability Account that doesn't have a corresponding entry. Remember that each expense will typically have both a positive and a negative entry in the Liability Account, balancing out to $0.
+2. Go to the General Ledger (GL) account where your daily Expensify Card settlement withdrawals are recorded, and locate entries for the dates identified in Step 1.
+3. Adjust each settlement entry so that it now posts to the Clearing Account.
+4. Create a Journal Entry or Receive Money Transaction to clear the balance in the Liability Account using the funds currently held in the Clearing Account, which was set up in Step 2.
diff --git a/docs/articles/expensify-classic/exports/Insights.md b/docs/articles/expensify-classic/exports/Insights.md
index 682c2a251228..6c71630015c5 100644
--- a/docs/articles/expensify-classic/exports/Insights.md
+++ b/docs/articles/expensify-classic/exports/Insights.md
@@ -1,6 +1,7 @@
---
title: Custom Reporting and Insights
description: How to get the most out of the Custom Reporing and Insights
+redirect_from: articles/other/Insights/
---
{% raw %}
diff --git a/docs/articles/expensify-classic/get-paid-back/expenses/Apply-Tax.md b/docs/articles/expensify-classic/get-paid-back/expenses/Apply-Tax.md
index 36e0a2194d24..b5f5ec8be048 100644
--- a/docs/articles/expensify-classic/get-paid-back/expenses/Apply-Tax.md
+++ b/docs/articles/expensify-classic/get-paid-back/expenses/Apply-Tax.md
@@ -1,5 +1,39 @@
---
title: Apply Tax
-description: Apply Tax
+description: This is article shows you how to apply taxes to your expenses!
---
-## Resource Coming Soon!
+
+
+
+# About
+
+There are two types of tax in Expensify: Simple Tax (i.e. one tax rate) and Complex Tax (i.e. more than one tax rate). This article shows you how to apply both to your expenses!
+
+
+# How-to Apply Tax
+
+When Tax Tracking is enabled on a Workspace, the default tax rate is selected under **Settings > Workspace > _Workspace Name_ > Tax**, with the default tax rate applied to all expenses automatically.
+
+There may be multiple tax rates set up within your Workspace, so if the tax on your receipt is different to the default tax that has been applied, you can select the appropriate rate from the tax drop-down on the web expense editor or the mobile app.
+
+If the tax amount on your receipt is different to the calculated amount or the tax rate doesn’t show up, you can always manually type in the correct tax amount.
+
+
+# FAQ
+
+## How do I set up multiple taxes (GST/PST/QST) on indirect connections?
+Expenses sometimes have more than one tax applied to them - for example in Canada, expenses can have both a Federal GST and a provincial PST or QST.
+
+To handle these, you can create a single tax that combines both taxes into a single effective tax rate. For example, if you have a GST of 5% and PST of 7%, adding the two tax rates together gives you an effective tax rate of 12%.
+
+From the Reports page, you can select Reports and then click **Export To > Tax Report** to generate a CSV containing all the expense information, including the split-out taxes.
+
+
+# Deep Dive
+
+If you have a receipt that has more than one tax rate (i.e. Complex Tax) on it, then there are two options for handling this in Expensify!
+
+Many tax authorities do not require the reporting of tax amounts by rate and the easiest approach is to apply the highest rate on the receipt and then modify the tax amount to reflect the amount shown on the receipt if this is less. Please check with your local tax advisor if this approach will be allowed.
+
+Alternatively, you can apply each specific tax rate by splitting the expense into the components that each rate will be applied to. To do this, click on **Split Expense** and apply the correct tax rate to each part.
+
diff --git a/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md b/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md
index e565e59dc754..7fa714189542 100644
--- a/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md
+++ b/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md
@@ -48,11 +48,11 @@ You can also create a number of future 'placeholder' expenses for your recurring
# How to Edit Bulk Expenses
Editing expenses in bulk will allow you to apply the same coding across multiple expenses and is a web-only feature. To bulk edit expenses:
Go to the Expenses page.
-To narrow down your selection, use the filters (e.g. "Merchant" and "Open") to find the specific expenses you want to edit.
+To narrow down your selection, use the filters (e.g. "Merchant" and "Draft") to find the specific expenses you want to edit.
Select all the expenses you want to edit.
Click on the **Edit Multiple** button at the top of the page.
# How to Edit Expenses on a Report
-If you’d like to edit expenses within an Open report:
+If you’d like to edit expenses within a Draft report:
1. Click on the Report containing all the expenses.
2. Click on **Details**.
@@ -61,8 +61,8 @@ If you’d like to edit expenses within an Open report:
If you've already submitted your report, you'll need to Retract it or have it Unapproved first before you can edit the expenses.
-
# FAQ
+
## Does Expensify account for duplicates?
Yes, Expensify will account for duplicates. Expensify works behind the scenes to identify duplicate expenses before they are submitted, warning employees when they exist. If a duplicate expense is submitted, the same warning will be shown to the approver responsible for reviewing the report.
@@ -71,6 +71,7 @@ If two expenses are SmartScanned on the same day for the same amount, they will
The expenses were split from a single expense,
The expenses were imported from a credit card, or
Matching email receipts sent to receipts@expensify.com were received with different timestamps.
+
## How do I resolve a duplicate expense?
If Concierge has let you know it's flagged a receipt as a duplicate, scanning the receipt again will trigger the same duplicate flagging.Users have the ability to resolve duplicates by either deleting the duplicated transactions, merging them, or ignoring them (if they are legitimately separate expenses of the same date and amount).
@@ -88,12 +89,13 @@ Click the **Undelete** button and you're all set. You’ll find the expense on y
## What are the different Expense statuses?
There are a number of different expense statuses in Expensify:
-1. **Unreported**: Unreported expenses are not yet part of a report (and therefore unsubmitted) and are not viewable by anyone but the expense creator/owner.
-2. **Open**: Open expenses are on a report that's still in progress, and are unsubmitted. Your Policy Admin will be able to view them, making it a collaborative step toward reimbursement.
+1. **Personal**: Personal expenses are not yet part of a report (and therefore unsubmitted) and are not viewable by anyone but the expense creator/owner.
+2. **Draft**: Draft expenses are seen as still in progress, and are unsubmitted. Your Policy Admin will be able to view them, making this a collaborative step toward reimbursement.
3. **Processing**: Processing expenses are submitted, but waiting for approval.
4. **Approved**: If it's a non-reimbursable expense, the workflow is complete at this point. If it's a reimbursable expense, you're one step closer to getting paid.
5. **Reimbursed**: Reimbursed expenses are fully settled. You can check the Report Comments to see when you'll get paid.
6. **Closed**: Sometimes an expense accidentally ends up on your Individual Policy, falling into the Closed status. You’ll need to reopen the report and change the Policy by clicking on the **Details** tab in order to resubmit your report.
+
## What are Violations?
Violations represent errors or discrepancies that Expensify has picked up and need to be corrected before a report can be successfully submitted. The one exception is when an expense comment is added, it will override the violation - as the user is providing a valid reason for submission.
@@ -101,8 +103,9 @@ Violations represent errors or discrepancies that Expensify has picked up and ne
To enable or configure violations according to your policy, go to **Settings > Policies > _Policy Name_ > Expenses > Expense Violations**. Keep in mind that Expensify includes certain system mandatory violations that can't be disabled, even if your policy has violations turned off.
You can spot violations by the exclamation marks (!) attached to expenses. Hovering over the symbol will provide a brief description and you can find more detailed information below the list of expenses. The two types of violations are:
-**Red**: These indicate violations directly tied to your report's Policy settings. They are clear rule violations that must be addressed before submission.
-**Yellow**: Concierge will highlight items that require attention but may not necessarily need corrective action. For example, if a receipt was SmartScanned and then the amount was modified, we’ll bring it to your attention so that it can be manually reviewed.
+1. **Red**: These indicate violations directly tied to your report's Policy settings. They are clear rule violations that must be addressed before submission.
+2. **Yellow**: Concierge will highlight items that require attention but may not necessarily need corrective action. For example, if a receipt was SmartScanned and then the amount was modified, we’ll bring it to your attention so that it can be manually reviewed.
+
## How to Track Attendees
Attendee tracking makes it easy to track shared expenses and maintain transparency in your group spending.
@@ -116,9 +119,10 @@ External attendees are considered users outside your group policy or domain. To
1. Click or tap the **Attendee** field within your expense.
2. Type in the individual's name or email address.
3. Tap **Add** to include the attendee.
-You can continue adding more attendees or save the Expense.
+4. You can continue adding more attendees or save the Expense.
+
To remove an attendee from an expense:
-Open the expense.
-Click or tap the **Attendees** field to display the list of attendees.
-From the list, de-select the attendees you'd like to remove from the expense.
+1. Open the expense.
+2. Click or tap the **Attendees** field to display the list of attendees.
+3. From the list, de-select the attendees you'd like to remove from the expense.
diff --git a/docs/articles/expensify-classic/get-paid-back/expenses/Merge-Expenses.md b/docs/articles/expensify-classic/get-paid-back/expenses/Merge-Expenses.md
index bfbc0773768c..a8444b98c951 100644
--- a/docs/articles/expensify-classic/get-paid-back/expenses/Merge-Expenses.md
+++ b/docs/articles/expensify-classic/get-paid-back/expenses/Merge-Expenses.md
@@ -14,7 +14,7 @@ Keep in mind:
1. Merging expenses cannot be undone.
2. You can only merge two expenses at a time.
3. You can merge a cash expense with a credit card expense, or two cash expenses - but not two credit card expenses.
-4. In order to merge, both expenses will need to be in an Open or Unreported state.
+4. In order to merge, both expenses will need to be in a Personal or Draft status.
# How to merge expenses on the web app
To merge two expenses from the Expenses page:
@@ -41,11 +41,12 @@ If the expenses exist on two different reports, you will be asked which report y
## Can you merge expenses across different reports?
-You cannot merge expenses across different reports. Expenses will only merge if they are on the same report. If you have expenses across different reports that you wish to merge, you’ll need to move both expenses onto the same report (and ensure they are in the Open status) in order to merge them.
+You cannot merge expenses across different reports. Expenses will only merge if they are on the same report. If you have expenses across different reports that you wish to merge, you’ll need to move both expenses onto the same report (and ensure they are in the Draft status) in order to merge them.
## Can you merge expenses across different accounts?
You cannot merge expenses across two separate accounts. You will need to choose one submitter and transfer the expense information to that user's account in order to merge the expense.
+
## Can you merge expenses with different currencies?
Yes, you can merge expenses with different currencies. The conversion amount will be based on the daily exchange rate for the date of the transaction, as long as the converted rates are within +/- 5%. If the currencies are the same, then the amounts must be an exact match to merge.
diff --git a/docs/articles/expensify-classic/getting-started/Individual-Users.md b/docs/articles/expensify-classic/getting-started/Individual-Users.md
index de7a527df010..12029f80388b 100644
--- a/docs/articles/expensify-classic/getting-started/Individual-Users.md
+++ b/docs/articles/expensify-classic/getting-started/Individual-Users.md
@@ -1,5 +1,43 @@
---
title: Individual Users
-description: Individual Users
+description: Learn how Expensify can help you track and submit your personal or self-employed business expenses.
---
-## Resource Coming Soon!
+# Overview
+If you're an individual using Expensify, the Track and Submit plans are designed to assist self-employed users in effectively managing both their personal and business finances.
+
+# How to use the Track plan
+
+The Track plan is tailored for solo Expensify users who don't require expense submission to others. Individuals or sole proprietors can choose the Track plan to customize their Individual Workspace to align with their personal expense tracking requirements.
+
+You can select the Track plan from the Workspace settings. Navigate to **Settings > Workspace > Individual > *[Workspace Name]* > Plan** to select Track.
+You can also do this from the Pricing page at https://www.expensify.com/pricing.
+
+The Track plan includes a predefined set of categories designed to align with IRS Schedule C expense categories. However, you have the flexibility to add extra categories as needed. For a more detailed breakdown, you can also set up tags to create another layer of coding.
+
+The Track plan offers 25 free SmartScans per month. If you require more than 25 SmartScans, you can upgrade to a Monthly Individual subscription at a cost of $4.99 USD per month.
+
+# How to use the Submit plan
+The Submit plan is designed for individuals who need to keep track of their expenses and share them with someone else, such as their boss, accountant, or even a housemate. It's specifically tailored for single users who want to both track and submit their expenses efficiently.
+
+You can select the Track plan from the Workspace settings. Navigate to **Settings > Workspaces > Individual > *[Workspace Name]* > Plan** to select "Submit" or from the Pricing page at https://www.expensify.com/pricing.
+
+You will select who your expenses get sent to under **Settings > Workspace > Individual > *[Workspace Name]* > Reports**. If the recipient already has an Expensify account, they'll be able to see the report directly in the Expensify app. Otherwise, non-Expensify users will receive a PDF copy of the report attached to the email so it can be processed.
+
+The Submit plan includes a predefined set of categories designed to align with IRS Schedule C expense categories. However, you have the flexibility to add extra categories as needed. For a more detailed breakdown, you can also set up tags to create another layer of coding.
+
+The Submit plan offers 25 free SmartScans per month.If you require more than 25 SmartScans, you can upgrade to a Monthly Individual subscription at a cost of $4.99 USD per month.
+
+# FAQ
+
+## Who should use the Track plan?
+An individual who wants to store receipts, look to track spending by category to help with budgeting and a self-employed user who needs to track receipts and mileage for tax purposes.
+
+## Who should use the Submit plan?
+An individual who seeks to utilize the features of the track plan to monitor their expenses while also requiring the ability to submit those expenses to someone else.
+
+## How can I keep track of personal and business expenses in the same account?
+You have the capability to create distinct "business" and "personal" tags and assign them to your expenses for proper categorization. By doing so, you can effectively code your expenses based on their nature. Additionally, you can utilize filters to ensure that you only view the expenses that are relevant to your specific needs, whether they are business-related or personal.
+
+## How can I export expenses for tax purposes?
+From the expense page, you have the option to select all of your expenses and export them to a CSV (Comma-Separated Values) file. This CSV file can be conveniently imported directly into your tax software for easier tax preparation.
+
diff --git a/docs/articles/expensify-classic/getting-started/Referral-Program.md b/docs/articles/expensify-classic/getting-started/Referral-Program.md
index 683e93d0277a..b4a2b4a7de74 100644
--- a/docs/articles/expensify-classic/getting-started/Referral-Program.md
+++ b/docs/articles/expensify-classic/getting-started/Referral-Program.md
@@ -1,6 +1,7 @@
---
title: Expensify Referral Program
description: Send your joining link, submit a receipt or invoice, and we'll pay you if your referral adopts Expensify.
+redirect_from: articles/other/Referral-Program/
---
diff --git a/docs/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners.md b/docs/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners.md
index b18531d43200..a8e1b0690b72 100644
--- a/docs/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners.md
+++ b/docs/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners.md
@@ -1,6 +1,7 @@
---
title: Expensify Card revenue share for ExpensifyApproved! partners
description: Earn money when your clients adopt the Expensify Card
+redirect_from: articles/other/Card-Revenue-Share-for-ExpensifyApproved!-Partners/
---
diff --git a/docs/articles/expensify-classic/getting-started/approved-accountants/Your-Expensify-Partner-Manager.md b/docs/articles/expensify-classic/getting-started/approved-accountants/Your-Expensify-Partner-Manager.md
index c7a5dc5a04ab..104cd49daf96 100644
--- a/docs/articles/expensify-classic/getting-started/approved-accountants/Your-Expensify-Partner-Manager.md
+++ b/docs/articles/expensify-classic/getting-started/approved-accountants/Your-Expensify-Partner-Manager.md
@@ -1,6 +1,7 @@
---
title: Your Expensify Partner Manager
description: Everything you need to know about your Expensify Partner Manager
+redirect_from: articles/other/Your-Expensify-Partner-Manager/
---
diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md
index 2b95a1d13fde..a7553e6ae179 100644
--- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md
+++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md
@@ -1,6 +1,7 @@
---
title: Expensify Playbook for Small to Medium-Sized Businesses
description: Best practices for how to deploy Expensify for your business
+redirect_from: articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses/
---
## Overview
This guide provides practical tips and recommendations for small businesses with 100 to 250 employees to effectively use Expensify to improve spend visibility, facilitate employee reimbursements, and reduce the risk of fraudulent expenses.
diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-Bootstrapped-Startups.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-Bootstrapped-Startups.md
index 86c6a583c758..bef59546a13d 100644
--- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-Bootstrapped-Startups.md
+++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-Bootstrapped-Startups.md
@@ -1,6 +1,7 @@
---
title: Expensify Playbook for US-Based Bootstrapped Startups
description: Best practices for how to deploy Expensify for your business
+redirect_from: articles/playbooks/Expensify-Playbook-for-US-Based-Bootstrapped-Startups/
---
This playbook details best practices on how Bootstrapped Startups with less than 5 employees can use Expensify to prioritize product development while capturing business-related receipts for future reimbursement.
diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md
index 501d2f1538ef..bdce2cd7bf81 100644
--- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md
+++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md
@@ -1,6 +1,7 @@
---
title: Expensify Playbook for US-Based VC-Backed Startups
description: Best practices for how to deploy Expensify for your business
+redirect_from: articles/playbooks/Expensify-Playbook-for-US-based-VC-Backed-Startups/
---
This playbook details best practices on how Seed to Series A startups with under 100 employees can use Expensify to prioritize top-line revenue growth while managing spend responsibly.
diff --git a/docs/articles/expensify-classic/getting-started/support/Your-Expensify-Account-Manager.md b/docs/articles/expensify-classic/getting-started/support/Your-Expensify-Account-Manager.md
index 3ef47337a74c..a6fa0220c0dc 100644
--- a/docs/articles/expensify-classic/getting-started/support/Your-Expensify-Account-Manager.md
+++ b/docs/articles/expensify-classic/getting-started/support/Your-Expensify-Account-Manager.md
@@ -1,6 +1,7 @@
---
title: Your Expensify Account Manager
description: Everything you need to know about Having an Expensify account manager
+redirect_from: articles/other/Your-Expensify-Account-Manager/
---
diff --git a/docs/articles/expensify-classic/getting-started/tips-and-tricks/Enable-Location-Access-On-Web.md b/docs/articles/expensify-classic/getting-started/tips-and-tricks/Enable-Location-Access-On-Web.md
index 649212b00f7b..507d24503af8 100644
--- a/docs/articles/expensify-classic/getting-started/tips-and-tricks/Enable-Location-Access-On-Web.md
+++ b/docs/articles/expensify-classic/getting-started/tips-and-tricks/Enable-Location-Access-On-Web.md
@@ -1,6 +1,7 @@
---
title: Enable Location Access on Web
description: How to enable location access for Expensify websites on your browser
+redirect_from: articles/other/Enable-Location-Access-on-Web/
---
diff --git a/docs/articles/expensify-classic/integrations/HR-integrations/Greenhouse.md b/docs/articles/expensify-classic/integrations/HR-integrations/Greenhouse.md
index 3ee1c8656b4b..b44e5a090d17 100644
--- a/docs/articles/expensify-classic/integrations/HR-integrations/Greenhouse.md
+++ b/docs/articles/expensify-classic/integrations/HR-integrations/Greenhouse.md
@@ -1,5 +1,43 @@
---
-title: Coming Soon
-description: Coming Soon
+title: Greenhouse Integration
+description: Automatically send candidates from Greenhouse to Expensify for easy reimbursement
---
-## Resource Coming Soon!
+
+# Overview
+Expensify's direct integration with Greenhouse allows you to automatically send candidates from Greenhouse to Expensify for easy reimbursement. The integration can set the candidate's recruiter or recruiting coordinator as approver in Expensify.
+
+## Prerequisites of the integration
+- You must be a Workspace Admin in Expensify and an Admin in Greenhouse with Developer Permissions to complete this connection. This can be the same person or two different people.
+- Each Greenhouse candidate record must have an email address in order to send to Expensify since we use this as the unique identifier in Expensify.
+- We highly recommend that you create a specific Expensify workspace for candidates so that you can set up a separate workflow and a different set of Categories and Tags from what your employees would see.
+
+# How to connect Greenhouse to Expensify
+## Establish the connection from Expensify
+
+1. Log into Expensify as a Workspace admin and navigate to **Settings > Workspaces > _[Workspace Name]_ > Connections**
+2. Under Greenhouse, click **Connect to Greenhouse** then click **Sync with Greenhouse**, which will open the "Greenhouse Integration" instructions page in a new browser window
+
+## Create the Web hook
+
+1. Click the link under Step 1 on the Greenhouse Integration instructions page, or log into your Greenhouse account and navigate to **Configure > Dev Center > Web Hooks > Web Hooks**.
+2. After landing on the "Create a New Web Hook" page, follow the steps on the Greenhouse Integration instructions page to create the web hook.
+
+## Create the custom candidate field
+
+1. Click the link under Step 2 on the Greenhouse Integration instructions page, or log into your Greenhouse account and navigate to **Configure > Custom Options > Custom Company Fields > Candidates**
+2. Follow the steps on the Greenhouse Integration instructions page to create the custom Candidate field.
+3. Click **Finish** (Step 3 on the Greenhouse Integration instructions page) to finish connecting Greenhouse with Expensify.
+
+# How to send candidates from Greenhouse to Expensify
+## In Greenhouse:
+
+1. Log into Greenhouse and go to any candidate’s Details tab
+2. Confirm that the Email field is filled in
+3. Optionally select the Recruiter field to set the recruiter as the candidate's expense approver in Expensify (Note: if you'd prefer to have the Recruiting Coordinator used as the default approver, please reach out to concierge@expensify.com or your account manager to request that we change the default approver on your behalf)
+4. Send this candidate to Expensify by toggling the **Invite to Expensify** field to **Yes** and clicking **Save**
+
+## In Expensify:
+
+1. Navigate to **Settings > Policies > Group > _[Workspace Name]_ > Members**
+2. The candidate you just sent to Expensify should be listed in the workspace members list
+3. If the Recruiter (or Recruiting Coordinator) field was filled in in Greenhouse, the candidate will already be configured to submit reports to that recruiter for approval. If no Recruiter was selected, then the candidate will submit based on the Expensify workspace approval settings.
diff --git a/docs/articles/expensify-classic/integrations/HR-integrations/Rippling.md b/docs/articles/expensify-classic/integrations/HR-integrations/Rippling.md
index 3ee1c8656b4b..fa4aaec3376f 100644
--- a/docs/articles/expensify-classic/integrations/HR-integrations/Rippling.md
+++ b/docs/articles/expensify-classic/integrations/HR-integrations/Rippling.md
@@ -1,5 +1,13 @@
---
-title: Coming Soon
-description: Coming Soon
+title: Rippling Integration
+description: Sync employee and expense data between Expensify and Rippling
---
-## Resource Coming Soon!
+# Overview
+The Rippling integration allows mutual customers to sync employee and expense data between Expensify and Rippling. The Rippling integration allows you to:
+1. **Automate Employee Management:** Automatically create and remove employee accounts in Expensify directly from Rippling.
+2. **Simplify Employee Access to Expensify:** Employees can sign into Expensify from their Rippling SSO bar using SAML single sign-on.
+3. **Import Reimbursable Expense Reports:** Admins can export reimbursable expense reports from Expensify directly into Rippling Payroll.
+
+# How to use the Rippling integration
+The Rippling team manages this integration. To connect your Expensify workspace with Rippling, please visit the Rippling App Shop and sign into your Rippling account.
+For instructions on how to connect, and for troubleshooting the integration, please contact the Rippling support team by emailing support@rippling.com.
diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/Indirect-Accounting-Integrations.md b/docs/articles/expensify-classic/integrations/accounting-integrations/Indirect-Accounting-Integrations.md
new file mode 100644
index 000000000000..852db0b7f7c0
--- /dev/null
+++ b/docs/articles/expensify-classic/integrations/accounting-integrations/Indirect-Accounting-Integrations.md
@@ -0,0 +1,48 @@
+---
+title: Indirect Accounting Integrations
+description: Learn how to export your expenses and reports to a built-for-purpose flat file that works with your accounting platform.
+---
+
+
+# Overview
+
+Along with the direct integrations Expensify supports, there's also an option to integrate with other accounting solutions via a flat-file import.
+
+When you set up one of these accounting packages in Expensify, we will automatically create and add a relevant export template. The template will allow you to quickly and easily transfer expense and report data to your accounting package.
+
+# How to Set Up an Indirect Accounting Integration
+
+## Home Page
+
+After selecting your Group Plan type for your first workspace, you'll be taken through a few workspace setup tasks on the home page. When you reach the **Accounting Software** task, select your accounting solution from the available options.
+
+You'll receive a confirmation message, and the respective export template will be added to the account. From then on, it will show in the **Export to** option on the **Reports** page and at the top of each report.
+
+## Workspace Settings
+
+Head to **Settings** > **Workspaces** > **Group** > _Your desired workspace_ > **Connections** and select an accounting package from the options listed here. You'll receive a confirmation message, and the respective export template will be added to the account. From then on, it will show in the **Export to** option on the **Reports** page and at the top of each report.
+
+# How to Export a Report for My Accounting Package
+
+You can export reports to these templates in two ways:
+
+To export a report, click **Export To** in the top-left of a report and select your accounting package from the dropdown menu.
+
+To export multiple reports, tick the checkbox next to the reports on the **Reports** page, then click **Export To** and select your accounting package from the dropdown menu.
+
+# FAQ
+
+## Which accounting packages offer this indirect integration with Expensify?
+
+We support a pre-configured flat-file integration for the following accounting packages:
+
+ - Sage
+ - Microsoft Dynamics
+ - MYOB
+ - Oracle
+ - SAP
+
+## What if my accounting package isn’t listed here?
+
+If your accounting package isn’t listed, but it still accepts a flat-file import, select **Other** when completing the Accounting Software task on your Home page or head to **Settings** > **Workspaces** > **Group** > _Your desired workspace_ > **Export Formats**. This option allows you to create your own templates to export your expense and report data into a format compatible with your accounting system.
+
diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Global-VaTax.md b/docs/articles/expensify-classic/integrations/travel-integrations/Global-VaTax.md
index 3ee1c8656b4b..4a18b30458a6 100644
--- a/docs/articles/expensify-classic/integrations/travel-integrations/Global-VaTax.md
+++ b/docs/articles/expensify-classic/integrations/travel-integrations/Global-VaTax.md
@@ -1,5 +1,31 @@
---
-title: Coming Soon
-description: Coming Soon
+title: Global VaTax Integration
+description: The Expensify-Global VaTax integration turns your company’s international expenses into an easy VAT refund.
---
-## Resource Coming Soon!
+# About
+The Expensify-Global VaTax integration turns your company’s international expenses into an easy VAT refund.
+If your company is based in the U.S. but doing business abroad, track your [expenses](https://use.expensify.com/expense-management) in Expensify and then sync to [Global VaTax](https://globalvatax.com/) to receive a VAT refund.
+## VAT
+[Value Added Tax (VAT)](https://www.investopedia.com/terms/v/valueaddedtax.asp#:~:text=Value%2Dadded%20tax%20(VAT)%20is%20a%20flat%20tax%20levied,different%20parties%20to%20a%20transaction.) is a consumption tax applied to goods and services in many countries. It is typically applied automatically upon purchase, however, international business travelers can reclaim some VAT.
+
+VAT-recoverable expenses include hotels, entertainment, conferences, legal and marketing fees, DDP shipping and storage fees, aviation recovery, and other international expenses.
+## VAT Refund
+The VAT refund process is complex and requires a detailed understanding of the regulations and requirements in each country. The VAT rules and rates vary from country to country and by expense type.
+
+You can seamlessly sync your Expensify expenses to Global VaTax for easy VAT analysis, calculation, and reporting.
+
+
+# How to Connect to Global VaTax
+1. Fill out [this form](https://www.vataxcloud.com/expensify/signup) to receive a confirmation email from Global VaTax.
+2. Click the link in the confirmation email to sign into your Global VaTax account.
+3. Go to the **Method of Extraction** section.
+4. Click **Expensify**
+5. Follow the steps to obtain your partner credentials and activate the integration.
+6. Enter your credentials and extraction dates
+7. Click **Submit**
+8. VaTax Cloud will begin automatically calculating your VAT rates for each eligible expense line item, per expense type, in each country.
+9. Within a few hours, you'll receive an email that your VAT potential analysis report is ready for review.
+
+Your VAT reclaim will be prepared by Global VaTax in the necessary languages and submitted to the appropriate tax agents in each country.
+
+After submitting your VAT reclaim, you can track it via the submission analysis report in Global VaTax.
diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md
index a65dc378a793..65238457f1a9 100644
--- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md
+++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md
@@ -9,34 +9,34 @@ This guide is for those who are part of a **Group Workspace**.
Each member has a role that defines what they can see and do in the workspace. Most members will have the role of "Employee."
-# How to Manage User Roles
+# How to manage user roles
-To find and edit the roles of group workspace members, go to **Settings > Workspaces > Group > [Your Specific Workspace Name] > Members > Workspace Members**
+To find and edit the roles of group workspace members, go to **Settings > Workspaces > Group > _[Workspace Name]_ > Members > Workspace Members**
Here you'll see the list of members in your group workspace. To change their roles, click **Settings** next to the member’s name and choose the role that the member needs.
Next, let’s go over the various user roles that are available on a group workspace.
-## The Employee Role
+### The Employee Role
- **What can they do:** Employees can only see their own expense reports or reports that have been submitted to or shared with them. They can't change settings or invite new users.
- **Who is it for:** Regular employees who only need to manage their own expenses, or managers who are reviewing expense reports for a few users but don’t need global visibility.
- **Approvers:** Members who approve expenses can either be Employees, Admins, or Workspace Auditors, depending on how much control they need.
- **Billable:** Employees are billable actors if they take actions on a report on your Group Workspace (including **SmartScanning** a receipt).
-## Workspace Admin Role
+### Workspace Admin Role
- **What can they do:** Admins have full control. They can change settings, invite members, and view all reports. They can also process reimbursements if they have access to the company’s account.
- **Billing Owners:** Billing owners are Admins by default. **Workspace Admins** are assigned by the owner or another admin.
- **Billable:** Yes, if they perform actions like changing settings or inviting users. Just viewing reports is not billable.
-## Workspace Auditor Role
+### Workspace Auditor Role
- **What can they do:** Workspace Auditors can see all reports, make comments, and export them. They can also mark reports as reimbursed if they're the final approver.
- **Who is it for:** Accountants, bookkeepers, and internal or external audit agents who need to view but not edit workspace settings.
- **Billable:** Yes, if they perform any actions like commenting or exporting a report. Viewing alone doesn't incur a charge.
-## Technical Contact
+### Technical Contact
- **What can they do:** In case of connection issues, alerts go to the billing owner by default. You can set a technical contact if you want alerts to go to an IT administrator instead.
- **How to set one:** Go to **Settings > Workspaces > Group > [Workspace Name] > Connections > Technical Contact**.
diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate.md
index e107734216f5..7c21b12a83e1 100644
--- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate.md
+++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate.md
@@ -1,8 +1,54 @@
---
-title: Coming Soon
-description: Coming Soon
+title: Vacation Delegate
+description: In Expensify, a vacation delegate is someone you choose to act on your behalf when you're on vacation or taking personal time off.
---
-## Resource Coming Soon!
+
+# Overview
+
+A delegate is someone who can handle approving expense reports for you, which is especially useful when you're out of the office!
+
+In Expensify, a **Vacation Delegate** is someone you choose to act on your behalf when you're on vacation or taking personal time off. They will approve expense reports just like you would, and everything moves forward as usual afterward.
+
+The system keeps a detailed audit trail, showing exactly when your delegate stepped in to approve a report for you. And if your delegate also goes on vacation, they can have their own delegate, so reports keep getting approved.
+
+By using this feature, you ensure that all reports get the approvals they need, even when you're not around.
+
+# How to use Vacation Delegate
+
+If you're planning to take some time off, you can use the **Vacation Delegate** feature to assign someone to approve expense reports for you. The reports will continue on their usual path as if you had approved them yourself.
+
+## Set a Vacation Delegate for yourself
+
+1. Go to the Expensify website (note: you can't do this from the mobile app).
+2. Navigate to **Settings > Your Account > Account Details** and scroll down to find **Vacation Delegate**.
+3. Enter the email address of the person you're designating as your delegate and click **Set Delegate**.
+
+Voila! You've set a vacation delegate. Any reports that usually come to you will now go to your delegate instead. When you return, you can easily remove the delegate by clicking a link at the top of the Expensify homepage.
+
+## Setting a Vacation Delegate as a Domain Admin
+
+1. Head to **Settings > Domains > [Your Domain Name] > Domain Members > Edit Settings**
+2. Enter the delegate's email address and click **Save.**
+
+Your delegate's actions will be noted in the history and comments of each report they approve, so you can keep track of what happened while you were away.
+
+# Deep Dive
+
+## An audit trail of delegate actions
+
+The system records every action your vacation delegate takes on your behalf in the **Report History and Comments**. So, you can see when they approved an expense report for you.
+
+# FAQs
+
+## Why can't my Vacation Delegate reimburse reports that they approve?
+
+If your **Vacation Delegate** also needs to reimburse reports on your behalf whilst you're away, they'll also need access to the reimbursement account.
+
+If they do not have access to the reimbursement account used on your workspace, they won’t have the option to reimburse reports, even as your **Vacation Delegate**.
+
+## What if my Vacation Delegate is also on vacation?
+
+Don't worry, your delegate can also pick their own **Vacation Delegate**. This way, expense reports continue to get approved even if multiple people are away.
+
-Kayak.md Lyft.md TrainLine.md TravelPerk.md Trip Actions.md TripCatcher.md Uber.md
\ No newline at end of file
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Admins.md b/docs/articles/expensify-classic/policy-and-domain-settings/Admins.md
deleted file mode 100644
index cea96cfe2057..000000000000
--- a/docs/articles/expensify-classic/policy-and-domain-settings/Admins.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Admins
-description: Admins
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Admins.md b/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Admins.md
deleted file mode 100644
index 3ee1c8656b4b..000000000000
--- a/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Admins.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Coming Soon
-description: Coming Soon
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Members.md b/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Members.md
deleted file mode 100644
index 3ee1c8656b4b..000000000000
--- a/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Members.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Coming Soon
-description: Coming Soon
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Expenses.md b/docs/articles/expensify-classic/policy-and-domain-settings/Expenses.md
index 3ee1c8656b4b..424338120010 100644
--- a/docs/articles/expensify-classic/policy-and-domain-settings/Expenses.md
+++ b/docs/articles/expensify-classic/policy-and-domain-settings/Expenses.md
@@ -1,5 +1,118 @@
---
-title: Coming Soon
-description: Coming Soon
+title: Expensify Workspace Expense Settings
+description: Expense Settings
---
-## Resource Coming Soon!
+# Overview
+
+Expensify offers multiple ways to customize how expenses are created in your workspace. In this doc, you’ll learn how to set up and expense basics, distance expenses, and time expenses.
+
+Whether you’re flying solo with your Individual workspace or submitting with a team on your Group workspace, we have settings to support how you use Expensify.
+
+# How to manage expense settings in your workspace
+
+Let’s cover the expense basics first! In the following sections, we’ll go through each part of managing expense settings in your workspace.
+
+## Controlling cash expenses
+
+A cash expense is any expense created manually or by uploading a receipt for SmartScan; a cash expense does not mean the expense was paid for with cash. The other type of expense you’ll most commonly see is credit card expenses, which means the expenses imported from a credit card or bank connection.
+
+There are four options for cash expenses:
+
+- **Reimbursable by default** - All cash expenses are reimbursable but can be marked as non-reimbursable as needed.
+- **Non-reimbursable by default** - All cash expenses are non-reimbursable but can be marked as reimbursable as needed.
+- **Forced always reimbursable** - All cash expenses are forced to be reimbursable; they cannot be marked as non-reimbursable.
+- **Forced always non-reimbursable** - All cash expenses are forced to be non-reimbursable; they cannot be marked as reimbursable.
+
+## Setting up billable expenses
+
+Billable expenses refer to expenses you or your employees incur that need to be re-billed to a specific client or vendor.
+
+If you need to track expenses for the purpose of billing them to customers, clients, or other departments, billable expenses are supported in both Individual and Group workspaces. Either way, head to **Settings** > **Workspaces** > **Individual** or **Group** > [_Workspace Name_] > **Expenses**.
+
+Under Expense Basics, you can choose the setting that is best for you.
+
+- **Disabled** means expenses are not allowed to be billable at all.
+- **Default to billable** means expenses will always be billable but can be marked as non-billable as needed.
+- **Default to non-billable** means expenses will always be non-billable but can be marked as billable as needed.
+
+If your Group workspace is connected to Xero, QuickBooks Online, NetSuite, or Sage Intacct, you can export billable expenses to be invoiced to customers. To set this up, go to the Coding tab in the connection configuration settings.
+
+## Using eReceipts
+
+eReceipts are full digital replacements of their paper equivalents for purchases of $75 or less.
+
+Click the toggle to your preferred configuration.
+
+- **Enabled** - All imported credit card expenses in US dollars of $75 or less will have eReceipts in the receipt image.
+- **Disabled** - No expenses will generate an eReceipt.
+
+Note: _We will not generate an eReceipt for lodging expenses._
+
+## Securing receipt images
+
+Whether you’re sharing your receipts with your accountant, having an auditor review exported expenses, or simply wanting to export to keep a hard copy for yourself, receipt visibility will be an essential consideration.
+
+Under _Public Receipt Visibility_, you can determine who can view receipts on your workspace.
+
+- **Enabled** means receipts are viewable by anyone with the URL. They don't need to be an Expensify user or a workspace member to view receipts.
+- **Disabled** means receipts are viewable by users of Expensify, who would have access to view the receipt in the application. You must be an Expensify user with access to the report a receipt is on and logged into your account to view a receipt image via URL.
+
+
+## Track mileage expenses
+
+Whether using the Individual or Group workspace, you can create distance rates to capture expenses in miles or kilometers.
+
+Preliminary setup steps include:
+
+1. Selecting whether you want to capture _miles_ or _kilometers_,
+2. Setting the default category to be used on distance expenses,
+3. Click **Add A Mileage Rate** to add as many rates as you need,
+4. Set the reimbursable amount per mile or kilometer.
+
+Note: _If a rate is toggled off it is immediately disabled. This means that users are no longer able to select it when creating a new distance expense. If only one rate is available then this rate will be toggled on by default._
+
+## Set an hourly rate
+
+Using Expensify you can track time-based expenses to bill your clients at an hourly rate or allow employees to claim an hourly stipend.
+
+Click the toggle under the _Time_ section to enable the feature and set a default hourly rate. After that, you and your users will be able to create time-based expenses from the [**Expenses**](https://expensify.com/expenses) page of the account.
+
+# Deep dives
+
+## What is Concierge Receipt Audit for the Control Plan?
+
+Concierge Receipt Audit is a real-time audit and compliance of receipts submitted by employees and workspace users. Concierge checks every receipt for accuracy and compliance, flagging any expenses that seem fishy before expense reports are even submitted for approval. All risky expenses are highlighted for manual review, leaving you with more control over and visibility into expenses. When a report is submitted and there are risky expenses on it, you will be immediately prompted to review the risky expenses and determine the next steps.
+
+**Why you should use Concierge Receipt Audit**
+
+- To make sure you don't miss any risky expenses that need human oversight.
+- To avoid needing to manually review all your company receipts.
+- It's included for free with the [Control Plan](https://www.expensify.com/pricing).
+- Instead of paying someone to audit your company expenses or being concerned that your expenses might be audited by a government agency.
+- It's easy to use! Concierge will alert you to the risky expense and present it to you in an easy-to-follow review tutorial.
+- In addition to the risky expense alerts, Expensify will include a Note with audit details on every report.
+
+Note: _If a report has audit alerts on it, you'll need to Review the report and Accept the alerts before it can be approved._
+
+## Tracking tax on mileage expenses
+
+If you’re tracking tax in Expensify you can also track tax on distance expenses. The first step is to enable tax the workspace. You can do this by going to **Settings** > **Workspaces** > **Individual** or **Group** > [_Workspace Name_] > **Tax**.
+
+Once tax is enabled on a workspace level you will see a toggle to _Track Tax_ in the Distance section of the workspace settings. If tax is disabled on the workspace the Track Tax toggle will not display.
+
+When Track Tax is enabled you will need to enter additional information to the rates you have set, this includes the _Tax Reclaimable on_ and _Tax Rate_ fields. With that information, Expensify will work out the correct tax reclaim for each expense.
+
+If you enable tax but don’t select a tax rate or enter a tax reclaimable amount, we will not calculate any tax amount for that rate. If, at any point, you switch the tax rate or enter a different reclaimable portion for an existing distance rate, the mileage rate will need to be re-selected on expenses for the tax amount to update according to the new values.
+
+Note: _Expensify won’t automatically track cumulative mileage. If you need to track cumulative mileage per employee, we recommend building a mileage report using our custom export formulas._
+
+# FAQs
+
+## Why do I see eReceipts for expenses greater than $75?
+
+An eReceipt is generated for Expensify card purchases of any amount in the following categories: Airlines, Commuter expenses, Gas, Groceries, Mail, Meals, Car rental, Taxis, and Utilities.
+
+## Why didn’t my rate get updated with the newest rate guidance by the IRS?
+
+Expensify does not update mileage rates to match the rate provided by the IRS. An admin of the workspace will need to update the rate or create a new rate in the workspace. This is because Expensify has customers worldwide, not just in the United States, and most companies want to communicate the change with employees and control the timing.
+
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Overview.md b/docs/articles/expensify-classic/policy-and-domain-settings/Overview.md
deleted file mode 100644
index 3ee1c8656b4b..000000000000
--- a/docs/articles/expensify-classic/policy-and-domain-settings/Overview.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Coming Soon
-description: Coming Soon
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Reports.md b/docs/articles/expensify-classic/policy-and-domain-settings/Reports.md
deleted file mode 100644
index 3ee1c8656b4b..000000000000
--- a/docs/articles/expensify-classic/policy-and-domain-settings/Reports.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Coming Soon
-description: Coming Soon
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Trips.md b/docs/articles/expensify-classic/policy-and-domain-settings/Trips.md
deleted file mode 100644
index 3ee1c8656b4b..000000000000
--- a/docs/articles/expensify-classic/policy-and-domain-settings/Trips.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Coming Soon
-description: Coming Soon
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/reports/Currency.md b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Currency.md
new file mode 100644
index 000000000000..e5c9096fa610
--- /dev/null
+++ b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Currency.md
@@ -0,0 +1,5 @@
+---
+title: Currency
+description: Currency
+---
+## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/reports/Report-Fields-And-Titles.md b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Report-Fields-And-Titles.md
new file mode 100644
index 000000000000..e79e30ce42c9
--- /dev/null
+++ b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Report-Fields-And-Titles.md
@@ -0,0 +1,43 @@
+---
+title: Report Fields & Titles
+description: This article is about managing Report Fields and Report Titles in Expensify
+---
+# Overview
+
+In this article, we'll go over how to use Report Titles and Report Fields.
+
+## How to use Report Titles
+
+Default report titles enable group workspace admins or individual workspace users to establish a standardized title format for reports associated with a specific workspace. Additionally, admins have the choice to enforce these report titles, preventing employees from altering them. This ensures uniformity in report naming conventions during the review process, eliminating the need for employees to manually input titles for each report they generate.
+
+- Group workspace admins can set the Default Report Title from **Settings > Workspaces > Group > *[Workspace Name]* > Reports**.
+- Individual users can set the Default Report Title from **Settings > Workspaces > Individual > *[Workspace Name]* > Reports**.
+
+You can configure the title by using the formulas that we provide to populate the Report Title. Take a look at the help article on Custom Formulas to find all eligible formulas for your Report Titles.
+
+## Deep Dive on Report Titles
+
+Some formulas will automatically update the report title as changes are made to the report. For example, any formula related to dates, total amounts, workspace name, would adjust the title before the report is submitted for approval. Changes will not retroactively update report titles for reports which have been Approved or Reimbursed.
+
+To prevent report title editing by employees, simply enable "Enforce Default Report Title."
+
+## How to use Report Fields
+
+Report fields let you specify header-level details, distinct from tags which pertain to expenses on individual line items. These details can encompass specific project names, business trip information, locations, and more. Customize them according to your workspace's requirements.
+
+To set up Report Fields, follow these steps:
+- Workspace Admins can create report fields for group workspaces from **Settings > Workspaces > Group > *[Workspace Name]* > Reports > Report and Invoice Fields**. For individual workspaces, follow **Settings > Workspaces > Individual > *[Workspace Name]* > Reports > Report and Invoice Fields**.
+- Under "Add New Field," enter the desired field name in the "Field Title" to describe the type of information to be selected.
+- Choose the appropriate input method under "Type":
+ - Text: Provides users with a free-text box to enter the requested information.
+ - Dropdown: Creates a selection of options for users to choose from.
+ - Date: Displays a clickable box that opens a calendar for users to select a date.
+
+## Deep Dive on Report Fields
+
+You cannot create these report fields directly in Expensify if you are connected to an accounting integration (QuickBooks Online, QuickBooks Desktop, Intacct, Xero, or NetSuite). Please refer to the relevant article for instructions on creating fields within that system.
+
+When report fields are configured on a workspace, they become mandatory information for associated reports. Leaving a report field empty or unselected will trigger a report violation, potentially blocking report submission or export.
+
+Report fields are "sticky," which means that any changes made by an employee will persist and be reflected in subsequent reports they create.
+
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/reports/Scheduled-Submit.md b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Scheduled-Submit.md
new file mode 100644
index 000000000000..c05df92bbbff
--- /dev/null
+++ b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Scheduled-Submit.md
@@ -0,0 +1,38 @@
+---
+title: Scheduled Submit
+description: How to use the Scheduled Submit feature
+---
+# Overview
+
+Scheduled Submit reduces the delay between the time an employee creates an expense to when it is submitted to the admin. This gives admins significantly faster visibility into employee spend. Without Scheduled Submit enabled, expenses can be left Unreported giving workspace admins no visibility into employee spend.
+
+The biggest delay in expense management is the time it takes for an employee to actually submit the expense after it is incurred. Scheduled Submit allows you to automatically collect employee expenses on a schedule of your choosing without delaying the process while you wait for employees to submit them.
+
+It works like this: Employee expenses are automatically gathered onto a report. If there is not an existing report, a new one will be created. This report is submitted automatically at the cadence you choose (daily, weekly, monthly, twice month, by trip).
+
+# How to enable Scheduled Submit
+
+**For workspace admins**: To enable Scheduled Submit on your group workspace, follow **Settings > Workspaces > Group > *[Workspace Name]* > Reports > Scheduled Submit**. From there, toggle Scheduled Submit to Enabled. Then, choose your desired frequency from the dropdown menu.
+For individuals or employees: To enable Scheduled Submit on your individual workspace, follow **Settings > Workspaces > Individual > *[Workspace Name]* > Reports > Scheduled Submit**. From there, toggle Scheduled Submit to Enabled. Then, choose your desired frequency from the dropdown menu.
+
+## Scheduled Submit frequency options
+
+**Daily**: Each night, expenses without violations will be submitted. Expenses with violations will remain on an open report until the violations are corrected, after which they will be submitted in the evening (PDT).
+
+**Weekly**: Expenses that are free of violations will be submitted on a weekly basis. However, expenses with violations will be held in a new open report and combined with any new expenses. They will then be submitted at the end of the following weekly cycle, specifically on Sunday evening (PDT).
+
+**Twice a month**: Expenses that are violation-free will be submitted on both the 15th and the last day of each month, in the evening (PDT). Expenses with violations will not be submitted, but moved on to a new open report so the employee can resolve the violations and then will be submitted at the conclusion of the next cycle.
+
+**Monthly**: Expenses that are free from violations will be submitted on a monthly basis. Expenses with violations will be held back and moved to a new Open report so the violations can be resolved, and they will be submitted on the evening (PDT) of the specified date.
+
+**By trip**: Expenses are grouped by trip. This is calculated by grouping all expenses together that occur in a similar time frame. If two full days pass without any new expenses being created, the trip report will be submitted on the evening of the second day. Any expenses generated after this submission will initiate a new trip report. Please note that the "2-day" period refers to a date-based interval, not a 48-hour time frame.
+
+**Manually**: An open report will be created, and expenses will be added to it automatically. However, it's important to note that the report will not be submitted automatically; manual submission of reports will be required.This is a great option for automatically gathering all an employee’s expenses on a report for the employee’s convenience, but they will still need to review and submit the report.
+
+# Deep Dive
+
+## Schedule Submit Override
+If Scheduled Submit is disabled at the group workspace level or configured the frequency as "Manually," the individual workspace settings of a user will take precedence and be applied. This means an employee can still set up Scheduled Submit for themselves even if the admin has not enabled it. We highly recommend Scheduled Submit as it helps put your expenses on auto-pilot!
+
+## Personal Card Transactions
+Personal card transactions are handled differently compared to other expenses. If a user has added a card through Settings > Account > Credit Card Import, they need to make sure it is set as non-reimbursable and transactions must be automatically merged with a SmartScanned receipt. If transactions are set to come in as reimbursable or they aren’t merged with a SmartScanned receipt, Scheduled Submit settings will not apply.
diff --git a/docs/articles/new-expensify/billing-and-plan-types/The-Free-Plan.md b/docs/articles/new-expensify/billing-and-plan-types/The-Free-Plan.md
index 0a8d6b3493e0..e157ede1969d 100644
--- a/docs/articles/new-expensify/billing-and-plan-types/The-Free-Plan.md
+++ b/docs/articles/new-expensify/billing-and-plan-types/The-Free-Plan.md
@@ -1,6 +1,7 @@
---
title: The Free Plan
description: Everything you need to know about Expensify's Free Plan!
+redirect_from: articles/split-bills/workspaces/The-Free-Plan/
---
diff --git a/docs/articles/new-expensify/get-paid-back/Request-Money.md b/docs/articles/new-expensify/get-paid-back/Request-Money.md
index dc6de6656cc9..a2b765915af0 100644
--- a/docs/articles/new-expensify/get-paid-back/Request-Money.md
+++ b/docs/articles/new-expensify/get-paid-back/Request-Money.md
@@ -1,5 +1,6 @@
---
title: Request Money
description: Request Money
+redirect_from: articles/request-money/Request-and-Split-Bills/
---
## Resource Coming Soon!
diff --git a/docs/articles/new-expensify/getting-started/Expensify-Lounge.md b/docs/articles/new-expensify/getting-started/Expensify-Lounge.md
index 01a2d7a9e250..bdccbe927769 100644
--- a/docs/articles/new-expensify/getting-started/Expensify-Lounge.md
+++ b/docs/articles/new-expensify/getting-started/Expensify-Lounge.md
@@ -1,6 +1,7 @@
---
title: Welcome to the Expensify Lounge!
description: How to get the most out of the Expensify Lounge.
+redirect_from: articles/other/Expensify-Lounge/
---
diff --git a/docs/articles/new-expensify/getting-started/chat/Everything-About-Chat.md b/docs/articles/new-expensify/getting-started/chat/Everything-About-Chat.md
index 9f73d1c759c2..77bbe54e8e2c 100644
--- a/docs/articles/new-expensify/getting-started/chat/Everything-About-Chat.md
+++ b/docs/articles/new-expensify/getting-started/chat/Everything-About-Chat.md
@@ -1,6 +1,7 @@
---
title: Everything About Chat
description: Everything you need to know about Expensify's Chat Features!
+redirect_from: articles/other/Everything-About-Chat/
---
diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md
index 31de150d5b5e..996d7896502f 100644
--- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md
+++ b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md
@@ -1,6 +1,7 @@
---
title: Expensify Chat for Admins
description: Best Practices for Admins settings up Expensify Chat
+redirect_from: articles/other/Expensify-Chat-For-Admins/
---
## Overview
diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md
index 3d30237dca5a..20e15aaa6c72 100644
--- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md
+++ b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md
@@ -1,6 +1,7 @@
---
title: Expensify Chat for Conference Attendees
description: Best Practices for Conference Attendees
+redirect_from: articles/other/Expensify-Chat-For-Conference-Attendees/
---
## Overview
diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md
index 5bd52425d92b..3e19cf6fe26a 100644
--- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md
+++ b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md
@@ -1,6 +1,7 @@
---
title: Expensify Chat for Conference Speakers
description: Best Practices for Conference Speakers
+redirect_from: articles/other/Expensify-Chat-For-Conference-Speakers/
---
## Overview
diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md
index 8f806bb03146..a81aef2044a2 100644
--- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md
+++ b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md
@@ -1,6 +1,7 @@
---
title: Expensify Chat Playbook for Conferences
description: Best practices for how to deploy Expensify Chat for your conference
+redirect_from: articles/playbooks/Expensify-Chat-Playbook-for-Conferences/
---
## Overview
To help make setting up Expensify Chat for your event and your attendees super simple, we’ve created a guide for all of the technical setup details.
diff --git a/docs/expensify-classic/hubs/policy-and-domain-settings/reports.html b/docs/expensify-classic/hubs/policy-and-domain-settings/reports.html
new file mode 100644
index 000000000000..86641ee60b7d
--- /dev/null
+++ b/docs/expensify-classic/hubs/policy-and-domain-settings/reports.html
@@ -0,0 +1,5 @@
+---
+layout: default
+---
+
+{% include section.html %}
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index c7d0f2f4f0f5..dac53193fdc6 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -17,7 +17,7 @@ platform :android do
desc "Generate a new local APK for e2e testing"
lane :build_e2e do
ENV["ENVFILE"]="tests/e2e/.env.e2e"
- ENV["ENTRY_FILE"]="#{Dir.pwd}/../src/libs/E2E/reactNativeLaunchingTest.js"
+ ENV["ENTRY_FILE"]="src/libs/E2E/reactNativeLaunchingTest.js"
ENV["E2E_TESTING"]="true"
gradle(
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 73e22053eda1..13506746d091 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 1.3.74
+ 1.3.75CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.3.74.3
+ 1.3.75.10ITSAppUsesNonExemptEncryptionLSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 5e7f02699579..b328c9cf715e 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 1.3.74
+ 1.3.75CFBundleSignature????CFBundleVersion
- 1.3.74.3
+ 1.3.75.10
diff --git a/package-lock.json b/package-lock.json
index 8c63ba6ce9b3..38faa78b58c1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.3.74-3",
+ "version": "1.3.75-10",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.3.74-3",
+ "version": "1.3.75-10",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -81,16 +81,16 @@
"react-native-fast-image": "^8.6.3",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "2.12.0",
- "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#cef3ac29d9501091453136e1219e24c4ec9f9d76",
+ "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#c8c2a873335df19081056a5667f5c109583882e1",
"react-native-haptic-feedback": "^1.13.0",
"react-native-image-pan-zoom": "^2.1.12",
"react-native-image-picker": "^5.1.0",
"react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b",
- "react-native-key-command": "^1.0.1",
+ "react-native-key-command": "^1.0.5",
"react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "1.0.89",
+ "react-native-onyx": "1.0.97",
"react-native-pager-view": "^6.2.0",
"react-native-pdf": "^6.7.1",
"react-native-performance": "^5.1.0",
@@ -41099,8 +41099,8 @@
},
"node_modules/react-native-google-places-autocomplete": {
"version": "2.5.1",
- "resolved": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#cef3ac29d9501091453136e1219e24c4ec9f9d76",
- "integrity": "sha512-2z3ED8jOXasPTzBqvPwpG10LQsBArTRsYszmoz+TfqbgZrSBmP3c8rhaC//lx6Pvfs2r+KYWqJUrLf4mbCrjZw==",
+ "resolved": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#c8c2a873335df19081056a5667f5c109583882e1",
+ "integrity": "sha512-jYQJlI5Pp/UI4k4Xy9fqnE0x4BC+O6c5Fh7I+7SjtaywA5KpZqQcYApx2e9YcH/igJ4Rdp/n4awKPX+vE5vFcg==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4.0.8",
@@ -41151,19 +41151,25 @@
"license": "MIT"
},
"node_modules/react-native-key-command": {
- "version": "1.0.1",
- "license": "MIT",
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/react-native-key-command/-/react-native-key-command-1.0.5.tgz",
+ "integrity": "sha512-SJWf1e8f3yGFrFDNCmJ+aiGmnwokGgtMicfvuyukhQtXkncCQb9pBI4uhBen0Bd30uMmUDgGAA9O56OyIdf5jw==",
"dependencies": {
- "events": "^3.3.0",
+ "eventemitter3": "^5.0.1",
"underscore": "^1.13.4"
},
"peerDependencies": {
"react": "^18.1.0",
"react-dom": "18.1.0",
"react-native": "^0.70.4",
- "react-native-web": "^0.18.1"
+ "react-native-web": "^0.19.7"
}
},
+ "node_modules/react-native-key-command/node_modules/eventemitter3": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
+ },
"node_modules/react-native-linear-gradient": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/react-native-linear-gradient/-/react-native-linear-gradient-2.8.1.tgz",
@@ -41204,9 +41210,9 @@
}
},
"node_modules/react-native-onyx": {
- "version": "1.0.89",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.89.tgz",
- "integrity": "sha512-bSC8YwVbMBJYm6BMtuhuYmZi6zMh13e1t8Kaxp7K5EDLcSoTWsWPkuWX4wBvewlkLfw+HgB1IdgnXpa6+jS+ag==",
+ "version": "1.0.97",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.97.tgz",
+ "integrity": "sha512-6w4pp9Ktm4lQ6jIS+ZASQ5tYwRU1lt751yxfddvmN646XZefj4iDvC7uQaUnAgg1xL52dEV5RZWaI3sQ3e9AGQ==",
"dependencies": {
"ascii-table": "0.0.9",
"fast-equals": "^4.0.3",
@@ -41491,21 +41497,23 @@
}
},
"node_modules/react-native-web": {
- "version": "0.18.12",
- "license": "MIT",
+ "version": "0.19.9",
+ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.9.tgz",
+ "integrity": "sha512-m69arZbS6FV+BNSKE6R/NQwUX+CzxCkYM7AJlSLlS8dz3BDzlaxG8Bzqtzv/r3r1YFowhnZLBXVKIwovKDw49g==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.6",
- "create-react-class": "^15.7.0",
+ "@react-native/normalize-color": "^2.1.0",
"fbjs": "^3.0.4",
"inline-style-prefixer": "^6.0.1",
- "normalize-css-color": "^1.0.2",
+ "memoize-one": "^6.0.0",
+ "nullthrows": "^1.1.1",
"postcss-value-parser": "^4.2.0",
- "styleq": "^0.1.2"
+ "styleq": "^0.1.3"
},
"peerDependencies": {
- "react": "^17.0.2 || ^18.0.0",
- "react-dom": "^17.0.2 || ^18.0.0"
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
}
},
"node_modules/react-native-web-linear-gradient": {
@@ -41526,6 +41534,12 @@
"react-native-web": "*"
}
},
+ "node_modules/react-native-web/node_modules/memoize-one": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+ "peer": true
+ },
"node_modules/react-native-webview": {
"version": "11.23.0",
"license": "MIT",
@@ -45183,8 +45197,9 @@
}
},
"node_modules/styleq": {
- "version": "0.1.2",
- "license": "MIT"
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz",
+ "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA=="
},
"node_modules/sudo-prompt": {
"version": "9.2.1",
@@ -77213,9 +77228,9 @@
}
},
"react-native-google-places-autocomplete": {
- "version": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#cef3ac29d9501091453136e1219e24c4ec9f9d76",
- "integrity": "sha512-2z3ED8jOXasPTzBqvPwpG10LQsBArTRsYszmoz+TfqbgZrSBmP3c8rhaC//lx6Pvfs2r+KYWqJUrLf4mbCrjZw==",
- "from": "react-native-google-places-autocomplete@git+https://github.com/Expensify/react-native-google-places-autocomplete.git#cef3ac29d9501091453136e1219e24c4ec9f9d76",
+ "version": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#c8c2a873335df19081056a5667f5c109583882e1",
+ "integrity": "sha512-jYQJlI5Pp/UI4k4Xy9fqnE0x4BC+O6c5Fh7I+7SjtaywA5KpZqQcYApx2e9YcH/igJ4Rdp/n4awKPX+vE5vFcg==",
+ "from": "react-native-google-places-autocomplete@git+https://github.com/Expensify/react-native-google-places-autocomplete.git#c8c2a873335df19081056a5667f5c109583882e1",
"requires": {
"lodash.debounce": "^4.0.8",
"prop-types": "^15.7.2",
@@ -77245,10 +77260,19 @@
"from": "react-native-image-size@git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b"
},
"react-native-key-command": {
- "version": "1.0.1",
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/react-native-key-command/-/react-native-key-command-1.0.5.tgz",
+ "integrity": "sha512-SJWf1e8f3yGFrFDNCmJ+aiGmnwokGgtMicfvuyukhQtXkncCQb9pBI4uhBen0Bd30uMmUDgGAA9O56OyIdf5jw==",
"requires": {
- "events": "^3.3.0",
+ "eventemitter3": "^5.0.1",
"underscore": "^1.13.4"
+ },
+ "dependencies": {
+ "eventemitter3": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
+ }
}
},
"react-native-linear-gradient": {
@@ -77269,9 +77293,9 @@
}
},
"react-native-onyx": {
- "version": "1.0.89",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.89.tgz",
- "integrity": "sha512-bSC8YwVbMBJYm6BMtuhuYmZi6zMh13e1t8Kaxp7K5EDLcSoTWsWPkuWX4wBvewlkLfw+HgB1IdgnXpa6+jS+ag==",
+ "version": "1.0.97",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.97.tgz",
+ "integrity": "sha512-6w4pp9Ktm4lQ6jIS+ZASQ5tYwRU1lt751yxfddvmN646XZefj4iDvC7uQaUnAgg1xL52dEV5RZWaI3sQ3e9AGQ==",
"requires": {
"ascii-table": "0.0.9",
"fast-equals": "^4.0.3",
@@ -77439,16 +77463,27 @@
"requires": {}
},
"react-native-web": {
- "version": "0.18.12",
+ "version": "0.19.9",
+ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.9.tgz",
+ "integrity": "sha512-m69arZbS6FV+BNSKE6R/NQwUX+CzxCkYM7AJlSLlS8dz3BDzlaxG8Bzqtzv/r3r1YFowhnZLBXVKIwovKDw49g==",
"peer": true,
"requires": {
"@babel/runtime": "^7.18.6",
- "create-react-class": "^15.7.0",
+ "@react-native/normalize-color": "^2.1.0",
"fbjs": "^3.0.4",
"inline-style-prefixer": "^6.0.1",
- "normalize-css-color": "^1.0.2",
+ "memoize-one": "^6.0.0",
+ "nullthrows": "^1.1.1",
"postcss-value-parser": "^4.2.0",
- "styleq": "^0.1.2"
+ "styleq": "^0.1.3"
+ },
+ "dependencies": {
+ "memoize-one": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+ "peer": true
+ }
}
},
"react-native-web-linear-gradient": {
@@ -79905,7 +79940,9 @@
}
},
"styleq": {
- "version": "0.1.2"
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz",
+ "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA=="
},
"sudo-prompt": {
"version": "9.2.1",
diff --git a/package.json b/package.json
index d013caa1c402..d84801e37322 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.3.74-3",
+ "version": "1.3.75-10",
"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.",
@@ -124,16 +124,16 @@
"react-native-fast-image": "^8.6.3",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "2.12.0",
- "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#cef3ac29d9501091453136e1219e24c4ec9f9d76",
+ "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#c8c2a873335df19081056a5667f5c109583882e1",
"react-native-haptic-feedback": "^1.13.0",
"react-native-image-pan-zoom": "^2.1.12",
"react-native-image-picker": "^5.1.0",
"react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b",
- "react-native-key-command": "^1.0.1",
+ "react-native-key-command": "^1.0.5",
"react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "1.0.89",
+ "react-native-onyx": "1.0.97",
"react-native-pager-view": "^6.2.0",
"react-native-pdf": "^6.7.1",
"react-native-performance": "^5.1.0",
diff --git a/scripts/android-repackage-app-bundle-and-sign.sh b/scripts/android-repackage-app-bundle-and-sign.sh
index fe4ee1e4b8fc..1636edc21388 100755
--- a/scripts/android-repackage-app-bundle-and-sign.sh
+++ b/scripts/android-repackage-app-bundle-and-sign.sh
@@ -1,4 +1,5 @@
#!/bin/bash
+source ./scripts/shellUtils.sh
###
# Takes an android app that has been built with the debug keystore,
@@ -41,7 +42,7 @@ if [ ! -f "$NEW_BUNDLE_FILE" ]; then
echo "Bundle file not found: $NEW_BUNDLE_FILE"
exit 1
fi
-OUTPUT_APK=$(realpath "$OUTPUT_APK")
+OUTPUT_APK=$(get_abs_path "$OUTPUT_APK")
# check if "apktool" command is available
if ! command -v apktool &> /dev/null
then
diff --git a/scripts/shellUtils.sh b/scripts/shellUtils.sh
index 876933af9766..4c9e2febc34d 100644
--- a/scripts/shellUtils.sh
+++ b/scripts/shellUtils.sh
@@ -41,3 +41,46 @@ function join_by_string {
shift
printf "%s" "$first" "${@/#/$separator}"
}
+
+# Usage: get_abs_path
+# Will make a path absolute, resolving any relative paths
+# example: get_abs_path "./foo/bar"
+get_abs_path() {
+ local the_path=$1
+ local -a path_elements
+ IFS='/' read -ra path_elements <<< "$the_path"
+
+ # If the path is already absolute, start with an empty string.
+ # We'll prepend the / later when reconstructing the path.
+ if [[ "$the_path" = /* ]]; then
+ abs_path=""
+ else
+ abs_path="$(pwd)"
+ fi
+
+ # Handle each path element
+ for element in "${path_elements[@]}"; do
+ if [ "$element" = "." ] || [ -z "$element" ]; then
+ continue
+ elif [ "$element" = ".." ]; then
+ # Remove the last element from abs_path
+ abs_path=$(dirname "$abs_path")
+ else
+ # Append element to the absolute path
+ abs_path="${abs_path}/${element}"
+ fi
+ done
+
+ # Remove any trailing '/'
+ while [[ $abs_path == */ ]]; do
+ abs_path=${abs_path%/}
+ done
+
+ # Special case for root
+ [ -z "$abs_path" ] && abs_path="/"
+
+ # Special case to remove any starting '//' when the input path was absolute
+ abs_path=${abs_path/#\/\//\/}
+
+ echo "$abs_path"
+}
\ No newline at end of file
diff --git a/src/App.js b/src/App.js
index 284c6115d7b8..1d2e07345c24 100644
--- a/src/App.js
+++ b/src/App.js
@@ -9,7 +9,7 @@ import {PickerStateProvider} from 'react-native-picker-select';
import CustomStatusBar from './components/CustomStatusBar';
import ErrorBoundary from './components/ErrorBoundary';
import Expensify from './Expensify';
-import {LocaleContextProvider} from './components/withLocalize';
+import {LocaleContextProvider} from './components/LocaleContextProvider';
import OnyxProvider from './components/OnyxProvider';
import HTMLEngineProvider from './components/HTMLEngineProvider';
import PopoverContextProvider from './components/PopoverProvider';
diff --git a/src/CONST.ts b/src/CONST.ts
index e2ec7f96a758..4627d12e6676 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -992,6 +992,11 @@ const CONST = {
STATEMENT: 'STATEMENT_NAVIGATE',
CONCIERGE: 'CONCIERGE_NAVIGATE',
},
+ MTL_WALLET_PROGRAM_ID: '760',
+ PROGRAM_ISSUERS: {
+ EXPENSIFY_PAYMENTS: 'Expensify Payments LLC',
+ BANCORP_BANK: 'The Bancorp Bank',
+ },
},
PLAID: {
@@ -1263,6 +1268,8 @@ const CONST = {
DATE_TIME_FORMAT: /^\d{2}-\d{2} \d{2}:\d{2} [AP]M$/,
ATTACHMENT_ROUTE: /\/r\/(\d*)\/attachment/,
ILLEGAL_FILENAME_CHARACTERS: /\/|<|>|\*|"|:|\?|\\|\|/g,
+
+ ENCODE_PERCENT_CHARACTER: /%(25)+/g,
},
PRONOUNS: {
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index d2b3031220f1..0a17d3a1d2f7 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -1,5 +1,4 @@
import {ValueOf} from 'type-fest';
-import {OnyxUpdate} from 'react-native-onyx';
import DeepValueOf from './types/utils/DeepValueOf';
import * as OnyxTypes from './types/onyx';
import CONST from './CONST';
@@ -30,9 +29,6 @@ const ONYXKEYS = {
/** Note: These are Persisted Requests - not all requests in the main queue as the key name might lead one to believe */
PERSISTED_REQUESTS: 'networkRequestQueue',
- /** Onyx updates from a response, or success or failure data from a request. */
- QUEUED_ONYX_UPDATES: 'queuedOnyxUpdates',
-
/** Stores current date */
CURRENT_DATE: 'currentDate',
@@ -242,6 +238,9 @@ const ONYXKEYS = {
POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_',
WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_',
REPORT: 'report_',
+ // REPORT_METADATA is a perf optimization used to hold loading states (isLoadingReportActions, isLoadingMoreReportActions).
+ // A lot of components are connected to the Report entity and do not care about the actions. Setting the loading state
+ // directly on the report caused a lot of unnecessary re-renders
REPORT_METADATA: 'reportMetadata_',
REPORT_ACTIONS: 'reportActions_',
REPORT_ACTIONS_DRAFTS: 'reportActionsDrafts_',
@@ -307,7 +306,6 @@ type OnyxValues = {
[ONYXKEYS.DEVICE_ID]: string;
[ONYXKEYS.IS_SIDEBAR_LOADED]: boolean;
[ONYXKEYS.PERSISTED_REQUESTS]: OnyxTypes.Request[];
- [ONYXKEYS.QUEUED_ONYX_UPDATES]: OnyxUpdate[];
[ONYXKEYS.CURRENT_DATE]: string;
[ONYXKEYS.CREDENTIALS]: OnyxTypes.Credentials;
[ONYXKEYS.IOU]: OnyxTypes.IOU;
diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js
index 1b4200572664..14d13a63eec3 100644
--- a/src/components/AddressSearch/index.js
+++ b/src/components/AddressSearch/index.js
@@ -291,6 +291,12 @@ function AddressSearch(props) {
{props.translate('common.noResultsFound')}
)
}
+ renderHeaderComponent={() =>
+ !props.value &&
+ props.predefinedPlaces && (
+ {props.translate('common.recentDestinations')}
+ )
+ }
onPress={(data, details) => {
saveLocationDetails(data, details);
diff --git a/src/components/Button/index.js b/src/components/Button/index.js
index 4ca933a45d6f..dc12a4ded5c2 100644
--- a/src/components/Button/index.js
+++ b/src/components/Button/index.js
@@ -18,6 +18,9 @@ import PressableWithFeedback from '../Pressable/PressableWithFeedback';
import refPropTypes from '../refPropTypes';
const propTypes = {
+ /** Should the press event bubble across multiple instances when Enter key triggers it. */
+ allowBubble: PropTypes.bool,
+
/** The text for the button label */
text: PropTypes.string,
@@ -123,6 +126,7 @@ const propTypes = {
};
const defaultProps = {
+ allowBubble: false,
text: '',
shouldShowRightIcon: false,
icon: null,
@@ -183,7 +187,7 @@ class Button extends Component {
shortcutConfig.descriptionKey,
shortcutConfig.modifiers,
true,
- false,
+ this.props.allowBubble,
this.props.enterKeyEventListenerPriority,
false,
);
diff --git a/src/components/ButtonWithDropdownMenu.js b/src/components/ButtonWithDropdownMenu.js
index 54d6c0deac5a..a0a6e276bc28 100644
--- a/src/components/ButtonWithDropdownMenu.js
+++ b/src/components/ButtonWithDropdownMenu.js
@@ -19,6 +19,9 @@ const propTypes = {
/** Callback to execute when the main button is pressed */
onPress: PropTypes.func.isRequired,
+ /** Call the onPress function on main button when Enter key is pressed */
+ pressOnEnter: PropTypes.bool,
+
/** Whether we should show a loading state for the main button */
isLoading: PropTypes.bool,
@@ -57,6 +60,7 @@ const propTypes = {
const defaultProps = {
isLoading: false,
isDisabled: false,
+ pressOnEnter: false,
menuHeaderText: '',
style: [],
buttonSize: CONST.DROPDOWN_BUTTON_SIZE.MEDIUM,
@@ -101,6 +105,7 @@ function ButtonWithDropdownMenu(props) {
onSubmit(waypoints)}
isDisabled={_.size(validatedWaypoints) < 2 || (!isOffline && (hasRouteError || isLoadingRoute || isLoading))}
diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js
index 90f5c22e5b3c..5261d1258ad0 100644
--- a/src/components/Form/FormProvider.js
+++ b/src/components/Form/FormProvider.js
@@ -108,6 +108,7 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c
(values) => {
const validateErrors = validate(values);
setErrors(validateErrors);
+ return validateErrors;
},
[validate],
);
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js
index efc9e432cba8..782ad82f643c 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js
@@ -1,70 +1,69 @@
-import React from 'react';
+import React, {useCallback, useEffect, useRef} from 'react';
import _ from 'underscore';
-import withLocalize from '../../../withLocalize';
+
+import ControlSelection from '../../../../libs/ControlSelection';
+import * as DeviceCapabilities from '../../../../libs/DeviceCapabilities';
import htmlRendererPropTypes from '../htmlRendererPropTypes';
import BasePreRenderer from './BasePreRenderer';
-import * as DeviceCapabilities from '../../../../libs/DeviceCapabilities';
-import ControlSelection from '../../../../libs/ControlSelection';
-
-class PreRenderer extends React.Component {
- constructor(props) {
- super(props);
- this.scrollNode = this.scrollNode.bind(this);
- this.debouncedIsScrollingVertically = _.debounce(this.isScrollingVertically.bind(this), 100, true);
- }
+const isScrollingVertically = (event) =>
+ // Mark as vertical scrolling only when absolute value of deltaY is more than the double of absolute
+ // value of deltaX, so user can use trackpad scroll on the code block horizontally at a wide angle.
+ Math.abs(event.deltaY) > Math.abs(event.deltaX) * 2;
- componentDidMount() {
- if (!this.ref) {
- return;
- }
- this.ref.getScrollableNode().addEventListener('wheel', this.scrollNode);
- }
+const debouncedIsScrollingVertically = _.debounce(isScrollingVertically, 100, true);
- componentWillUnmount() {
- this.ref.getScrollableNode().removeEventListener('wheel', this.scrollNode);
- }
+function PreRenderer(props) {
+ const scrollViewRef = useRef();
/**
- * Check if user is scrolling vertically based on deltaX and deltaY. We debounce this
- * method in the constructor to make sure it's called only for the first event.
+ * Checks if user is scrolling vertically based on deltaX and deltaY. We debounce this
+ * method in order to make sure it's called only for the first event.
* @param {WheelEvent} event Wheel event
* @returns {Boolean} true if user is scrolling vertically
*/
- isScrollingVertically(event) {
- // Mark as vertical scrolling only when absolute value of deltaY is more than the double of absolute
- // value of deltaX, so user can use trackpad scroll on the code block horizontally at a wide angle.
- return Math.abs(event.deltaY) > Math.abs(event.deltaX) * 2;
- }
/**
* Manually scrolls the code block if code block horizontal scrollable, then prevents the event from being passed up to the parent.
* @param {Object} event native event
*/
- scrollNode(event) {
- const node = this.ref.getScrollableNode();
+ const scrollNode = useCallback((event) => {
+ const node = scrollViewRef.current.getScrollableNode();
const horizontalOverflow = node.scrollWidth > node.offsetWidth;
- const isScrollingVertically = this.debouncedIsScrollingVertically(event);
- if (event.currentTarget === node && horizontalOverflow && !isScrollingVertically) {
+ if (event.currentTarget === node && horizontalOverflow && !debouncedIsScrollingVertically(event)) {
node.scrollLeft += event.deltaX;
event.preventDefault();
event.stopPropagation();
}
- }
+ }, []);
+
+ useEffect(() => {
+ const eventListenerRefValue = scrollViewRef.current;
+ if (!eventListenerRefValue) {
+ return;
+ }
+ eventListenerRefValue.getScrollableNode().addEventListener('wheel', scrollNode);
+
+ return () => {
+ if (!eventListenerRefValue.getScrollableNode()) {
+ return;
+ }
+ eventListenerRefValue.getScrollableNode().removeEventListener('wheel', scrollNode);
+ };
+ }, [scrollNode]);
- render() {
- return (
- (this.ref = el)}
- onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()}
- onPressOut={() => ControlSelection.unblock()}
- />
- );
- }
+ return (
+ DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()}
+ onPressOut={ControlSelection.unblock}
+ />
+ );
}
PreRenderer.propTypes = htmlRendererPropTypes;
+PreRenderer.displayName = 'PreRenderer';
-export default withLocalize(PreRenderer);
+export default PreRenderer;
diff --git a/src/components/Hoverable/index.js b/src/components/Hoverable/index.js
index 38ea64952a2c..f39c44b278ae 100644
--- a/src/components/Hoverable/index.js
+++ b/src/components/Hoverable/index.js
@@ -1,103 +1,73 @@
import _ from 'underscore';
-import React, {Component} from 'react';
+import React, {useEffect, useCallback, useState, useRef, useMemo, useImperativeHandle} from 'react';
import {DeviceEventEmitter} from 'react-native';
import {propTypes, defaultProps} from './hoverablePropTypes';
import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
import CONST from '../../CONST';
+function mapChildren(children, callbackParam) {
+ if (_.isArray(children) && children.length === 1) {
+ return children[0];
+ }
+
+ if (_.isFunction(children)) {
+ return children(callbackParam);
+ }
+
+ return children;
+}
+
/**
* It is necessary to create a Hoverable component instead of relying solely on Pressable support for hover state,
* because nesting Pressables causes issues where the hovered state of the child cannot be easily propagated to the
* parent. https://github.com/necolas/react-native-web/issues/1875
*/
-class Hoverable extends Component {
- constructor(props) {
- super(props);
- this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
- this.checkHover = this.checkHover.bind(this);
+function InnerHoverable({disabled, onHoverIn, onHoverOut, children, shouldHandleScroll}, outerRef) {
+ const [isHovered, setIsHovered] = useState(false);
- this.state = {
- isHovered: false,
- };
+ const isScrolling = useRef(false);
+ const isHoveredRef = useRef(false);
+ const ref = useRef(null);
- this.isHoveredRef = false;
- this.isScrollingRef = false;
- this.wrapperView = null;
- }
+ const updateIsHoveredOnScrolling = useCallback(
+ (hovered) => {
+ if (disabled) {
+ return;
+ }
- componentDidMount() {
- document.addEventListener('visibilitychange', this.handleVisibilityChange);
- document.addEventListener('mouseover', this.checkHover);
-
- /**
- * Only add the scrolling listener if the shouldHandleScroll prop is true
- * and the scrollingListener is not already set.
- */
- if (!this.scrollingListener && this.props.shouldHandleScroll) {
- this.scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => {
- /**
- * If user has stopped scrolling and the isHoveredRef is true, then we should update the hover state.
- */
- if (!scrolling && this.isHoveredRef) {
- this.setState({isHovered: this.isHoveredRef}, this.props.onHoverIn);
- } else if (scrolling && this.isHoveredRef) {
- /**
- * If the user has started scrolling and the isHoveredRef is true, then we should set the hover state to false.
- * This is to hide the existing hover and reaction bar.
- */
- this.setState({isHovered: false}, this.props.onHoverOut);
- }
- this.isScrollingRef = scrolling;
- });
- }
- }
+ isHoveredRef.current = hovered;
- componentDidUpdate(prevProps) {
- if (prevProps.disabled === this.props.disabled) {
- return;
- }
+ if (shouldHandleScroll && isScrolling.current) {
+ return;
+ }
+ setIsHovered(hovered);
+ },
+ [disabled, shouldHandleScroll],
+ );
- if (this.props.disabled && this.state.isHovered) {
- this.setState({isHovered: false});
- }
- }
+ useEffect(() => {
+ const unsetHoveredWhenDocumentIsHidden = () => document.visibilityState === 'hidden' && setIsHovered(false);
- componentWillUnmount() {
- document.removeEventListener('visibilitychange', this.handleVisibilityChange);
- document.removeEventListener('mouseover', this.checkHover);
- if (this.scrollingListener) {
- this.scrollingListener.remove();
- }
- }
-
- /**
- * Sets the hover state of this component to true and execute the onHoverIn callback.
- *
- * @param {Boolean} isHovered - Whether or not this component is hovered.
- */
- setIsHovered(isHovered) {
- if (this.props.disabled) {
- return;
- }
+ document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
- /**
- * Capture whther or not the user is hovering over the component.
- * We will use this to determine if we should update the hover state when the user has stopped scrolling.
- */
- this.isHoveredRef = isHovered;
+ return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
+ }, []);
- /**
- * If the isScrollingRef is true, then the user is scrolling and we should not update the hover state.
- */
- if (this.isScrollingRef && this.props.shouldHandleScroll && !this.state.isHovered) {
+ useEffect(() => {
+ if (!shouldHandleScroll) {
return;
}
- if (isHovered !== this.state.isHovered) {
- this.setState({isHovered}, isHovered ? this.props.onHoverIn : this.props.onHoverOut);
- }
- }
+ const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => {
+ isScrolling.current = scrolling;
+ if (!scrolling) {
+ setIsHovered(isHoveredRef.current);
+ }
+ });
+
+ return () => scrollingListener.remove();
+ }, [shouldHandleScroll]);
/**
* Checks the hover state of a component and updates it based on the event target.
@@ -105,85 +75,108 @@ class Hoverable extends Component {
* such as when an element is removed before the mouseleave event is triggered.
* @param {Event} e - The hover event object.
*/
- checkHover(e) {
- if (!this.wrapperView || !this.state.isHovered) {
+ const unsetHoveredIfOutside = useCallback(
+ (e) => {
+ if (!ref.current || !isHovered) {
+ return;
+ }
+
+ if (ref.current.contains(e.target)) {
+ return;
+ }
+
+ setIsHovered(false);
+ },
+ [isHovered],
+ );
+
+ useEffect(() => {
+ if (!DeviceCapabilities.hasHoverSupport()) {
return;
}
- if (this.wrapperView.contains(e.target)) {
- return;
- }
+ document.addEventListener('mouseover', unsetHoveredIfOutside);
- this.setIsHovered(false);
- }
+ return () => document.removeEventListener('mouseover', unsetHoveredIfOutside);
+ }, [unsetHoveredIfOutside]);
- handleVisibilityChange() {
- if (document.visibilityState !== 'hidden') {
+ useEffect(() => {
+ if (!disabled || !isHovered) {
return;
}
+ setIsHovered(false);
+ }, [disabled, isHovered]);
- this.setIsHovered(false);
- }
-
- render() {
- let child = this.props.children;
- if (_.isArray(this.props.children) && this.props.children.length === 1) {
- child = this.props.children[0];
+ useEffect(() => {
+ if (disabled) {
+ return;
}
-
- if (_.isFunction(child)) {
- child = child(this.state.isHovered);
+ if (onHoverIn && isHovered) {
+ return onHoverIn();
}
-
- if (!DeviceCapabilities.hasHoverSupport()) {
- return child;
+ if (onHoverOut && !isHovered) {
+ return onHoverOut();
}
-
- return React.cloneElement(React.Children.only(child), {
- ref: (el) => {
- this.wrapperView = el;
-
- // Call the original ref, if any
- const {ref} = child;
- if (_.isFunction(ref)) {
- ref(el);
- return;
- }
-
- if (_.isObject(ref)) {
- ref.current = el;
- }
- },
- onMouseEnter: (el) => {
- this.setIsHovered(true);
-
- if (_.isFunction(child.props.onMouseEnter)) {
- child.props.onMouseEnter(el);
- }
- },
- onMouseLeave: (el) => {
- this.setIsHovered(false);
-
- if (_.isFunction(child.props.onMouseLeave)) {
- child.props.onMouseLeave(el);
- }
- },
- onBlur: (el) => {
- // Check if the blur event occurred due to clicking outside the element
- // and the wrapperView contains the element that caused the blur and reset isHovered
- if (!this.wrapperView.contains(el.target) && !this.wrapperView.contains(el.relatedTarget)) {
- this.setIsHovered(false);
- }
-
- if (_.isFunction(child.props.onBlur)) {
- child.props.onBlur(el);
- }
- },
- });
+ }, [disabled, isHovered, onHoverIn, onHoverOut]);
+
+ // Expose inner ref to parent through outerRef. This enable us to use ref both in parent and child.
+ useImperativeHandle(outerRef, () => ref.current, []);
+
+ const child = useMemo(() => React.Children.only(mapChildren(children, isHovered)), [children, isHovered]);
+
+ const onMouseEnter = useCallback(
+ (el) => {
+ updateIsHoveredOnScrolling(true);
+
+ if (_.isFunction(child.props.onMouseEnter)) {
+ child.props.onMouseEnter(el);
+ }
+ },
+ [child.props, updateIsHoveredOnScrolling],
+ );
+
+ const onMouseLeave = useCallback(
+ (el) => {
+ updateIsHoveredOnScrolling(false);
+
+ if (_.isFunction(child.props.onMouseLeave)) {
+ child.props.onMouseLeave(el);
+ }
+ },
+ [child.props, updateIsHoveredOnScrolling],
+ );
+
+ const onBlur = useCallback(
+ (el) => {
+ // Check if the blur event occurred due to clicking outside the element
+ // and the wrapperView contains the element that caused the blur and reset isHovered
+ if (!ref.current.contains(el.target) && !ref.current.contains(el.relatedTarget)) {
+ setIsHovered(false);
+ }
+
+ if (_.isFunction(child.props.onBlur)) {
+ child.props.onBlur(el);
+ }
+ },
+ [child.props],
+ );
+
+ if (!DeviceCapabilities.hasHoverSupport()) {
+ return child;
}
+
+ return React.cloneElement(child, {
+ ref,
+ onMouseEnter,
+ onMouseLeave,
+ onBlur,
+ });
}
+const Hoverable = React.forwardRef(InnerHoverable);
+
Hoverable.propTypes = propTypes;
Hoverable.defaultProps = defaultProps;
+Hoverable.displayName = 'Hoverable';
export default Hoverable;
diff --git a/src/components/Icon/Expensicons.js b/src/components/Icon/Expensicons.js
index a0c8b72d755a..810bbc86b5dc 100644
--- a/src/components/Icon/Expensicons.js
+++ b/src/components/Icon/Expensicons.js
@@ -19,6 +19,7 @@ import Camera from '../../../assets/images/camera.svg';
import Car from '../../../assets/images/car.svg';
import Cash from '../../../assets/images/cash.svg';
import ChatBubble from '../../../assets/images/chatbubble.svg';
+import ChatBubbles from '../../../assets/images/chatbubbles.svg';
import Checkmark from '../../../assets/images/checkmark.svg';
import Chair from '../../../assets/images/chair.svg';
import Close from '../../../assets/images/close.svg';
@@ -147,6 +148,7 @@ export {
Car,
Cash,
ChatBubble,
+ ChatBubbles,
Checkmark,
Chair,
Close,
diff --git a/src/components/LocaleContextProvider.js b/src/components/LocaleContextProvider.js
new file mode 100644
index 000000000000..b8838f253e74
--- /dev/null
+++ b/src/components/LocaleContextProvider.js
@@ -0,0 +1,135 @@
+import React, {createContext, useMemo} from 'react';
+import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
+import lodashGet from 'lodash/get';
+
+import ONYXKEYS from '../ONYXKEYS';
+import * as Localize from '../libs/Localize';
+import DateUtils from '../libs/DateUtils';
+import * as NumberFormatUtils from '../libs/NumberFormatUtils';
+import * as LocaleDigitUtils from '../libs/LocaleDigitUtils';
+import CONST from '../CONST';
+import compose from '../libs/compose';
+import withCurrentUserPersonalDetails from './withCurrentUserPersonalDetails';
+import * as LocalePhoneNumber from '../libs/LocalePhoneNumber';
+
+const LocaleContext = createContext(null);
+
+const localeProviderPropTypes = {
+ /** The user's preferred locale e.g. 'en', 'es-ES' */
+ preferredLocale: PropTypes.string,
+
+ /** Actual content wrapped by this component */
+ children: PropTypes.node.isRequired,
+
+ /** The current user's personalDetails */
+ currentUserPersonalDetails: PropTypes.shape({
+ /** Timezone of the current user */
+ timezone: PropTypes.shape({
+ /** Value of the selected timezone */
+ selected: PropTypes.string,
+ }),
+ }),
+};
+
+const localeProviderDefaultProps = {
+ preferredLocale: CONST.LOCALES.DEFAULT,
+ currentUserPersonalDetails: {},
+};
+
+function LocaleContextProvider({children, currentUserPersonalDetails, preferredLocale}) {
+ const selectedTimezone = useMemo(() => lodashGet(currentUserPersonalDetails, 'timezone.selected'), [currentUserPersonalDetails]);
+
+ /**
+ * @param {String} phrase
+ * @param {Object} [variables]
+ * @returns {String}
+ */
+ const translate = useMemo(() => (phrase, variables) => Localize.translate(preferredLocale, phrase, variables), [preferredLocale]);
+
+ /**
+ * @param {Number} number
+ * @param {Intl.NumberFormatOptions} options
+ * @returns {String}
+ */
+ const numberFormat = useMemo(() => (number, options) => NumberFormatUtils.format(preferredLocale, number, options), [preferredLocale]);
+
+ /**
+ * @param {String} datetime
+ * @returns {String}
+ */
+ const datetimeToRelative = useMemo(() => (datetime) => DateUtils.datetimeToRelative(preferredLocale, datetime), [preferredLocale]);
+
+ /**
+ * @param {String} datetime - ISO-formatted datetime string
+ * @param {Boolean} [includeTimezone]
+ * @param {Boolean} isLowercase
+ * @returns {String}
+ */
+ const datetimeToCalendarTime = useMemo(
+ () =>
+ (datetime, includeTimezone, isLowercase = false) =>
+ DateUtils.datetimeToCalendarTime(preferredLocale, datetime, includeTimezone, selectedTimezone, isLowercase),
+ [preferredLocale, selectedTimezone],
+ );
+
+ /**
+ * Updates date-fns internal locale to the user preferredLocale
+ */
+ const updateLocale = useMemo(() => () => DateUtils.setLocale(preferredLocale), [preferredLocale]);
+
+ /**
+ * @param {String} phoneNumber
+ * @returns {String}
+ */
+ const formatPhoneNumber = LocalePhoneNumber.formatPhoneNumber;
+
+ /**
+ * @param {String} digit
+ * @returns {String}
+ */
+ const toLocaleDigit = useMemo(() => (digit) => LocaleDigitUtils.toLocaleDigit(preferredLocale, digit), [preferredLocale]);
+
+ /**
+ * @param {String} localeDigit
+ * @returns {String}
+ */
+ const fromLocaleDigit = useMemo(() => (localeDigit) => LocaleDigitUtils.fromLocaleDigit(preferredLocale, localeDigit), [preferredLocale]);
+
+ /**
+ * The context this component exposes to child components
+ * @returns {object} translation util functions and locale
+ */
+ const contextValue = useMemo(
+ () => ({
+ translate,
+ numberFormat,
+ datetimeToRelative,
+ datetimeToCalendarTime,
+ updateLocale,
+ formatPhoneNumber,
+ toLocaleDigit,
+ fromLocaleDigit,
+ preferredLocale,
+ }),
+ [translate, numberFormat, datetimeToRelative, datetimeToCalendarTime, updateLocale, formatPhoneNumber, toLocaleDigit, fromLocaleDigit, preferredLocale],
+ );
+
+ return {children};
+}
+
+LocaleContextProvider.propTypes = localeProviderPropTypes;
+LocaleContextProvider.defaultProps = localeProviderDefaultProps;
+
+const Provider = compose(
+ withCurrentUserPersonalDetails,
+ withOnyx({
+ preferredLocale: {
+ key: ONYXKEYS.NVP_PREFERRED_LOCALE,
+ },
+ }),
+)(LocaleContextProvider);
+
+Provider.displayName = 'withOnyx(LocaleContextProvider)';
+
+export {Provider as LocaleContextProvider, LocaleContext};
diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js
index 1db1acddc5d7..454aacc8a03b 100644
--- a/src/components/MagicCodeInput.js
+++ b/src/components/MagicCodeInput.js
@@ -2,7 +2,6 @@ import React, {useEffect, useImperativeHandle, useRef, useState, forwardRef} fro
import {StyleSheet, View} from 'react-native';
import PropTypes from 'prop-types';
import _ from 'underscore';
-import {TapGestureHandler} from 'react-native-gesture-handler';
import styles from '../styles/styles';
import * as StyleUtils from '../styles/StyleUtils';
import * as ValidationUtils from '../libs/ValidationUtils';
@@ -13,9 +12,6 @@ import FormHelpMessage from './FormHelpMessage';
import {withNetwork} from './OnyxProvider';
import networkPropTypes from './networkPropTypes';
import useNetwork from '../hooks/useNetwork';
-import * as Browser from '../libs/Browser';
-
-const TEXT_INPUT_EMPTY_STATE = '';
const propTypes = {
/** Information about the network */
@@ -95,40 +91,22 @@ const composeToString = (value) => _.map(value, (v) => (v === undefined || v ===
const getInputPlaceholderSlots = (length) => Array.from(Array(length).keys());
function MagicCodeInput(props) {
- const inputRefs = useRef();
- const [input, setInput] = useState(TEXT_INPUT_EMPTY_STATE);
+ const inputRefs = useRef([]);
+ const [input, setInput] = useState('');
const [focusedIndex, setFocusedIndex] = useState(0);
const [editIndex, setEditIndex] = useState(0);
- const shouldFocusLast = useRef(false);
- const inputWidth = useRef(0);
- const lastFocusedIndex = useRef(0);
const blurMagicCodeInput = () => {
- inputRefs.current.blur();
+ inputRefs.current[editIndex].blur();
setFocusedIndex(undefined);
};
- const focusMagicCodeInput = () => {
- setFocusedIndex(0);
- lastFocusedIndex.current = 0;
- setEditIndex(0);
- inputRefs.current.focus();
- };
-
useImperativeHandle(props.innerRef, () => ({
focus() {
- focusMagicCodeInput();
- },
- resetFocus() {
- setInput(TEXT_INPUT_EMPTY_STATE);
- focusMagicCodeInput();
+ inputRefs.current[0].focus();
},
clear() {
- setInput(TEXT_INPUT_EMPTY_STATE);
- setFocusedIndex(0);
- lastFocusedIndex.current = 0;
- setEditIndex(0);
- inputRefs.current.focus();
+ inputRefs.current[0].focus();
props.onChangeText('');
},
blur() {
@@ -159,37 +137,17 @@ function MagicCodeInput(props) {
}, [props.value, props.shouldSubmitOnComplete]);
/**
- * Focuses on the input when it is pressed.
+ * Callback for the onFocus event, updates the indexes
+ * of the currently focused input.
*
* @param {Object} event
* @param {Number} index
*/
- const onFocus = (event) => {
- if (shouldFocusLast.current) {
- setInput(TEXT_INPUT_EMPTY_STATE);
- setFocusedIndex(lastFocusedIndex.current);
- setEditIndex(lastFocusedIndex.current);
- }
+ const onFocus = (event, index) => {
event.preventDefault();
- };
-
- /**
- * Callback for the onPress event, updates the indexes
- * of the currently focused input.
- *
- * @param {Number} index
- */
- const onPress = (index) => {
- shouldFocusLast.current = false;
- // TapGestureHandler works differently on mobile web and native app
- // On web gesture handler doesn't block interactions with textInput below so there is no need to run `focus()` manually
- if (!Browser.isMobileChrome() && !Browser.isMobileSafari()) {
- inputRefs.current.focus();
- }
- setInput(TEXT_INPUT_EMPTY_STATE);
+ setInput('');
setFocusedIndex(index);
setEditIndex(index);
- lastFocusedIndex.current = index;
};
/**
@@ -217,9 +175,7 @@ function MagicCodeInput(props) {
let numbers = decomposeString(props.value, props.maxLength);
numbers = [...numbers.slice(0, editIndex), ...numbersArr, ...numbers.slice(numbersArr.length + editIndex, props.maxLength)];
- setFocusedIndex(updatedFocusedIndex);
- setEditIndex(updatedFocusedIndex);
- setInput(TEXT_INPUT_EMPTY_STATE);
+ inputRefs.current[updatedFocusedIndex].focus();
const finalInput = composeToString(numbers);
props.onChangeText(finalInput);
@@ -240,7 +196,7 @@ function MagicCodeInput(props) {
// If the currently focused index already has a value, it will delete
// that value but maintain the focus on the same input.
if (numbers[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) {
- setInput(TEXT_INPUT_EMPTY_STATE);
+ setInput('');
numbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, props.maxLength)];
setEditIndex(focusedIndex);
props.onChangeText(composeToString(numbers));
@@ -259,37 +215,24 @@ function MagicCodeInput(props) {
}
const newFocusedIndex = Math.max(0, focusedIndex - 1);
-
- // Saves the input string so that it can compare to the change text
- // event that will be triggered, this is a workaround for mobile that
- // triggers the change text on the event after the key press.
- setInput(TEXT_INPUT_EMPTY_STATE);
- setFocusedIndex(newFocusedIndex);
- setEditIndex(newFocusedIndex);
props.onChangeText(composeToString(numbers));
if (!_.isUndefined(newFocusedIndex)) {
- inputRefs.current.focus();
+ inputRefs.current[newFocusedIndex].focus();
}
}
if (keyValue === 'ArrowLeft' && !_.isUndefined(focusedIndex)) {
const newFocusedIndex = Math.max(0, focusedIndex - 1);
- setInput(TEXT_INPUT_EMPTY_STATE);
- setFocusedIndex(newFocusedIndex);
- setEditIndex(newFocusedIndex);
- inputRefs.current.focus();
+ inputRefs.current[newFocusedIndex].focus();
} else if (keyValue === 'ArrowRight' && !_.isUndefined(focusedIndex)) {
const newFocusedIndex = Math.min(focusedIndex + 1, props.maxLength - 1);
- setInput(TEXT_INPUT_EMPTY_STATE);
- setFocusedIndex(newFocusedIndex);
- setEditIndex(newFocusedIndex);
- inputRefs.current.focus();
+ inputRefs.current[newFocusedIndex].focus();
} else if (keyValue === 'Enter') {
// We should prevent users from submitting when it's offline.
if (props.network.isOffline) {
return;
}
- setInput(TEXT_INPUT_EMPTY_STATE);
+ setInput('');
props.onFulfill(props.value);
}
};
@@ -297,48 +240,6 @@ function MagicCodeInput(props) {
return (
<>
- {
- onPress(Math.floor(e.nativeEvent.x / (inputWidth.current / props.maxLength)));
- }}
- >
- {/* Android does not handle touch on invisible Views so I created a wrapper around invisible TextInput just to handle taps */}
-
- {
- inputWidth.current = e.nativeEvent.layout.width;
- }}
- ref={(ref) => (inputRefs.current = ref)}
- autoFocus={props.autoFocus}
- inputMode="numeric"
- textContentType="oneTimeCode"
- name={props.name}
- maxLength={props.maxLength}
- value={input}
- hideFocusedState
- autoComplete={props.autoComplete}
- keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
- onChangeText={(value) => {
- onChangeText(value);
- }}
- onKeyPress={onKeyPress}
- onFocus={onFocus}
- onBlur={() => {
- shouldFocusLast.current = true;
- lastFocusedIndex.current = focusedIndex;
- setFocusedIndex(undefined);
- }}
- selectionColor="transparent"
- inputStyle={[styles.inputTransparent]}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
- style={[styles.inputTransparent]}
- textInputContainerStyles={[styles.borderNone]}
- />
-
-
{_.map(getInputPlaceholderSlots(props.maxLength), (index) => (
{decomposeString(props.value, props.maxLength)[index] || ''}
+ {/* Hide the input above the text. Cannot set opacity to 0 as it would break pasting on iOS Safari. */}
+
+ {
+ inputRefs.current[index] = ref;
+ // Setting attribute type to "search" to prevent Password Manager from appearing in Mobile Chrome
+ if (ref && ref.setAttribute) {
+ ref.setAttribute('type', 'search');
+ }
+ }}
+ autoFocus={index === 0 && props.autoFocus}
+ inputMode="numeric"
+ textContentType="oneTimeCode"
+ name={props.name}
+ maxLength={props.maxLength}
+ value={input}
+ hideFocusedState
+ autoComplete={index === 0 ? props.autoComplete : 'off'}
+ keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
+ onChangeText={(value) => {
+ // Do not run when the event comes from an input that is
+ // not currently being responsible for the input, this is
+ // necessary to avoid calls when the input changes due to
+ // deleted characters. Only happens in mobile.
+ if (index !== editIndex || _.isUndefined(focusedIndex)) {
+ return;
+ }
+ onChangeText(value);
+ }}
+ onKeyPress={onKeyPress}
+ onFocus={(event) => onFocus(event, index)}
+ // Manually set selectionColor to make caret transparent.
+ // We cannot use caretHidden as it breaks the pasting function on Android.
+ selectionColor="transparent"
+ textInputContainerStyles={[styles.borderNone]}
+ inputStyle={[styles.inputTransparent]}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
+ />
+
))}
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index 695d935d7183..c36ae3a85f0f 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -5,6 +5,7 @@ import {format} from 'date-fns';
import _ from 'underscore';
import {View} from 'react-native';
import lodashGet from 'lodash/get';
+import {useIsFocused} from '@react-navigation/native';
import Text from './Text';
import styles from '../styles/styles';
import * as ReportUtils from '../libs/ReportUtils';
@@ -36,9 +37,11 @@ import tagPropTypes from './tagPropTypes';
import ConfirmedRoute from './ConfirmedRoute';
import transactionPropTypes from './transactionPropTypes';
import DistanceRequestUtils from '../libs/DistanceRequestUtils';
+import FormHelpMessage from './FormHelpMessage';
import * as IOU from '../libs/actions/IOU';
import * as TransactionUtils from '../libs/TransactionUtils';
import * as PolicyUtils from '../libs/PolicyUtils';
+import * as MoneyRequestUtils from '../libs/MoneyRequestUtils';
const propTypes = {
/** Callback to inform parent modal of success */
@@ -216,6 +219,13 @@ function MoneyRequestConfirmationList(props) {
props.isDistanceRequest ? currency : props.iouCurrencyCode,
);
+ const isFocused = useIsFocused();
+ const [formError, setFormError] = useState('');
+ useEffect(() => {
+ // reset the form error whenever the screen gains or loses focus
+ setFormError('');
+ }, [isFocused]);
+
useEffect(() => {
if (!shouldCalculateDistanceAmount) {
return;
@@ -370,8 +380,6 @@ function MoneyRequestConfirmationList(props) {
*/
const confirm = useCallback(
(paymentMethod) => {
- setDidConfirm(true);
-
if (_.isEmpty(selectedParticipants)) {
return;
}
@@ -381,13 +389,22 @@ function MoneyRequestConfirmationList(props) {
return;
}
+ setDidConfirm(true);
+
Log.info(`[IOU] Sending money via: ${paymentMethod}`);
onSendMoney(paymentMethod);
} else {
+ // validate the amount for distance requests
+ if (props.isDistanceRequest && !isDistanceRequestWithoutRoute && !MoneyRequestUtils.validateAmount(String(props.iouAmount))) {
+ setFormError('common.error.invalidAmount');
+ return;
+ }
+
+ setDidConfirm(true);
onConfirm(selectedParticipants);
}
},
- [selectedParticipants, onSendMoney, onConfirm, props.iouType],
+ [selectedParticipants, onSendMoney, onConfirm, props.iouType, props.isDistanceRequest, isDistanceRequestWithoutRoute, props.iouAmount],
);
const footerContent = useMemo(() => {
@@ -398,7 +415,7 @@ function MoneyRequestConfirmationList(props) {
const shouldShowSettlementButton = props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.SEND;
const shouldDisableButton = selectedParticipants.length === 0;
- return shouldShowSettlementButton ? (
+ const button = shouldShowSettlementButton ? (
) : (
confirm(value)}
options={splitOrRequestOptions}
buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
/>
);
- }, [confirm, props.bankAccountRoute, props.iouCurrencyCode, props.iouType, props.isReadOnly, props.policyID, selectedParticipants, splitOrRequestOptions]);
+
+ return (
+ <>
+ {!_.isEmpty(formError) && (
+
+ )}
+ {button}
+ >
+ );
+ }, [confirm, props.bankAccountRoute, props.iouCurrencyCode, props.iouType, props.isReadOnly, props.policyID, selectedParticipants, splitOrRequestOptions, translate, formError]);
return (
selectItem(menuIndex)}
diff --git a/src/components/ReportActionItem/TaskPreview.js b/src/components/ReportActionItem/TaskPreview.js
index 20ccd49620b1..9f82c2000dcf 100644
--- a/src/components/ReportActionItem/TaskPreview.js
+++ b/src/components/ReportActionItem/TaskPreview.js
@@ -8,6 +8,7 @@ import compose from '../../libs/compose';
import styles from '../../styles/styles';
import ONYXKEYS from '../../ONYXKEYS';
import withLocalize, {withLocalizePropTypes} from '../withLocalize';
+import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../withCurrentUserPersonalDetails';
import Icon from '../Icon';
import CONST from '../../CONST';
import * as Expensicons from '../Icon/Expensicons';
@@ -23,6 +24,7 @@ import RenderHTML from '../RenderHTML';
import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback';
import personalDetailsPropType from '../../pages/personalDetailsPropType';
import * as Session from '../../libs/actions/Session';
+import * as LocalePhoneNumber from '../../libs/LocalePhoneNumber';
const propTypes = {
/** All personal details asssociated with user */
@@ -51,9 +53,12 @@ const propTypes = {
}),
...withLocalizePropTypes,
+
+ ...withCurrentUserPersonalDetailsPropTypes,
};
const defaultProps = {
+ ...withCurrentUserPersonalDetailsDefaultProps,
personalDetailsList: {},
taskReport: {},
isHovered: false,
@@ -70,7 +75,7 @@ function TaskPreview(props) {
const taskAssigneeAccountID = Task.getTaskAssigneeAccountID(props.taskReport) || props.action.childManagerAccountID;
const assigneeLogin = lodashGet(props.personalDetailsList, [taskAssigneeAccountID, 'login'], '');
const assigneeDisplayName = lodashGet(props.personalDetailsList, [taskAssigneeAccountID, 'displayName'], '');
- const taskAssignee = assigneeLogin || assigneeDisplayName;
+ const taskAssignee = assigneeDisplayName || LocalePhoneNumber.formatPhoneNumber(assigneeLogin);
const htmlForTaskPreview = taskAssignee ? `@${taskAssignee} ${taskTitle}` : `${taskTitle}`;
const isDeletedParentAction = ReportUtils.isCanceledTaskReport(props.taskReport, props.action);
@@ -91,7 +96,7 @@ function TaskPreview(props) {
style={[styles.mr2]}
containerStyle={[styles.taskCheckbox]}
isChecked={isTaskCompleted}
- disabled={ReportUtils.isCanceledTaskReport(props.taskReport)}
+ disabled={!Task.canModifyTask(props.taskReport, props.currentUserPersonalDetails.accountID)}
onPress={Session.checkIfActionIsAllowed(() => {
if (isTaskCompleted) {
Task.reopenTask(props.taskReport);
@@ -118,6 +123,7 @@ TaskPreview.displayName = 'TaskPreview';
export default compose(
withLocalize,
+ withCurrentUserPersonalDetails,
withOnyx({
taskReport: {
key: ({taskReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`,
diff --git a/src/components/ReportActionItem/TaskView.js b/src/components/ReportActionItem/TaskView.js
index c52427ae1e8d..7cddc7a969dc 100644
--- a/src/components/ReportActionItem/TaskView.js
+++ b/src/components/ReportActionItem/TaskView.js
@@ -49,9 +49,8 @@ function TaskView(props) {
const taskTitle = convertToLTR(props.report.reportName || '');
const isCompleted = ReportUtils.isCompletedTaskReport(props.report);
const isOpen = ReportUtils.isOpenTaskReport(props.report);
- const isCanceled = ReportUtils.isCanceledTaskReport(props.report);
const canModifyTask = Task.canModifyTask(props.report, props.currentUserPersonalDetails.accountID);
- const disableState = !canModifyTask || isCanceled;
+ const disableState = !canModifyTask;
const isDisableInteractive = !canModifyTask || !isOpen;
return (
@@ -102,7 +101,7 @@ function TaskView(props) {
containerBorderRadius={8}
caretSize={16}
accessibilityLabel={taskTitle || props.translate('task.task')}
- disabled={isCanceled || !canModifyTask}
+ disabled={!canModifyTask}
/>
{}}) {
+ const isUserItem = lodashGet(item, 'icons.length', 0) > 0;
+ const ListItem = isUserItem ? UserListItem : RadioListItem;
+
+ return (
+ onDismissError(item)}
+ pendingAction={item.pendingAction}
+ errors={item.errors}
+ errorRowStyles={styles.ph5}
+ >
+ onSelectRow(item)}
+ disabled={isDisabled}
+ accessibilityLabel={item.text}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ hoverDimmingValue={1}
+ hoverStyle={styles.hoveredComponentBG}
+ dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
+ >
+
+ {canSelectMultiple && (
+
+
+ {item.isSelected && (
+
+ )}
+
+
+ )}
+
+
+
+ {!canSelectMultiple && item.isSelected && (
+
+
+
+
+
+ )}
+
+
+
+ );
+}
+
+BaseListItem.displayName = 'BaseListItem';
+BaseListItem.propTypes = baseListItemPropTypes;
+
+export default BaseListItem;
diff --git a/src/components/SelectionList/BaseSelectionList.js b/src/components/SelectionList/BaseSelectionList.js
index 8d894e4c983a..ebb95475bcd9 100644
--- a/src/components/SelectionList/BaseSelectionList.js
+++ b/src/components/SelectionList/BaseSelectionList.js
@@ -7,12 +7,9 @@ import SectionList from '../SectionList';
import Text from '../Text';
import styles from '../../styles/styles';
import TextInput from '../TextInput';
-import ArrowKeyFocusManager from '../ArrowKeyFocusManager';
import CONST from '../../CONST';
import variables from '../../styles/variables';
import {propTypes as selectionListPropTypes} from './selectionListPropTypes';
-import RadioListItem from './RadioListItem';
-import UserListItem from './UserListItem';
import useKeyboardShortcut from '../../hooks/useKeyboardShortcut';
import SafeAreaConsumer from '../SafeAreaConsumer';
import withKeyboardState, {keyboardStatePropTypes} from '../withKeyboardState';
@@ -24,6 +21,9 @@ import useLocalize from '../../hooks/useLocalize';
import Log from '../../libs/Log';
import OptionsListSkeletonView from '../OptionsListSkeletonView';
import useActiveElement from '../../hooks/useActiveElement';
+import BaseListItem from './BaseListItem';
+import themeColors from '../../styles/themes/default';
+import ArrowKeyFocusManager from '../ArrowKeyFocusManager';
const propTypes = {
...keyboardStatePropTypes,
@@ -48,10 +48,13 @@ function BaseSelectionList({
headerMessage = '',
confirmButtonText = '',
onConfirm,
+ footerContent,
showScrollIndicator = false,
showLoadingPlaceholder = false,
showConfirmButton = false,
isKeyboardShown = false,
+ disableKeyboardShortcuts = false,
+ children,
}) {
const {translate} = useLocalize();
const firstLayoutRef = useRef(true);
@@ -136,19 +139,19 @@ function BaseSelectionList({
};
}, [canSelectMultiple, sections]);
- // Disable `Enter` hotkey if the active element is a button or checkbox
- const shouldDisableHotkeys = activeElement && [CONST.ACCESSIBILITY_ROLE.BUTTON, CONST.ACCESSIBILITY_ROLE.CHECKBOX].includes(activeElement.role);
-
// If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member
const [focusedIndex, setFocusedIndex] = useState(() => _.findIndex(flattenedSections.allOptions, (option) => option.keyForList === initiallyFocusedOptionKey));
+ // Disable `Enter` shortcut if the active element is a button or checkbox
+ const disableEnterShortcut = activeElement && [CONST.ACCESSIBILITY_ROLE.BUTTON, CONST.ACCESSIBILITY_ROLE.CHECKBOX].includes(activeElement.role);
+
/**
* Scrolls to the desired item index in the section list
*
* @param {Number} index - the index of the item to scroll to
* @param {Boolean} animated - whether to animate the scroll
*/
- const scrollToIndex = (index, animated) => {
+ const scrollToIndex = useCallback((index, animated = true) => {
const item = flattenedSections.allOptions[index];
if (!listRef.current || !item) {
@@ -169,7 +172,10 @@ function BaseSelectionList({
}
listRef.current.scrollToLocation({sectionIndex: adjustedSectionIndex, itemIndex, animated, viewOffset: variables.contentHeaderHeight});
- };
+
+ // If we don't disable dependencies here, we would need to make sure that the `sections` prop is stable in every usage of this component.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
/**
* Logic to run when a row is selected, either with click/press or keyboard hotkeys.
@@ -234,6 +240,14 @@ function BaseSelectionList({
const getItemLayout = (data, flatDataArrayIndex) => {
const targetItem = flattenedSections.itemLayouts[flatDataArrayIndex];
+ if (!targetItem) {
+ return {
+ length: 0,
+ offset: 0,
+ index: flatDataArrayIndex,
+ };
+ }
+
return {
length: targetItem.length,
offset: targetItem.offset,
@@ -259,33 +273,40 @@ function BaseSelectionList({
const renderItem = ({item, index, section}) => {
const normalizedIndex = index + lodashGet(section, 'indexOffset', 0);
- const isDisabled = section.isDisabled;
+ const isDisabled = section.isDisabled || item.isDisabled;
const isItemFocused = !isDisabled && focusedIndex === normalizedIndex;
// We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade.
const showTooltip = normalizedIndex < 10;
- if (canSelectMultiple) {
- return (
- selectRow(item, true)}
- onDismissError={onDismissError}
- showTooltip={showTooltip}
- />
- );
- }
-
return (
- selectRow(item, true)}
+ onDismissError={onDismissError}
/>
);
};
+ const scrollToFocusedIndexOnFirstRender = useCallback(() => {
+ if (!firstLayoutRef.current) {
+ return;
+ }
+ scrollToIndex(focusedIndex, false);
+ firstLayoutRef.current = false;
+ }, [focusedIndex, scrollToIndex]);
+
+ const updateAndScrollToFocusedIndex = useCallback(
+ (newFocusedIndex) => {
+ setFocusedIndex(newFocusedIndex);
+ scrollToIndex(newFocusedIndex, true);
+ },
+ [scrollToIndex],
+ );
+
/** Focuses the text input when the component comes into focus and after any navigation animations finish. */
useFocusEffect(
useCallback(() => {
@@ -305,14 +326,14 @@ function BaseSelectionList({
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, {
captureOnInputs: true,
shouldBubble: () => !flattenedSections.allOptions[focusedIndex],
- isActive: !shouldDisableHotkeys && isFocused,
+ isActive: !disableKeyboardShortcuts && !disableEnterShortcut && isFocused,
});
/** Calls confirm action when pressing CTRL (CMD) + Enter */
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, onConfirm, {
captureOnInputs: true,
shouldBubble: () => !flattenedSections.allOptions[focusedIndex],
- isActive: Boolean(onConfirm) && isFocused,
+ isActive: !disableKeyboardShortcuts && Boolean(onConfirm) && isFocused,
});
return (
@@ -320,10 +341,7 @@ function BaseSelectionList({
disabledIndexes={flattenedSections.disabledOptionsIndexes}
focusedIndex={focusedIndex}
maxIndex={flattenedSections.allOptions.length - 1}
- onFocusedIndexChanged={(newFocusedIndex) => {
- setFocusedIndex(newFocusedIndex);
- scrollToIndex(newFocusedIndex, true);
- }}
+ onFocusedIndexChanged={updateAndScrollToFocusedIndex}
>
{({safeAreaPaddingBottomStyle}) => (
@@ -360,7 +378,7 @@ function BaseSelectionList({
style={[styles.peopleRow, styles.userSelectNone, styles.ph5, styles.pb3]}
onPress={onSelectAll}
accessibilityLabel={translate('workspace.people.selectAll')}
- accessibilityRole="button"
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
accessibilityState={{checked: flattenedSections.allSelected}}
disabled={flattenedSections.allOptions.length === flattenedSections.disabledOptionsIndexes.length}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
@@ -387,7 +405,7 @@ function BaseSelectionList({
onScrollBeginDrag={onScrollBeginDrag}
keyExtractor={(item) => item.keyForList}
extraData={focusedIndex}
- indicatorStyle="white"
+ indicatorStyle={themeColors.selectionListIndicatorColor}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={showScrollIndicator}
initialNumToRender={12}
@@ -395,18 +413,14 @@ function BaseSelectionList({
windowSize={5}
viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}}
testID="selection-list"
- onLayout={() => {
- if (!firstLayoutRef.current) {
- return;
- }
- scrollToIndex(focusedIndex, false);
- firstLayoutRef.current = false;
- }}
+ style={[styles.flexGrow0]}
+ onLayout={scrollToFocusedIndexOnFirstRender}
/>
+ {children}
>
)}
{showConfirmButton && (
-
+
)}
+ {Boolean(footerContent) && {footerContent}}
)}
diff --git a/src/components/SelectionList/RadioListItem.js b/src/components/SelectionList/RadioListItem.js
index 530af66d91d3..83d0fc922f08 100644
--- a/src/components/SelectionList/RadioListItem.js
+++ b/src/components/SelectionList/RadioListItem.js
@@ -1,51 +1,18 @@
import React from 'react';
import {View} from 'react-native';
-import CONST from '../../CONST';
-import PressableWithFeedback from '../Pressable/PressableWithFeedback';
import styles from '../../styles/styles';
import Text from '../Text';
-import Icon from '../Icon';
-import * as Expensicons from '../Icon/Expensicons';
-import themeColors from '../../styles/themes/default';
import {radioListItemPropTypes} from './selectionListPropTypes';
-function RadioListItem({item, isFocused = false, isDisabled = false, onSelectRow}) {
+function RadioListItem({item, isFocused = false}) {
return (
- onSelectRow(item)}
- disabled={isDisabled}
- accessibilityLabel={item.text}
- accessibilityRole="button"
- hoverDimmingValue={1}
- hoverStyle={styles.hoveredComponentBG}
- dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
- >
-
-
-
- {item.text}
-
+
+ {item.text}
- {Boolean(item.alternateText) && (
- {item.alternateText}
- )}
-
-
- {item.isSelected && (
-
-
-
-
-
- )}
-
-
+ {Boolean(item.alternateText) && (
+ {item.alternateText}
+ )}
+
);
}
diff --git a/src/components/SelectionList/UserListItem.js b/src/components/SelectionList/UserListItem.js
index 0d37162a7995..436ae8cb056b 100644
--- a/src/components/SelectionList/UserListItem.js
+++ b/src/components/SelectionList/UserListItem.js
@@ -1,108 +1,50 @@
import React from 'react';
import {View} from 'react-native';
-import _ from 'underscore';
import lodashGet from 'lodash/get';
-import PressableWithFeedback from '../Pressable/PressableWithFeedback';
import styles from '../../styles/styles';
import Text from '../Text';
import {userListItemPropTypes} from './selectionListPropTypes';
-import Avatar from '../Avatar';
-import OfflineWithFeedback from '../OfflineWithFeedback';
-import CONST from '../../CONST';
-import * as StyleUtils from '../../styles/StyleUtils';
-import Icon from '../Icon';
-import * as Expensicons from '../Icon/Expensicons';
-import themeColors from '../../styles/themes/default';
import Tooltip from '../Tooltip';
-import UserDetailsTooltip from '../UserDetailsTooltip';
-
-function UserListItem({item, isFocused = false, showTooltip, onSelectRow, onDismissError = () => {}}) {
- const hasError = !_.isEmpty(item.errors);
-
- const avatar = (
-
- );
-
- const text = (
-
- {item.text}
-
- );
-
- const alternateText = (
-
- {item.alternateText}
-
- );
+import SubscriptAvatar from '../SubscriptAvatar';
+function UserListItem({item, isFocused = false, showTooltip}) {
return (
- onDismissError(item)}
- pendingAction={item.pendingAction}
- errors={item.errors}
- errorRowStyles={styles.ph5}
- >
- onSelectRow(item)}
- disabled={item.isDisabled}
- accessibilityLabel={item.text}
- accessibilityRole="checkbox"
- accessibilityState={{checked: item.isSelected}}
- hoverDimmingValue={1}
- hoverStyle={styles.hoveredComponentBG}
- dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
- >
-
-
+ {Boolean(item.icons) && (
+
+ )}
+
+
+
+ {item.text}
+
+
+ {Boolean(item.alternateText) && (
+
- {item.isSelected && (
-
- )}
-
-
- {Boolean(item.avatar) &&
- (showTooltip ? (
-
- {avatar}
-
- ) : (
- avatar
- ))}
-
- {showTooltip ? {text} : text}
- {Boolean(item.alternateText) && (showTooltip ? {alternateText} : alternateText)}
-
- {Boolean(item.rightElement) && item.rightElement}
-
-
+ {item.alternateText}
+
+
+ )}
+
+ {Boolean(item.rightElement) && item.rightElement}
+ >
);
}
diff --git a/src/components/SelectionList/selectionListPropTypes.js b/src/components/SelectionList/selectionListPropTypes.js
index 0a3c1efdf6a3..96c2f63eb09a 100644
--- a/src/components/SelectionList/selectionListPropTypes.js
+++ b/src/components/SelectionList/selectionListPropTypes.js
@@ -2,7 +2,29 @@ import PropTypes from 'prop-types';
import _ from 'underscore';
import CONST from '../../CONST';
+const commonListItemPropTypes = {
+ /** Whether this item is focused (for arrow key controls) */
+ isFocused: PropTypes.bool,
+
+ /** Whether this item is disabled */
+ isDisabled: PropTypes.bool,
+
+ /** Whether this item should show Tooltip */
+ showTooltip: PropTypes.bool.isRequired,
+
+ /** Whether to use the Checkbox (multiple selection) instead of the Checkmark (single selection) */
+ canSelectMultiple: PropTypes.bool,
+
+ /** Callback to fire when the item is pressed */
+ onSelectRow: PropTypes.func.isRequired,
+
+ /** Callback to fire when an error is dismissed */
+ onDismissError: PropTypes.func,
+};
+
const userListItemPropTypes = {
+ ...commonListItemPropTypes,
+
/** The section list item */
item: PropTypes.shape({
/** Text to display */
@@ -29,12 +51,14 @@ const userListItemPropTypes = {
/** Element to show on the right side of the item */
rightElement: PropTypes.element,
- /** Avatar for the user */
- avatar: PropTypes.shape({
- source: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
- name: PropTypes.string,
- type: PropTypes.string,
- }),
+ /** Icons for the user (can be multiple if it's a Workspace) */
+ icons: PropTypes.arrayOf(
+ PropTypes.shape({
+ source: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
+ name: PropTypes.string,
+ type: PropTypes.string,
+ }),
+ ),
/** Errors that this user may contain */
errors: PropTypes.objectOf(PropTypes.string),
@@ -42,21 +66,11 @@ const userListItemPropTypes = {
/** The type of action that's pending */
pendingAction: PropTypes.oneOf(_.values(CONST.RED_BRICK_ROAD_PENDING_ACTION)),
}).isRequired,
-
- /** Whether this item is focused (for arrow key controls) */
- isFocused: PropTypes.bool,
-
- /** Whether this item should show Tooltip */
- showTooltip: PropTypes.bool.isRequired,
-
- /** Callback to fire when the item is pressed */
- onSelectRow: PropTypes.func.isRequired,
-
- /** Callback to fire when an error is dismissed */
- onDismissError: PropTypes.func,
};
const radioListItemPropTypes = {
+ ...commonListItemPropTypes,
+
/** The section list item */
item: PropTypes.shape({
/** Text to display */
@@ -71,15 +85,11 @@ const radioListItemPropTypes = {
/** Whether this option is selected */
isSelected: PropTypes.bool,
}).isRequired,
+};
- /** Whether this item is focused (for arrow key controls) */
- isFocused: PropTypes.bool,
-
- /** Whether this item is disabled */
- isDisabled: PropTypes.bool,
-
- /** Callback to fire when the item is pressed */
- onSelectRow: PropTypes.func.isRequired,
+const baseListItemPropTypes = {
+ ...commonListItemPropTypes,
+ item: PropTypes.oneOfType([PropTypes.shape(userListItemPropTypes.item), PropTypes.shape(radioListItemPropTypes.item)]),
};
const propTypes = {
@@ -156,6 +166,9 @@ const propTypes = {
/** Whether to show the default confirm button */
showConfirmButton: PropTypes.bool,
+
+ /** Custom content to display in the footer */
+ footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
};
-export {propTypes, radioListItemPropTypes, userListItemPropTypes};
+export {propTypes, baseListItemPropTypes, radioListItemPropTypes, userListItemPropTypes};
diff --git a/src/components/SubscriptAvatar.js b/src/components/SubscriptAvatar.js
index 81864d6e5af2..4102ae5ec043 100644
--- a/src/components/SubscriptAvatar.js
+++ b/src/components/SubscriptAvatar.js
@@ -26,6 +26,9 @@ const propTypes = {
/** Removes margin from around the avatar, used for the chat view */
noMargin: PropTypes.bool,
+
+ /** Whether to show the tooltip */
+ showTooltip: PropTypes.bool,
};
const defaultProps = {
@@ -34,42 +37,46 @@ const defaultProps = {
mainAvatar: {},
secondaryAvatar: {},
noMargin: false,
+ showTooltip: true,
};
-function SubscriptAvatar(props) {
- const isSmall = props.size === CONST.AVATAR_SIZE.SMALL;
- const subscriptStyle = props.size === CONST.AVATAR_SIZE.SMALL_NORMAL ? styles.secondAvatarSubscriptSmallNormal : styles.secondAvatarSubscript;
+function SubscriptAvatar({size, backgroundColor, mainAvatar, secondaryAvatar, noMargin, showTooltip}) {
+ const isSmall = size === CONST.AVATAR_SIZE.SMALL;
+ const subscriptStyle = size === CONST.AVATAR_SIZE.SMALL_NORMAL ? styles.secondAvatarSubscriptSmallNormal : styles.secondAvatarSubscript;
const containerStyle = isSmall ? styles.emptyAvatarSmall : styles.emptyAvatar;
// Default the margin style to what is normal for small or normal sized avatars
let marginStyle = isSmall ? styles.emptyAvatarMarginSmall : styles.emptyAvatarMargin;
// Some views like the chat view require that there be no margins
- if (props.noMargin) {
+ if (noMargin) {
marginStyle = {};
}
+
return (
diff --git a/src/components/TaskHeaderActionButton.js b/src/components/TaskHeaderActionButton.js
index b22b5c92bf70..a16f415f0fd7 100644
--- a/src/components/TaskHeaderActionButton.js
+++ b/src/components/TaskHeaderActionButton.js
@@ -34,7 +34,7 @@ function TaskHeaderActionButton(props) {
(ReportUtils.isCompletedTaskReport(props.report) ? Task.reopenTask(props.report) : Task.completeTask(props.report))}
diff --git a/src/components/TextInput/BaseTextInput.js b/src/components/TextInput/BaseTextInput.js
index b469f39c7037..bd6548607cb9 100644
--- a/src/components/TextInput/BaseTextInput.js
+++ b/src/components/TextInput/BaseTextInput.js
@@ -24,8 +24,8 @@ import useNativeDriver from '../../libs/useNativeDriver';
import * as Browser from '../../libs/Browser';
function BaseTextInput(props) {
- const inputValue = props.value || props.defaultValue || '';
- const initialActiveLabel = props.forceActiveLabel || inputValue.length > 0 || Boolean(props.prefixCharacter);
+ const initialValue = props.value || props.defaultValue || '';
+ const initialActiveLabel = props.forceActiveLabel || initialValue.length > 0 || Boolean(props.prefixCharacter);
const [isFocused, setIsFocused] = useState(false);
const [passwordHidden, setPasswordHidden] = useState(props.secureTextEntry);
@@ -145,30 +145,16 @@ function BaseTextInput(props) {
[props.autoGrowHeight, props.multiline],
);
- useEffect(() => {
- // Handle side effects when the value gets changed programatically from the outside
-
- // In some cases, When the value prop is empty, it is not properly updated on the TextInput due to its uncontrolled nature, thus manually clearing the TextInput.
- if (inputValue === '') {
- input.current.clear();
- }
-
- if (inputValue) {
- activateLabel();
- }
- }, [activateLabel, inputValue]);
-
- // We capture whether the input has a value or not in a ref.
- // It gets updated when the text gets changed.
- const hasValueRef = useRef(inputValue.length > 0);
+ // The ref is needed when the component is uncontrolled and we don't have a value prop
+ const hasValueRef = useRef(initialValue.length > 0);
+ const inputValue = props.value || '';
+ const hasValue = inputValue.length > 0 || hasValueRef.current;
- // Activate or deactivate the label when the focus changes:
+ // Activate or deactivate the label when either focus changes, or for controlled
+ // components when the value prop changes:
useEffect(() => {
- // We can't use inputValue here directly, as it might contain
- // the defaultValue, which doesn't get updated when the text changes.
- // We can't use props.value either, as it might be undefined.
if (
- hasValueRef.current ||
+ hasValue ||
isFocused ||
// If the text has been supplied by Chrome autofill, the value state is not synced with the value
// as Chrome doesn't trigger a change event. When there is autofill text, keep the label activated.
@@ -178,7 +164,16 @@ function BaseTextInput(props) {
} else {
deactivateLabel();
}
- }, [activateLabel, deactivateLabel, inputValue, isFocused]);
+ }, [activateLabel, deactivateLabel, hasValue, isFocused]);
+
+ // When the value prop gets cleared externally, we need to keep the ref in sync:
+ useEffect(() => {
+ // Return early when component uncontrolled, or we still have a value
+ if (props.value === undefined || !_.isEmpty(props.value)) {
+ return;
+ }
+ hasValueRef.current = false;
+ }, [props.value]);
/**
* Set Value & activateLabel
@@ -192,9 +187,13 @@ function BaseTextInput(props) {
}
Str.result(props.onChangeText, value);
+
if (value && value.length > 0) {
hasValueRef.current = true;
- activateLabel();
+ // When the componment is uncontrolled, we need to manually activate the label:
+ if (props.value === undefined) {
+ activateLabel();
+ }
} else {
hasValueRef.current = false;
}
diff --git a/src/components/Tooltip/BaseTooltip.js b/src/components/Tooltip/BaseTooltip.js
new file mode 100644
index 000000000000..f60982f52dd4
--- /dev/null
+++ b/src/components/Tooltip/BaseTooltip.js
@@ -0,0 +1,168 @@
+import _ from 'underscore';
+import React, {memo, useCallback, useEffect, useRef, useState} from 'react';
+import {Animated} from 'react-native';
+import {BoundsObserver} from '@react-ng/bounds-observer';
+import TooltipRenderedOnPageBody from './TooltipRenderedOnPageBody';
+import Hoverable from '../Hoverable';
+import * as tooltipPropTypes from './tooltipPropTypes';
+import TooltipSense from './TooltipSense';
+import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
+import usePrevious from '../../hooks/usePrevious';
+import useLocalize from '../../hooks/useLocalize';
+import useWindowDimensions from '../../hooks/useWindowDimensions';
+
+const hasHoverSupport = DeviceCapabilities.hasHoverSupport();
+
+/**
+ * A component used to wrap an element intended for displaying a tooltip. The term "tooltip's target" refers to the
+ * wrapped element, which, upon hover, triggers the tooltip to be shown.
+ * @param {propTypes} props
+ * @returns {ReactNodeLike}
+ */
+function Tooltip(props) {
+ const {children, numberOfLines, maxWidth, text, renderTooltipContent, renderTooltipContentKey} = props;
+
+ const {preferredLocale} = useLocalize();
+ const {windowWidth} = useWindowDimensions();
+
+ // Is tooltip already rendered on the page's body? happens once.
+ const [isRendered, setIsRendered] = useState(false);
+ // Is the tooltip currently visible?
+ const [isVisible, setIsVisible] = useState(false);
+ // The distance between the left side of the wrapper view and the left side of the window
+ const [xOffset, setXOffset] = useState(0);
+ // The distance between the top of the wrapper view and the top of the window
+ const [yOffset, setYOffset] = useState(0);
+ // The width and height of the wrapper view
+ const [wrapperWidth, setWrapperWidth] = useState(0);
+ const [wrapperHeight, setWrapperHeight] = useState(0);
+
+ // Whether the tooltip is first tooltip to activate the TooltipSense
+ const isTooltipSenseInitiator = useRef(false);
+ const animation = useRef(new Animated.Value(0));
+ const isAnimationCanceled = useRef(false);
+ const prevText = usePrevious(text);
+
+ /**
+ * Display the tooltip in an animation.
+ */
+ const showTooltip = useCallback(() => {
+ if (!isRendered) {
+ setIsRendered(true);
+ }
+
+ setIsVisible(true);
+
+ animation.current.stopAnimation();
+
+ // When TooltipSense is active, immediately show the tooltip
+ if (TooltipSense.isActive()) {
+ animation.current.setValue(1);
+ } else {
+ isTooltipSenseInitiator.current = true;
+ Animated.timing(animation.current, {
+ toValue: 1,
+ duration: 140,
+ delay: 500,
+ useNativeDriver: false,
+ }).start(({finished}) => {
+ isAnimationCanceled.current = !finished;
+ });
+ }
+ TooltipSense.activate();
+ }, [isRendered]);
+
+ // eslint-disable-next-line rulesdir/prefer-early-return
+ useEffect(() => {
+ // if the tooltip text changed before the initial animation was finished, then the tooltip won't be shown
+ // we need to show the tooltip again
+ if (isVisible && isAnimationCanceled.current && text && prevText !== text) {
+ isAnimationCanceled.current = false;
+ showTooltip();
+ }
+ }, [isVisible, text, prevText, showTooltip]);
+
+ /**
+ * Update the tooltip bounding rectangle
+ *
+ * @param {Object} bounds - updated bounds
+ */
+ const updateBounds = (bounds) => {
+ if (bounds.width === 0) {
+ setIsRendered(false);
+ }
+ setWrapperWidth(bounds.width);
+ setWrapperHeight(bounds.height);
+ setXOffset(bounds.x);
+ setYOffset(bounds.y);
+ };
+
+ /**
+ * Hide the tooltip in an animation.
+ */
+ const hideTooltip = () => {
+ animation.current.stopAnimation();
+
+ if (TooltipSense.isActive() && !isTooltipSenseInitiator.current) {
+ animation.current.setValue(0);
+ } else {
+ // Hide the first tooltip which initiated the TooltipSense with animation
+ isTooltipSenseInitiator.current = false;
+ Animated.timing(animation.current, {
+ toValue: 0,
+ duration: 140,
+ useNativeDriver: false,
+ }).start();
+ }
+
+ TooltipSense.deactivate();
+
+ setIsVisible(false);
+ };
+
+ // Skip the tooltip and return the children if the text is empty,
+ // we don't have a render function or the device does not support hovering
+ if ((_.isEmpty(text) && renderTooltipContent == null) || !hasHoverSupport) {
+ return children;
+ }
+
+ return (
+ <>
+ {isRendered && (
+
+ )}
+
+
+ {children}
+
+
+ >
+ );
+}
+
+Tooltip.propTypes = tooltipPropTypes.propTypes;
+Tooltip.defaultProps = tooltipPropTypes.defaultProps;
+export default memo(Tooltip);
diff --git a/src/components/Tooltip/index.native.js b/src/components/Tooltip/BaseTooltip.native.js
similarity index 100%
rename from src/components/Tooltip/index.native.js
rename to src/components/Tooltip/BaseTooltip.native.js
diff --git a/src/components/Tooltip/index.js b/src/components/Tooltip/index.js
index f60982f52dd4..2e6789ec73f6 100644
--- a/src/components/Tooltip/index.js
+++ b/src/components/Tooltip/index.js
@@ -1,168 +1,37 @@
-import _ from 'underscore';
-import React, {memo, useCallback, useEffect, useRef, useState} from 'react';
-import {Animated} from 'react-native';
-import {BoundsObserver} from '@react-ng/bounds-observer';
-import TooltipRenderedOnPageBody from './TooltipRenderedOnPageBody';
-import Hoverable from '../Hoverable';
-import * as tooltipPropTypes from './tooltipPropTypes';
-import TooltipSense from './TooltipSense';
-import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
-import usePrevious from '../../hooks/usePrevious';
-import useLocalize from '../../hooks/useLocalize';
-import useWindowDimensions from '../../hooks/useWindowDimensions';
+import React from 'react';
+import PropTypes from 'prop-types';
+import {propTypes as tooltipPropTypes, defaultProps as tooltipDefaultProps} from './tooltipPropTypes';
+import BaseTooltip from './BaseTooltip';
-const hasHoverSupport = DeviceCapabilities.hasHoverSupport();
+const propTypes = {
+ ...tooltipPropTypes,
-/**
- * A component used to wrap an element intended for displaying a tooltip. The term "tooltip's target" refers to the
- * wrapped element, which, upon hover, triggers the tooltip to be shown.
- * @param {propTypes} props
- * @returns {ReactNodeLike}
- */
-function Tooltip(props) {
- const {children, numberOfLines, maxWidth, text, renderTooltipContent, renderTooltipContentKey} = props;
+ /** Whether the actual Tooltip should be rendered. If false, it's just going to return the children */
+ shouldRender: PropTypes.bool,
+};
- const {preferredLocale} = useLocalize();
- const {windowWidth} = useWindowDimensions();
+const defaultProps = {
+ ...tooltipDefaultProps,
+ shouldRender: true,
+};
- // Is tooltip already rendered on the page's body? happens once.
- const [isRendered, setIsRendered] = useState(false);
- // Is the tooltip currently visible?
- const [isVisible, setIsVisible] = useState(false);
- // The distance between the left side of the wrapper view and the left side of the window
- const [xOffset, setXOffset] = useState(0);
- // The distance between the top of the wrapper view and the top of the window
- const [yOffset, setYOffset] = useState(0);
- // The width and height of the wrapper view
- const [wrapperWidth, setWrapperWidth] = useState(0);
- const [wrapperHeight, setWrapperHeight] = useState(0);
-
- // Whether the tooltip is first tooltip to activate the TooltipSense
- const isTooltipSenseInitiator = useRef(false);
- const animation = useRef(new Animated.Value(0));
- const isAnimationCanceled = useRef(false);
- const prevText = usePrevious(text);
-
- /**
- * Display the tooltip in an animation.
- */
- const showTooltip = useCallback(() => {
- if (!isRendered) {
- setIsRendered(true);
- }
-
- setIsVisible(true);
-
- animation.current.stopAnimation();
-
- // When TooltipSense is active, immediately show the tooltip
- if (TooltipSense.isActive()) {
- animation.current.setValue(1);
- } else {
- isTooltipSenseInitiator.current = true;
- Animated.timing(animation.current, {
- toValue: 1,
- duration: 140,
- delay: 500,
- useNativeDriver: false,
- }).start(({finished}) => {
- isAnimationCanceled.current = !finished;
- });
- }
- TooltipSense.activate();
- }, [isRendered]);
-
- // eslint-disable-next-line rulesdir/prefer-early-return
- useEffect(() => {
- // if the tooltip text changed before the initial animation was finished, then the tooltip won't be shown
- // we need to show the tooltip again
- if (isVisible && isAnimationCanceled.current && text && prevText !== text) {
- isAnimationCanceled.current = false;
- showTooltip();
- }
- }, [isVisible, text, prevText, showTooltip]);
-
- /**
- * Update the tooltip bounding rectangle
- *
- * @param {Object} bounds - updated bounds
- */
- const updateBounds = (bounds) => {
- if (bounds.width === 0) {
- setIsRendered(false);
- }
- setWrapperWidth(bounds.width);
- setWrapperHeight(bounds.height);
- setXOffset(bounds.x);
- setYOffset(bounds.y);
- };
-
- /**
- * Hide the tooltip in an animation.
- */
- const hideTooltip = () => {
- animation.current.stopAnimation();
-
- if (TooltipSense.isActive() && !isTooltipSenseInitiator.current) {
- animation.current.setValue(0);
- } else {
- // Hide the first tooltip which initiated the TooltipSense with animation
- isTooltipSenseInitiator.current = false;
- Animated.timing(animation.current, {
- toValue: 0,
- duration: 140,
- useNativeDriver: false,
- }).start();
- }
-
- TooltipSense.deactivate();
-
- setIsVisible(false);
- };
-
- // Skip the tooltip and return the children if the text is empty,
- // we don't have a render function or the device does not support hovering
- if ((_.isEmpty(text) && renderTooltipContent == null) || !hasHoverSupport) {
+function Tooltip({shouldRender, children, ...props}) {
+ if (!shouldRender) {
return children;
}
return (
- <>
- {isRendered && (
-
- )}
-
-
- {children}
-
-
- >
+
+ {children}
+
);
}
-Tooltip.propTypes = tooltipPropTypes.propTypes;
-Tooltip.defaultProps = tooltipPropTypes.defaultProps;
-export default memo(Tooltip);
+Tooltip.displayName = 'Tooltip';
+Tooltip.propTypes = propTypes;
+Tooltip.defaultProps = defaultProps;
+
+export default Tooltip;
diff --git a/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.js b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.js
new file mode 100644
index 000000000000..6c611dae17fd
--- /dev/null
+++ b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.js
@@ -0,0 +1,19 @@
+import PropTypes from 'prop-types';
+
+const propTypes = {
+ /** Children to wrap with Tooltip. */
+ children: PropTypes.node.isRequired,
+};
+
+/**
+ * @param {propTypes} props
+ * @returns {ReactNodeLike}
+ */
+function BaseUserDetailsTooltip(props) {
+ return props.children;
+}
+
+BaseUserDetailsTooltip.propTypes = propTypes;
+BaseUserDetailsTooltip.displayName = 'BaseUserDetailsTooltip';
+
+export default BaseUserDetailsTooltip;
diff --git a/src/components/UserDetailsTooltip/index.web.js b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.web.js
similarity index 94%
rename from src/components/UserDetailsTooltip/index.web.js
rename to src/components/UserDetailsTooltip/BaseUserDetailsTooltip.web.js
index e961c237ae5f..5f124cb21467 100644
--- a/src/components/UserDetailsTooltip/index.web.js
+++ b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.web.js
@@ -14,7 +14,7 @@ import CONST from '../../CONST';
import * as LocalePhoneNumber from '../../libs/LocalePhoneNumber';
import useLocalize from '../../hooks/useLocalize';
-function UserDetailsTooltip(props) {
+function BaseUserDetailsTooltip(props) {
const {translate} = useLocalize();
const userDetails = lodashGet(props.personalDetailsList, props.accountID, props.fallbackUserDetails);
@@ -74,12 +74,12 @@ function UserDetailsTooltip(props) {
);
}
-UserDetailsTooltip.propTypes = propTypes;
-UserDetailsTooltip.defaultProps = defaultProps;
-UserDetailsTooltip.displayName = 'UserDetailsTooltip';
+BaseUserDetailsTooltip.propTypes = propTypes;
+BaseUserDetailsTooltip.defaultProps = defaultProps;
+BaseUserDetailsTooltip.displayName = 'BaseUserDetailsTooltip';
export default withOnyx({
personalDetailsList: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
},
-})(UserDetailsTooltip);
+})(BaseUserDetailsTooltip);
diff --git a/src/components/UserDetailsTooltip/index.js b/src/components/UserDetailsTooltip/index.js
index b51dfee2060c..86778e4a1019 100644
--- a/src/components/UserDetailsTooltip/index.js
+++ b/src/components/UserDetailsTooltip/index.js
@@ -1,19 +1,37 @@
+import React from 'react';
import PropTypes from 'prop-types';
+import {propTypes as userDetailsTooltipPropTypes, defaultProps as userDetailsTooltipDefaultProps} from './userDetailsTooltipPropTypes';
+import BaseUserDetailsTooltip from './BaseUserDetailsTooltip';
const propTypes = {
- /** Children to wrap with Tooltip. */
- children: PropTypes.node.isRequired,
+ ...userDetailsTooltipPropTypes,
+
+ /** Whether the actual UserDetailsTooltip should be rendered. If false, it's just going to return the children */
+ shouldRender: PropTypes.bool,
+};
+
+const defaultProps = {
+ ...userDetailsTooltipDefaultProps,
+ shouldRender: true,
};
-/**
- * @param {propTypes} props
- * @returns {ReactNodeLike}
- */
-function UserDetailsTooltip(props) {
- return props.children;
+function UserDetailsTooltip({shouldRender, children, ...props}) {
+ if (!shouldRender) {
+ return children;
+ }
+
+ return (
+
+ {children}
+
+ );
}
-UserDetailsTooltip.propTypes = propTypes;
UserDetailsTooltip.displayName = 'UserDetailsTooltip';
+UserDetailsTooltip.propTypes = propTypes;
+UserDetailsTooltip.defaultProps = defaultProps;
export default UserDetailsTooltip;
diff --git a/src/components/withLocalize.js b/src/components/withLocalize.js
index 5ce1b0bc6d74..65e98f78f312 100755
--- a/src/components/withLocalize.js
+++ b/src/components/withLocalize.js
@@ -1,19 +1,7 @@
-import React, {createContext, forwardRef} from 'react';
+import React, {forwardRef} from 'react';
import PropTypes from 'prop-types';
-import {withOnyx} from 'react-native-onyx';
-import lodashGet from 'lodash/get';
+import {LocaleContext} from './LocaleContextProvider';
import getComponentDisplayName from '../libs/getComponentDisplayName';
-import ONYXKEYS from '../ONYXKEYS';
-import * as Localize from '../libs/Localize';
-import DateUtils from '../libs/DateUtils';
-import * as NumberFormatUtils from '../libs/NumberFormatUtils';
-import * as LocaleDigitUtils from '../libs/LocaleDigitUtils';
-import CONST from '../CONST';
-import compose from '../libs/compose';
-import withCurrentUserPersonalDetails from './withCurrentUserPersonalDetails';
-import * as LocalePhoneNumber from '../libs/LocalePhoneNumber';
-
-const LocaleContext = createContext(null);
const withLocalizePropTypes = {
/** Returns translated string for given locale and phrase */
@@ -42,140 +30,6 @@ const withLocalizePropTypes = {
toLocaleDigit: PropTypes.func.isRequired,
};
-const localeProviderPropTypes = {
- /** The user's preferred locale e.g. 'en', 'es-ES' */
- preferredLocale: PropTypes.string,
-
- /** Actual content wrapped by this component */
- children: PropTypes.node.isRequired,
-
- /** The current user's personalDetails */
- currentUserPersonalDetails: PropTypes.shape({
- /** Timezone of the current user */
- timezone: PropTypes.shape({
- /** Value of the selected timezone */
- selected: PropTypes.string,
- }),
- }),
-};
-
-const localeProviderDefaultProps = {
- preferredLocale: CONST.LOCALES.DEFAULT,
- currentUserPersonalDetails: {},
-};
-
-class LocaleContextProvider extends React.Component {
- shouldComponentUpdate(nextProps) {
- return (
- nextProps.preferredLocale !== this.props.preferredLocale ||
- lodashGet(nextProps, 'currentUserPersonalDetails.timezone.selected') !== lodashGet(this.props, 'currentUserPersonalDetails.timezone.selected')
- );
- }
-
- /**
- * The context this component exposes to child components
- * @returns {object} translation util functions and locale
- */
- getContextValue() {
- return {
- translate: this.translate.bind(this),
- numberFormat: this.numberFormat.bind(this),
- datetimeToRelative: this.datetimeToRelative.bind(this),
- datetimeToCalendarTime: this.datetimeToCalendarTime.bind(this),
- updateLocale: this.updateLocale.bind(this),
- formatPhoneNumber: this.formatPhoneNumber.bind(this),
- fromLocaleDigit: this.fromLocaleDigit.bind(this),
- toLocaleDigit: this.toLocaleDigit.bind(this),
- preferredLocale: this.props.preferredLocale,
- };
- }
-
- /**
- * @param {String} phrase
- * @param {Object} [variables]
- * @returns {String}
- */
- translate(phrase, variables) {
- return Localize.translate(this.props.preferredLocale, phrase, variables);
- }
-
- /**
- * @param {Number} number
- * @param {Intl.NumberFormatOptions} options
- * @returns {String}
- */
- numberFormat(number, options) {
- return NumberFormatUtils.format(this.props.preferredLocale, number, options);
- }
-
- /**
- * @param {String} datetime
- * @returns {String}
- */
- datetimeToRelative(datetime) {
- return DateUtils.datetimeToRelative(this.props.preferredLocale, datetime);
- }
-
- /**
- * @param {String} datetime - ISO-formatted datetime string
- * @param {Boolean} [includeTimezone]
- * @param {Boolean} isLowercase
- * @returns {String}
- */
- datetimeToCalendarTime(datetime, includeTimezone, isLowercase = false) {
- return DateUtils.datetimeToCalendarTime(this.props.preferredLocale, datetime, includeTimezone, lodashGet(this.props, 'currentUserPersonalDetails.timezone.selected'), isLowercase);
- }
-
- /**
- * Updates date-fns internal locale to the user preferredLocale
- */
- updateLocale() {
- DateUtils.setLocale(this.props.preferredLocale);
- }
-
- /**
- * @param {String} phoneNumber
- * @returns {String}
- */
- formatPhoneNumber(phoneNumber) {
- return LocalePhoneNumber.formatPhoneNumber(phoneNumber);
- }
-
- /**
- * @param {String} digit
- * @returns {String}
- */
- toLocaleDigit(digit) {
- return LocaleDigitUtils.toLocaleDigit(this.props.preferredLocale, digit);
- }
-
- /**
- * @param {String} localeDigit
- * @returns {String}
- */
- fromLocaleDigit(localeDigit) {
- return LocaleDigitUtils.fromLocaleDigit(this.props.preferredLocale, localeDigit);
- }
-
- render() {
- return {this.props.children};
- }
-}
-
-LocaleContextProvider.propTypes = localeProviderPropTypes;
-LocaleContextProvider.defaultProps = localeProviderDefaultProps;
-
-const Provider = compose(
- withCurrentUserPersonalDetails,
- withOnyx({
- preferredLocale: {
- key: ONYXKEYS.NVP_PREFERRED_LOCALE,
- },
- }),
-)(LocaleContextProvider);
-
-Provider.displayName = 'withOnyx(LocaleContextProvider)';
-
export default function withLocalize(WrappedComponent) {
const WithLocalize = forwardRef((props, ref) => (
@@ -196,4 +50,4 @@ export default function withLocalize(WrappedComponent) {
return WithLocalize;
}
-export {withLocalizePropTypes, Provider as LocaleContextProvider, LocaleContext};
+export {withLocalizePropTypes};
diff --git a/src/hooks/useLocalize.js b/src/hooks/useLocalize.js
index 9ad5048729bd..7f7a610fca8b 100644
--- a/src/hooks/useLocalize.js
+++ b/src/hooks/useLocalize.js
@@ -1,5 +1,5 @@
import {useContext} from 'react';
-import {LocaleContext} from '../components/withLocalize';
+import {LocaleContext} from '../components/LocaleContextProvider';
export default function useLocalize() {
return useContext(LocaleContext);
diff --git a/src/languages/en.ts b/src/languages/en.ts
index dfad8883e270..abba4cfd71a3 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -73,6 +73,7 @@ import type {
RequestedAmountMessageParams,
TagSelectionParams,
TranslationBase,
+ WalletProgramParams,
} from './types';
import * as ReportActionsUtils from '../libs/ReportActionsUtils';
@@ -175,6 +176,7 @@ export default {
notifications: 'Notifications',
na: 'N/A',
noResultsFound: 'No results found',
+ recentDestinations: 'Recent destinations',
timePrefix: "It's",
conjunctionFor: 'for',
todayAt: 'Today at',
@@ -208,6 +210,7 @@ export default {
debitCard: 'Debit card',
bankAccount: 'Bank account',
join: 'Join',
+ joinThread: 'Join thread',
decline: 'Decline',
transferBalance: 'Transfer balance',
cantFindAddress: "Can't find your address? ",
@@ -908,7 +911,7 @@ export default {
phrase2: 'Terms of Service',
phrase3: 'and',
phrase4: 'Privacy',
- phrase5: 'Money transmission is provided by Expensify Payments LLC (NMLS ID:2017010) pursuant to its',
+ phrase5: `Money transmission is provided by ${CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS} (NMLS ID:2017010) pursuant to its`,
phrase6: 'licenses',
},
validateCodeForm: {
@@ -1169,7 +1172,7 @@ export default {
electronicFundsWithdrawal: 'Electronic funds withdrawal',
standard: 'Standard',
shortTermsForm: {
- expensifyPaymentsAccount: 'The Expensify Wallet is issued by The Bancorp Bank.',
+ expensifyPaymentsAccount: ({walletProgram}: WalletProgramParams) => `The Expensify Wallet is issued by ${walletProgram}.`,
perPurchase: 'Per purchase',
atmWithdrawal: 'ATM withdrawal',
cashReload: 'Cash reload',
@@ -1211,10 +1214,10 @@ export default {
'several minutes. The fee is 1.5% of the transfer amount (with a minimum fee of $0.25).',
fdicInsuranceBancorp:
'Your funds are eligible for FDIC insurance. Your funds will be held at or ' +
- 'transferred to The Bancorp Bank, an FDIC-insured institution. Once there, your funds are insured up ' +
- 'to $250,000 by the FDIC in the event The Bancorp Bank fails. See',
+ `transferred to ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK}, an FDIC-insured institution. Once there, your funds are insured up ` +
+ `to $250,000 by the FDIC in the event ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK} fails. See`,
fdicInsuranceBancorp2: 'for details.',
- contactExpensifyPayments: 'Contact Expensify Payments by calling +1 833-400-0904, by email at',
+ contactExpensifyPayments: `Contact ${CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS} by calling +1 833-400-0904, by email at`,
contactExpensifyPayments2: 'or sign in at',
generalInformation: 'For general information about prepaid accounts, visit',
generalInformation2: 'If you have a complaint about a prepaid account, call the Consumer Financial Protection Bureau at 1-855-411-2372 or visit',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 6ffbda2648c6..fde0d22f6ec2 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -73,6 +73,7 @@ import type {
RequestedAmountMessageParams,
TagSelectionParams,
EnglishTranslation,
+ WalletProgramParams,
} from './types';
/* eslint-disable max-len */
@@ -165,6 +166,7 @@ export default {
notifications: 'Notificaciones',
na: 'N/A',
noResultsFound: 'No se han encontrado resultados',
+ recentDestinations: 'Destinos recientes',
timePrefix: 'Son las',
conjunctionFor: 'para',
todayAt: 'Hoy a las',
@@ -198,6 +200,7 @@ export default {
debitCard: 'Tarjeta de débito',
bankAccount: 'Cuenta bancaria',
join: 'Unirse',
+ joinThread: 'Unirse al hilo',
decline: 'Rechazar',
transferBalance: 'Transferencia de saldo',
cantFindAddress: '¿No encuentras tu dirección? ',
@@ -904,7 +907,7 @@ export default {
phrase2: 'Términos de Servicio',
phrase3: 'y',
phrase4: 'Privacidad',
- phrase5: 'El envío de dinero es brindado por Expensify Payments LLC (NMLS ID:2017010) de conformidad con sus',
+ phrase5: `El envío de dinero es brindado por ${CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS} (NMLS ID:2017010) de conformidad con sus`,
phrase6: 'licencias',
},
validateCodeForm: {
@@ -1186,7 +1189,7 @@ export default {
electronicFundsWithdrawal: 'Retiro electrónico de fondos',
standard: 'Estándar',
shortTermsForm: {
- expensifyPaymentsAccount: 'La billetera Expensify es emitida por The Bancorp Bank.',
+ expensifyPaymentsAccount: ({walletProgram}: WalletProgramParams) => `La billetera Expensify es emitida por ${walletProgram}.`,
perPurchase: 'Por compra',
atmWithdrawal: 'Retiro de cajero automático',
cashReload: 'Recarga de efectivo',
@@ -1229,10 +1232,10 @@ export default {
'transferencia (con una tarifa mínima de $ 0.25). ',
fdicInsuranceBancorp:
'Sus fondos son elegibles para el seguro de la FDIC. Sus fondos se mantendrán en o ' +
- 'transferido a The Bancorp Bank, una institución asegurada por la FDIC. Una vez allí, sus fondos ' +
- 'están asegurados a $ 250,000 por la FDIC en caso de que The Bancorp Bank quiebre. Ver',
+ `transferido a ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK}, una institución asegurada por la FDIC. Una vez allí, sus fondos ` +
+ `están asegurados a $ 250,000 por la FDIC en caso de que ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK} quiebre. Ver`,
fdicInsuranceBancorp2: 'para detalles.',
- contactExpensifyPayments: 'Comuníquese con Expensify Payments llamando al + 1833-400-0904, por correoelectrónico a',
+ contactExpensifyPayments: `Comuníquese con ${CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS} llamando al + 1833-400-0904, por correoelectrónico a`,
contactExpensifyPayments2: 'o inicie sesión en',
generalInformation: 'Para obtener información general sobre cuentas prepagas, visite',
generalInformation2: 'Si tiene una queja sobre una cuenta prepaga, llame al Consumer Financial Oficina de Protección al 1-855-411-2372 o visite',
diff --git a/src/languages/types.ts b/src/languages/types.ts
index 70bf2e4cae3d..52f2df8b3765 100644
--- a/src/languages/types.ts
+++ b/src/languages/types.ts
@@ -194,6 +194,8 @@ type FormattedMaxLengthParams = {formattedMaxLength: string};
type TagSelectionParams = {tagName: string};
+type WalletProgramParams = {walletProgram: string};
+
/* Translation Object types */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TranslationBaseValue = string | string[] | ((...args: any[]) => string);
@@ -307,4 +309,5 @@ export type {
RemovedTheRequestParams,
FormattedMaxLengthParams,
TagSelectionParams,
+ WalletProgramParams,
};
diff --git a/src/libs/ComposerUtils/debouncedSaveReportComment.js b/src/libs/ComposerUtils/debouncedSaveReportComment.js
deleted file mode 100644
index c39da78c2c3e..000000000000
--- a/src/libs/ComposerUtils/debouncedSaveReportComment.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import _ from 'underscore';
-import * as Report from '../actions/Report';
-
-/**
- * Save draft report comment. Debounced to happen at most once per second.
- * @param {String} reportID
- * @param {String} comment
- */
-const debouncedSaveReportComment = _.debounce((reportID, comment) => {
- Report.saveReportComment(reportID, comment || '');
-}, 1000);
-
-export default debouncedSaveReportComment;
diff --git a/src/libs/ComposerUtils/debouncedSaveReportComment.ts b/src/libs/ComposerUtils/debouncedSaveReportComment.ts
new file mode 100644
index 000000000000..e449245edc52
--- /dev/null
+++ b/src/libs/ComposerUtils/debouncedSaveReportComment.ts
@@ -0,0 +1,11 @@
+import debounce from 'lodash/debounce';
+import * as Report from '../actions/Report';
+
+/**
+ * Save draft report comment. Debounced to happen at most once per second.
+ */
+const debouncedSaveReportComment = debounce((reportID: string, comment = '') => {
+ Report.saveReportComment(reportID, comment);
+}, 1000);
+
+export default debouncedSaveReportComment;
diff --git a/src/libs/ComposerUtils/getDraftComment.js b/src/libs/ComposerUtils/getDraftComment.ts
similarity index 75%
rename from src/libs/ComposerUtils/getDraftComment.js
rename to src/libs/ComposerUtils/getDraftComment.ts
index 854df1ac65ee..ac3d2f3d09be 100644
--- a/src/libs/ComposerUtils/getDraftComment.js
+++ b/src/libs/ComposerUtils/getDraftComment.ts
@@ -1,7 +1,7 @@
-import Onyx from 'react-native-onyx';
+import Onyx, {OnyxEntry} from 'react-native-onyx';
import ONYXKEYS from '../../ONYXKEYS';
-const draftCommentMap = {};
+const draftCommentMap: Record> = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT,
callback: (value, key) => {
@@ -18,9 +18,7 @@ Onyx.connect({
* Returns a draft comment from the onyx collection.
* Note: You should use the HOCs/hooks to get onyx data, instead of using this directly.
* A valid use case to use this is if the value is only needed once for an initial value.
- * @param {String} reportID
- * @returns {String|undefined}
*/
-export default function getDraftComment(reportID) {
+export default function getDraftComment(reportID: string): OnyxEntry {
return draftCommentMap[reportID];
}
diff --git a/src/libs/ComposerUtils/getNumberOfLines/index.native.js b/src/libs/ComposerUtils/getNumberOfLines/index.native.js
deleted file mode 100644
index ff4a1c6d74b1..000000000000
--- a/src/libs/ComposerUtils/getNumberOfLines/index.native.js
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * Get the current number of lines in the composer
- *
- * @param {Number} lineHeight
- * @param {Number} paddingTopAndBottom
- * @param {Number} scrollHeight
- *
- * @returns {Number}
- */
-function getNumberOfLines(lineHeight, paddingTopAndBottom, scrollHeight) {
- return Math.ceil((scrollHeight - paddingTopAndBottom) / lineHeight);
-}
-
-export default getNumberOfLines;
diff --git a/src/libs/ComposerUtils/getNumberOfLines/index.native.ts b/src/libs/ComposerUtils/getNumberOfLines/index.native.ts
new file mode 100644
index 000000000000..9a7340b9a035
--- /dev/null
+++ b/src/libs/ComposerUtils/getNumberOfLines/index.native.ts
@@ -0,0 +1,8 @@
+import GetNumberOfLines from './types';
+
+/**
+ * Get the current number of lines in the composer
+ */
+const getNumberOfLines: GetNumberOfLines = (lineHeight, paddingTopAndBottom, scrollHeight) => Math.ceil((scrollHeight - paddingTopAndBottom) / lineHeight);
+
+export default getNumberOfLines;
diff --git a/src/libs/ComposerUtils/getNumberOfLines/index.js b/src/libs/ComposerUtils/getNumberOfLines/index.ts
similarity index 55%
rename from src/libs/ComposerUtils/getNumberOfLines/index.js
rename to src/libs/ComposerUtils/getNumberOfLines/index.ts
index a469da7516bb..cf85b45443d5 100644
--- a/src/libs/ComposerUtils/getNumberOfLines/index.js
+++ b/src/libs/ComposerUtils/getNumberOfLines/index.ts
@@ -1,17 +1,12 @@
+import GetNumberOfLines from './types';
+
/**
* Get the current number of lines in the composer
- *
- * @param {Number} maxLines
- * @param {Number} lineHeight
- * @param {Number} paddingTopAndBottom
- * @param {Number} scrollHeight
- *
- * @returns {Number}
*/
-function getNumberOfLines(maxLines, lineHeight, paddingTopAndBottom, scrollHeight) {
+const getNumberOfLines: GetNumberOfLines = (lineHeight, paddingTopAndBottom, scrollHeight, maxLines = 0) => {
let newNumberOfLines = Math.ceil((scrollHeight - paddingTopAndBottom) / lineHeight);
newNumberOfLines = maxLines <= 0 ? newNumberOfLines : Math.min(newNumberOfLines, maxLines);
return newNumberOfLines;
-}
+};
export default getNumberOfLines;
diff --git a/src/libs/ComposerUtils/getNumberOfLines/types.ts b/src/libs/ComposerUtils/getNumberOfLines/types.ts
new file mode 100644
index 000000000000..67bb790f726b
--- /dev/null
+++ b/src/libs/ComposerUtils/getNumberOfLines/types.ts
@@ -0,0 +1,3 @@
+type GetNumberOfLines = (lineHeight: number, paddingTopAndBottom: number, scrollHeight: number, maxLines?: number) => number;
+
+export default GetNumberOfLines;
diff --git a/src/libs/ComposerUtils/index.js b/src/libs/ComposerUtils/index.ts
similarity index 69%
rename from src/libs/ComposerUtils/index.js
rename to src/libs/ComposerUtils/index.ts
index dfe6cf446809..5e2a42fc65dd 100644
--- a/src/libs/ComposerUtils/index.js
+++ b/src/libs/ComposerUtils/index.ts
@@ -2,24 +2,22 @@ import getNumberOfLines from './getNumberOfLines';
import updateNumberOfLines from './updateNumberOfLines';
import * as DeviceCapabilities from '../DeviceCapabilities';
+type Selection = {
+ start: number;
+ end: number;
+};
+
/**
* Replace substring between selection with a text.
- * @param {String} text
- * @param {Object} selection
- * @param {String} textToInsert
- * @returns {String}
*/
-function insertText(text, selection, textToInsert) {
+function insertText(text: string, selection: Selection, textToInsert: string): string {
return text.slice(0, selection.start) + textToInsert + text.slice(selection.end, text.length);
}
/**
* Check whether we can skip trigger hotkeys on some specific devices.
- * @param {Boolean} isSmallScreenWidth
- * @param {Boolean} isKeyboardShown
- * @returns {Boolean}
*/
-function canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown) {
+function canSkipTriggerHotkeys(isSmallScreenWidth: boolean, isKeyboardShown: boolean): boolean {
// Do not trigger actions for mobileWeb or native clients that have the keyboard open
// because for those devices, we want the return key to insert newlines rather than submit the form
return (isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen()) || isKeyboardShown;
@@ -30,11 +28,9 @@ function canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown) {
* The common suffix is the number of characters shared by both strings
* at the end (suffix) until a mismatch is encountered.
*
- * @param {string} str1
- * @param {string} str2
- * @returns {number} The length of the common suffix between the strings.
+ * @returns The length of the common suffix between the strings.
*/
-function getCommonSuffixLength(str1, str2) {
+function getCommonSuffixLength(str1: string, str2: string): number {
let i = 0;
while (str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) {
i++;
diff --git a/src/libs/ComposerUtils/types.ts b/src/libs/ComposerUtils/types.ts
new file mode 100644
index 000000000000..a417d951ff51
--- /dev/null
+++ b/src/libs/ComposerUtils/types.ts
@@ -0,0 +1,6 @@
+type ComposerProps = {
+ isFullComposerAvailable: boolean;
+ setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void;
+};
+
+export default ComposerProps;
diff --git a/src/libs/ComposerUtils/updateIsFullComposerAvailable.js b/src/libs/ComposerUtils/updateIsFullComposerAvailable.ts
similarity index 66%
rename from src/libs/ComposerUtils/updateIsFullComposerAvailable.js
rename to src/libs/ComposerUtils/updateIsFullComposerAvailable.ts
index 00b12d1742e3..5d73619482db 100644
--- a/src/libs/ComposerUtils/updateIsFullComposerAvailable.js
+++ b/src/libs/ComposerUtils/updateIsFullComposerAvailable.ts
@@ -1,11 +1,11 @@
import CONST from '../../CONST';
+import ComposerProps from './types';
/**
* Update isFullComposerAvailable if needed
- * @param {Object} props
- * @param {Number} numberOfLines The number of lines in the text input
+ * @param numberOfLines The number of lines in the text input
*/
-function updateIsFullComposerAvailable(props, numberOfLines) {
+function updateIsFullComposerAvailable(props: ComposerProps, numberOfLines: number) {
const isFullComposerAvailable = numberOfLines >= CONST.COMPOSER.FULL_COMPOSER_MIN_LINES;
if (isFullComposerAvailable !== props.isFullComposerAvailable) {
props.setIsFullComposerAvailable(isFullComposerAvailable);
diff --git a/src/libs/ComposerUtils/updateNumberOfLines/index.js b/src/libs/ComposerUtils/updateNumberOfLines/index.js
deleted file mode 100644
index ff8b4c56321a..000000000000
--- a/src/libs/ComposerUtils/updateNumberOfLines/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export default {};
diff --git a/src/libs/ComposerUtils/updateNumberOfLines/index.native.js b/src/libs/ComposerUtils/updateNumberOfLines/index.native.ts
similarity index 77%
rename from src/libs/ComposerUtils/updateNumberOfLines/index.native.js
rename to src/libs/ComposerUtils/updateNumberOfLines/index.native.ts
index 5a13ae670d81..b22135b4f767 100644
--- a/src/libs/ComposerUtils/updateNumberOfLines/index.native.js
+++ b/src/libs/ComposerUtils/updateNumberOfLines/index.native.ts
@@ -1,23 +1,21 @@
-import lodashGet from 'lodash/get';
import styles from '../../../styles/styles';
import updateIsFullComposerAvailable from '../updateIsFullComposerAvailable';
import getNumberOfLines from '../getNumberOfLines';
+import UpdateNumberOfLines from './types';
/**
* Check the current scrollHeight of the textarea (minus any padding) and
* divide by line height to get the total number of rows for the textarea.
- * @param {Object} props
- * @param {Event} e
*/
-function updateNumberOfLines(props, e) {
+const updateNumberOfLines: UpdateNumberOfLines = (props, event) => {
const lineHeight = styles.textInputCompose.lineHeight;
const paddingTopAndBottom = styles.textInputComposeSpacing.paddingVertical * 2;
- const inputHeight = lodashGet(e, 'nativeEvent.contentSize.height', null);
+ const inputHeight = event?.nativeEvent?.contentSize?.height ?? null;
if (!inputHeight) {
return;
}
const numberOfLines = getNumberOfLines(lineHeight, paddingTopAndBottom, inputHeight);
updateIsFullComposerAvailable(props, numberOfLines);
-}
+};
export default updateNumberOfLines;
diff --git a/src/libs/ComposerUtils/updateNumberOfLines/index.ts b/src/libs/ComposerUtils/updateNumberOfLines/index.ts
new file mode 100644
index 000000000000..91a9c9c0f102
--- /dev/null
+++ b/src/libs/ComposerUtils/updateNumberOfLines/index.ts
@@ -0,0 +1,5 @@
+import UpdateNumberOfLines from './types';
+
+const updateNumberOfLines: UpdateNumberOfLines = () => {};
+
+export default updateNumberOfLines;
diff --git a/src/libs/ComposerUtils/updateNumberOfLines/types.ts b/src/libs/ComposerUtils/updateNumberOfLines/types.ts
new file mode 100644
index 000000000000..c0650be25433
--- /dev/null
+++ b/src/libs/ComposerUtils/updateNumberOfLines/types.ts
@@ -0,0 +1,6 @@
+import {NativeSyntheticEvent, TextInputContentSizeChangeEventData} from 'react-native';
+import ComposerProps from '../types';
+
+type UpdateNumberOfLines = (props: ComposerProps, event: NativeSyntheticEvent) => void;
+
+export default UpdateNumberOfLines;
diff --git a/src/libs/DateUtils.js b/src/libs/DateUtils.js
index a9ec4a3fd35a..c83f59e66ef2 100644
--- a/src/libs/DateUtils.js
+++ b/src/libs/DateUtils.js
@@ -46,6 +46,12 @@ Onyx.connect({
},
});
+let networkTimeSkew = 0;
+Onyx.connect({
+ key: ONYXKEYS.NETWORK,
+ callback: (val) => (networkTimeSkew = lodashGet(val, 'timeSkew', 0)),
+});
+
/**
* Gets the locale string and setting default locale for date-fns
*
@@ -307,6 +313,15 @@ function getDBTime(timestamp = '') {
return datetime.toISOString().replace('T', ' ').replace('Z', '');
}
+/**
+ * Returns the current time plus skew in milliseconds in the format expected by the database
+ *
+ * @returns {String}
+ */
+function getDBTimeWithSkew() {
+ return getDBTime(new Date().valueOf() + networkTimeSkew);
+}
+
/**
* @param {String} dateTime
* @param {Number} milliseconds
@@ -383,6 +398,7 @@ const DateUtils = {
setTimezoneUpdated,
getMicroseconds,
getDBTime,
+ getDBTimeWithSkew,
subtractMillisecondsFromDateTime,
getDateStringFromISOTimestamp,
getStatusUntilDate,
diff --git a/src/libs/E2E/API.mock.js b/src/libs/E2E/API.mock.js
index 501108025979..47f445f72222 100644
--- a/src/libs/E2E/API.mock.js
+++ b/src/libs/E2E/API.mock.js
@@ -19,6 +19,7 @@ const mocks = {
BeginSignIn: mockBeginSignin,
SigninUser: mockSigninUser,
OpenApp: mockOpenApp,
+ ReconnectApp: mockOpenApp,
OpenReport: mockOpenReport,
AuthenticatePusher: mockAuthenticatePusher,
};
diff --git a/src/libs/E2E/reactNativeLaunchingTest.js b/src/libs/E2E/reactNativeLaunchingTest.js
index 869f5d1f1f1a..13183c1044db 100644
--- a/src/libs/E2E/reactNativeLaunchingTest.js
+++ b/src/libs/E2E/reactNativeLaunchingTest.js
@@ -6,11 +6,7 @@
*/
import Performance from '../Performance';
-
-// start the usual app
-Performance.markStart('regularAppStart');
-import '../../../index';
-Performance.markEnd('regularAppStart');
+import * as Metrics from '../Metrics';
import E2EConfig from '../../../tests/e2e/config';
import E2EClient from './client';
@@ -19,6 +15,11 @@ console.debug('==========================');
console.debug('==== Running e2e test ====');
console.debug('==========================');
+// Check if the performance module is available
+if (!Metrics.canCapturePerformanceMetrics()) {
+ throw new Error('Performance module not available! Please set CAPTURE_METRICS=true in your environment file!');
+}
+
// import your test here, define its name and config first in e2e/config.js
const tests = {
[E2EConfig.TEST_NAMES.AppStartTime]: require('./tests/appStartTimeTest.e2e').default,
@@ -36,20 +37,33 @@ const appReady = new Promise((resolve) => {
});
});
-E2EClient.getTestConfig().then((config) => {
- const test = tests[config.name];
- if (!test) {
- // instead of throwing, report the error to the server, which is better for DX
- return E2EClient.submitTestResults({
- name: config.name,
- error: `Test '${config.name}' not found`,
- });
- }
- console.debug(`[E2E] Configured for test ${config.name}. Waiting for app to become ready`);
-
- appReady.then(() => {
- console.debug('[E2E] App is ready, running test…');
- Performance.measureFailSafe('appStartedToReady', 'regularAppStart');
- test();
+E2EClient.getTestConfig()
+ .then((config) => {
+ const test = tests[config.name];
+ if (!test) {
+ // instead of throwing, report the error to the server, which is better for DX
+ return E2EClient.submitTestResults({
+ name: config.name,
+ error: `Test '${config.name}' not found`,
+ });
+ }
+
+ console.debug(`[E2E] Configured for test ${config.name}. Waiting for app to become ready`);
+ appReady
+ .then(() => {
+ console.debug('[E2E] App is ready, running test…');
+ Performance.measureFailSafe('appStartedToReady', 'regularAppStart');
+ test();
+ })
+ .catch((error) => {
+ console.error('[E2E] Error while waiting for app to become ready', error);
+ });
+ })
+ .catch((error) => {
+ console.error("[E2E] Error while running test. Couldn't get test config!", error);
});
-});
+
+// start the usual app
+Performance.markStart('regularAppStart');
+import '../../../index';
+Performance.markEnd('regularAppStart');
diff --git a/src/libs/E2E/tests/openSearchPageTest.e2e.js b/src/libs/E2E/tests/openSearchPageTest.e2e.js
index 2f0f72f35bdd..3b2d91322cf0 100644
--- a/src/libs/E2E/tests/openSearchPageTest.e2e.js
+++ b/src/libs/E2E/tests/openSearchPageTest.e2e.js
@@ -7,24 +7,41 @@ import CONST from '../../../CONST';
const test = () => {
// check for login (if already logged in the action will simply resolve)
+ console.debug('[E2E] Logging in for search');
+
E2ELogin().then((neededLogin) => {
if (neededLogin) {
// we don't want to submit the first login to the results
return E2EClient.submitTestDone();
}
+ console.debug('[E2E] Logged in, getting search metrics and submitting them…');
+
Performance.subscribeToMeasurements((entry) => {
+ if (entry.name === CONST.TIMING.SIDEBAR_LOADED) {
+ console.debug(`[E2E] Sidebar loaded, navigating to search route…`);
+ Navigation.navigate(ROUTES.SEARCH);
+ return;
+ }
+
+ console.debug(`[E2E] Entry: ${JSON.stringify(entry)}`);
if (entry.name !== CONST.TIMING.SEARCH_RENDER) {
return;
}
+ console.debug(`[E2E] Submitting!`);
E2EClient.submitTestResults({
name: 'Open Search Page TTI',
duration: entry.duration,
- }).then(E2EClient.submitTestDone);
+ })
+ .then(() => {
+ console.debug('[E2E] Done with search, exiting…');
+ E2EClient.submitTestDone();
+ })
+ .catch((err) => {
+ console.debug('[E2E] Error while submitting test results:', err);
+ });
});
-
- Navigation.navigate(ROUTES.SEARCH);
});
};
diff --git a/src/libs/HttpUtils.js b/src/libs/HttpUtils.js
index 5a8185a03038..465d22760837 100644
--- a/src/libs/HttpUtils.js
+++ b/src/libs/HttpUtils.js
@@ -5,6 +5,7 @@ import ONYXKEYS from '../ONYXKEYS';
import HttpsError from './Errors/HttpsError';
import * as ApiUtils from './ApiUtils';
import alert from '../components/Alert';
+import * as NetworkActions from './actions/Network';
let shouldFailAllRequests = false;
let shouldForceOffline = false;
@@ -22,6 +23,16 @@ Onyx.connect({
// We use the AbortController API to terminate pending request in `cancelPendingRequests`
let cancellationController = new AbortController();
+/**
+ * The API commands that require the skew calculation
+ */
+const addSkewList = ['OpenReport', 'ReconnectApp', 'OpenApp'];
+
+/**
+ * Regex to get API command from the command
+ */
+const regex = /[?&]command=([^&]+)/;
+
/**
* Send an HTTP request, and attempt to resolve the json response.
* If there is a network error, we'll set the application offline.
@@ -33,12 +44,25 @@ let cancellationController = new AbortController();
* @returns {Promise}
*/
function processHTTPRequest(url, method = 'get', body = null, canCancel = true) {
+ const startTime = new Date().valueOf();
+
return fetch(url, {
// We hook requests to the same Controller signal, so we can cancel them all at once
signal: canCancel ? cancellationController.signal : undefined,
method,
body,
})
+ .then((response) => {
+ const match = url.match(regex)[1];
+ if (addSkewList.includes(match) && response.headers) {
+ const serverTime = new Date(response.headers.get('Date')).valueOf();
+ const endTime = new Date().valueOf();
+ const latency = (endTime - startTime) / 2;
+ const skew = serverTime - startTime + latency;
+ NetworkActions.setTimeSkew(skew);
+ }
+ return response;
+ })
.then((response) => {
// Test mode where all requests will succeed in the server, but fail to return a response
if (shouldFailAllRequests || shouldForceOffline) {
diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts
index b8a6a3da303f..c41fe73947d1 100644
--- a/src/libs/MoneyRequestUtils.ts
+++ b/src/libs/MoneyRequestUtils.ts
@@ -15,6 +15,13 @@ function stripSpacesFromAmount(amount: string): string {
return amount.replace(/\s+/g, '');
}
+/**
+ * Strip decimals from the amount
+ */
+function stripDecimalsFromAmount(amount: string): string {
+ return amount.replace(/\.\d*$/, '');
+}
+
/**
* Adds a leading zero to the amount if user entered just the decimal separator
*
@@ -42,8 +49,12 @@ function calculateAmountLength(amount: string): number {
/**
* Check if amount is a decimal up to 3 digits
*/
-function validateAmount(amount: string): boolean {
- const decimalNumberRegex = new RegExp(/^\d+(,\d+)*(\.\d{0,2})?$/, 'i');
+function validateAmount(amount: string, decimals: number): boolean {
+ const regexString =
+ decimals === 0
+ ? `^\\d+(,\\d+)*$` // Don't allow decimal point if decimals === 0
+ : `^\\d+(,\\d+)*(\\.\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point
+ const decimalNumberRegex = new RegExp(regexString, 'i');
return amount === '' || (decimalNumberRegex.test(amount) && calculateAmountLength(amount) <= CONST.IOU.AMOUNT_MAX_LENGTH);
}
@@ -78,4 +89,4 @@ function isScanRequest(selectedTab: ValueOf): boolean {
return selectedTab === CONST.TAB.SCAN;
}
-export {stripCommaFromAmount, stripSpacesFromAmount, addLeadingZero, validateAmount, replaceAllDigits, isDistanceRequest, isScanRequest};
+export {stripCommaFromAmount, stripDecimalsFromAmount, stripSpacesFromAmount, addLeadingZero, validateAmount, replaceAllDigits, isDistanceRequest, isScanRequest};
diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js
index dc4f35a59cba..de6162685079 100644
--- a/src/libs/Navigation/Navigation.js
+++ b/src/libs/Navigation/Navigation.js
@@ -10,6 +10,7 @@ import linkingConfig from './linkingConfig';
import navigationRef from './navigationRef';
import NAVIGATORS from '../../NAVIGATORS';
import originalGetTopmostReportId from './getTopmostReportId';
+import originalGetTopmostReportActionId from './getTopmostReportActionID';
import getStateFromPath from './getStateFromPath';
import SCREENS from '../../SCREENS';
import CONST from '../../CONST';
@@ -46,6 +47,9 @@ function canNavigate(methodName, params = {}) {
// Re-exporting the getTopmostReportId here to fill in default value for state. The getTopmostReportId isn't defined in this file to avoid cyclic dependencies.
const getTopmostReportId = (state = navigationRef.getState()) => originalGetTopmostReportId(state);
+// Re-exporting the getTopmostReportActionID here to fill in default value for state. The getTopmostReportActionID isn't defined in this file to avoid cyclic dependencies.
+const getTopmostReportActionId = (state = navigationRef.getState()) => originalGetTopmostReportActionId(state);
+
/**
* Method for finding on which index in stack we are.
* @param {Object} route
@@ -268,6 +272,7 @@ export default {
setIsNavigationReady,
getTopmostReportId,
getRouteNameFromStateEvent,
+ getTopmostReportActionId,
};
export {navigationRef};
diff --git a/src/libs/Navigation/getTopmostReportActionID.js b/src/libs/Navigation/getTopmostReportActionID.js
new file mode 100644
index 000000000000..a4480931cda0
--- /dev/null
+++ b/src/libs/Navigation/getTopmostReportActionID.js
@@ -0,0 +1,42 @@
+import lodashFindLast from 'lodash/findLast';
+import lodashGet from 'lodash/get';
+
+// This function is in a separate file than Navigation.js to avoid cyclic dependency.
+
+/**
+ * Find the last visited report screen in the navigation state and get the linked reportActionID of it.
+ *
+ * @param {Object} state - The react-navigation state
+ * @returns {String | undefined} - It's possible that there is no report screen
+ */
+function getTopmostReportActionID(state) {
+ if (!state) {
+ return;
+ }
+ const topmostCentralPane = lodashFindLast(state.routes, (route) => route.name === 'CentralPaneNavigator');
+
+ if (!topmostCentralPane) {
+ return;
+ }
+
+ const directReportActionIDParam = lodashGet(topmostCentralPane, 'params.params.reportActionID');
+
+ if (!topmostCentralPane.state && !directReportActionIDParam) {
+ return;
+ }
+
+ if (directReportActionIDParam) {
+ return directReportActionIDParam;
+ }
+
+ const topmostReport = lodashFindLast(topmostCentralPane.state.routes, (route) => route.name === 'Report');
+ if (!topmostReport) {
+ return;
+ }
+
+ const topmostReportActionID = lodashGet(topmostReport, 'params.reportActionID');
+
+ return topmostReportActionID;
+}
+
+export default getTopmostReportActionID;
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index e0f334ca36af..7832b9a180fe 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -386,6 +386,7 @@ function getLastMessageTextForReport(report) {
(reportAction, key) => ReportActionUtils.shouldReportActionBeVisible(reportAction, key) && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
);
let lastMessageTextFromReport = '';
+ const lastActionName = lodashGet(lastReportAction, 'actionName', '');
if (ReportUtils.isReportMessageAttachment({text: report.lastMessageText, html: report.lastMessageHtml, translationKey: report.lastMessageTranslationKey})) {
lastMessageTextFromReport = `[${Localize.translateLocal(report.lastMessageTranslationKey || 'common.attachment')}]`;
@@ -397,6 +398,12 @@ function getLastMessageTextForReport(report) {
} else if (ReportActionUtils.isModifiedExpenseAction(lastReportAction)) {
const properSchemaForModifiedExpenseMessage = ReportUtils.getModifiedExpenseMessage(lastReportAction);
lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForModifiedExpenseMessage, true);
+ } else if (
+ lastActionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED ||
+ lastActionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED ||
+ lastActionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELLED
+ ) {
+ lastMessageTextFromReport = lodashGet(lastReportAction, 'message[0].text', '');
} else {
lastMessageTextFromReport = report ? report.lastMessageText || '' : '';
@@ -1419,32 +1426,28 @@ function getShareDestinationOptions(
* Format personalDetails or userToInvite to be shown in the list
*
* @param {Object} member - personalDetails or userToInvite
- * @param {Boolean} isSelected - whether the item is selected
+ * @param {Object} config - keys to overwrite the default values
* @returns {Object}
*/
-function formatMemberForList(member, isSelected) {
+function formatMemberForList(member, config = {}) {
if (!member) {
return undefined;
}
- const avatarSource = lodashGet(member, 'participantsList[0].avatar', '') || lodashGet(member, 'avatar', '');
const accountID = lodashGet(member, 'accountID', '');
return {
text: lodashGet(member, 'text', '') || lodashGet(member, 'displayName', ''),
alternateText: lodashGet(member, 'alternateText', '') || lodashGet(member, 'login', ''),
keyForList: lodashGet(member, 'keyForList', '') || String(accountID),
- isSelected,
+ isSelected: false,
isDisabled: false,
accountID,
login: lodashGet(member, 'login', ''),
rightElement: null,
- avatar: {
- source: UserUtils.getAvatar(avatarSource, accountID),
- name: lodashGet(member, 'participantsList[0].login', '') || lodashGet(member, 'displayName', ''),
- type: 'avatar',
- },
+ icons: lodashGet(member, 'icons'),
pendingAction: lodashGet(member, 'pendingAction'),
+ ...config,
};
}
diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js
index 67c44784eeb2..42a308bbbe30 100644
--- a/src/libs/ReportActionsUtils.js
+++ b/src/libs/ReportActionsUtils.js
@@ -131,17 +131,6 @@ function getParentReportAction(report, allReportActionsParam = undefined) {
return lodashGet(allReportActionsParam || allReportActions, [report.parentReportID, report.parentReportActionID], {});
}
-/**
- * Find the reportAction having the given childReportID in parent report actions
- *
- * @param {String} childReportID
- * @param {String} parentReportID
- * @returns {Object}
- */
-function getParentReportActionInReport(childReportID, parentReportID) {
- return _.find(allReportActions[parentReportID], (reportAction) => reportAction && `${reportAction.childReportID}` === `${childReportID}`);
-}
-
/**
* Determines if the given report action is sent money report action by checking for 'pay' type and presence of IOUDetails object.
*
@@ -679,7 +668,6 @@ export {
getReportPreviewAction,
isCreatedTaskReportAction,
getParentReportAction,
- getParentReportActionInReport,
isTransactionThread,
isSentMoneyReportAction,
isDeletedParentAction,
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index 42e7e40fe62b..c03858cb15f3 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -1939,7 +1939,7 @@ function buildOptimisticAddCommentReportAction(text, file) {
],
automatic: false,
avatar: lodashGet(allPersonalDetails, [currentUserAccountID, 'avatar'], UserUtils.getDefaultAvatarURL(currentUserAccountID)),
- created: DateUtils.getDBTime(),
+ created: DateUtils.getDBTimeWithSkew(),
message: [
{
translationKey: isAttachment ? CONST.TRANSLATION_KEYS.ATTACHMENT : '',
@@ -2978,11 +2978,6 @@ function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, betas,
const isEmptyChat = !report.lastMessageText && !report.lastMessageTranslationKey && !lastVisibleMessage.lastMessageText && !lastVisibleMessage.lastMessageTranslationKey;
const canHideReport = shouldHideReport(report, currentReportId);
- // Hide only chat threads that haven't been commented on (other threads are actionable)
- if (isChatThread(report) && canHideReport && isEmptyChat) {
- return false;
- }
-
// Include reports if they are pinned
if (report.isPinned) {
return true;
@@ -3390,30 +3385,6 @@ function isReportDataReady() {
return !_.isEmpty(allReports) && _.some(_.keys(allReports), (key) => allReports[key].reportID);
}
-/**
- * Find the parent report action in assignee report for a task report
- * Returns an empty object if assignee report is the same as the share destination report
- *
- * @param {Object} taskReport
- * @returns {Object}
- */
-function getTaskParentReportActionIDInAssigneeReport(taskReport) {
- const assigneeChatReportID = lodashGet(getChatByParticipants(isReportManager(taskReport) ? [taskReport.ownerAccountID] : [taskReport.managerID]), 'reportID');
- if (!assigneeChatReportID || assigneeChatReportID === taskReport.parentReportID) {
- return {};
- }
-
- const clonedParentReportActionID = lodashGet(ReportActionsUtils.getParentReportActionInReport(taskReport.reportID, assigneeChatReportID), 'reportActionID');
- if (!clonedParentReportActionID) {
- return {};
- }
-
- return {
- reportID: assigneeChatReportID,
- reportActionID: clonedParentReportActionID,
- };
-}
-
/**
* Return the errors we have when creating a chat or a workspace room
* @param {Object} report
@@ -3798,7 +3769,6 @@ export {
getBankAccountRoute,
getParentReport,
getRootParentReport,
- getTaskParentReportActionIDInAssigneeReport,
getReportPreviewMessage,
getModifiedExpenseMessage,
shouldDisableWriteActions,
diff --git a/src/libs/TransactionUtils.js b/src/libs/TransactionUtils.js
deleted file mode 100644
index aff1068546d1..000000000000
--- a/src/libs/TransactionUtils.js
+++ /dev/null
@@ -1,461 +0,0 @@
-import Onyx from 'react-native-onyx';
-import {format, parseISO, isValid} from 'date-fns';
-import lodashGet from 'lodash/get';
-import _ from 'underscore';
-import CONST from '../CONST';
-import ONYXKEYS from '../ONYXKEYS';
-import DateUtils from './DateUtils';
-import * as NumberUtils from './NumberUtils';
-
-let allTransactions = {};
-Onyx.connect({
- key: ONYXKEYS.COLLECTION.TRANSACTION,
- waitForCollectionCallback: true,
- callback: (val) => {
- if (!val) {
- return;
- }
- allTransactions = _.pick(val, (transaction) => transaction);
- },
-});
-
-/**
- * Optimistically generate a transaction.
- *
- * @param {Number} amount – in cents
- * @param {String} currency
- * @param {String} reportID
- * @param {String} [comment]
- * @param {String} [created]
- * @param {String} [source]
- * @param {String} [originalTransactionID]
- * @param {String} [merchant]
- * @param {Object} [receipt]
- * @param {String} [filename]
- * @param {String} [existingTransactionID] When creating a distance request, an empty transaction has already been created with a transactionID. In that case, the transaction here needs to have it's transactionID match what was already generated.
- * @param {String} [category]
- * @param {String} [tag]
- * @param {Boolean} [billable]
- * @returns {Object}
- */
-function buildOptimisticTransaction(
- amount,
- currency,
- reportID,
- comment = '',
- created = '',
- source = '',
- originalTransactionID = '',
- merchant = '',
- receipt = {},
- filename = '',
- existingTransactionID = null,
- category = '',
- tag = '',
- billable = false,
-) {
- // transactionIDs are random, positive, 64-bit numeric strings.
- // Because JS can only handle 53-bit numbers, transactionIDs are strings in the front-end (just like reportActionID)
- const transactionID = existingTransactionID || NumberUtils.rand64();
-
- const commentJSON = {comment};
- if (source) {
- commentJSON.source = source;
- }
- if (originalTransactionID) {
- commentJSON.originalTransactionID = originalTransactionID;
- }
-
- // For the SmartScan to run successfully, we need to pass the merchant field empty to the API
- const defaultMerchant = _.isEmpty(receipt) ? CONST.TRANSACTION.DEFAULT_MERCHANT : '';
-
- return {
- transactionID,
- amount,
- currency,
- reportID,
- comment: commentJSON,
- merchant: merchant || defaultMerchant,
- created: created || DateUtils.getDBTime(),
- pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
- receipt,
- filename,
- category,
- tag,
- billable,
- };
-}
-
-/**
- * @param {Object|null} transaction
- * @returns {Boolean}
- */
-function hasReceipt(transaction) {
- return lodashGet(transaction, 'receipt.state', '') !== '';
-}
-
-/**
- * @param {Object} transaction
- * @returns {Boolean}
- */
-function areRequiredFieldsEmpty(transaction) {
- return (
- transaction.modifiedMerchant === CONST.TRANSACTION.UNKNOWN_MERCHANT ||
- transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT ||
- (transaction.modifiedMerchant === '' &&
- (transaction.merchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transaction.merchant === '' || transaction.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT)) ||
- (transaction.modifiedAmount === 0 && transaction.amount === 0) ||
- (transaction.modifiedCreated === '' && transaction.created === '')
- );
-}
-
-/**
- * Given the edit made to the money request, return an updated transaction object.
- *
- * @param {Object} transaction
- * @param {Object} transactionChanges
- * @param {Object} isFromExpenseReport
- * @returns {Object}
- */
-function getUpdatedTransaction(transaction, transactionChanges, isFromExpenseReport) {
- // Only changing the first level fields so no need for deep clone now
- const updatedTransaction = _.clone(transaction);
- let shouldStopSmartscan = false;
-
- // The comment property does not have its modifiedComment counterpart
- if (_.has(transactionChanges, 'comment')) {
- updatedTransaction.comment = {
- ...updatedTransaction.comment,
- comment: transactionChanges.comment,
- };
- }
- if (_.has(transactionChanges, 'created')) {
- updatedTransaction.modifiedCreated = transactionChanges.created;
- shouldStopSmartscan = true;
- }
- if (_.has(transactionChanges, 'amount')) {
- updatedTransaction.modifiedAmount = isFromExpenseReport ? -transactionChanges.amount : transactionChanges.amount;
- shouldStopSmartscan = true;
- }
- if (_.has(transactionChanges, 'currency')) {
- updatedTransaction.modifiedCurrency = transactionChanges.currency;
- shouldStopSmartscan = true;
- }
-
- if (_.has(transactionChanges, 'merchant')) {
- updatedTransaction.modifiedMerchant = transactionChanges.merchant;
- shouldStopSmartscan = true;
- }
-
- if (_.has(transactionChanges, 'waypoints')) {
- updatedTransaction.modifiedWaypoints = transactionChanges.waypoints;
- shouldStopSmartscan = true;
- }
-
- if (_.has(transactionChanges, 'billable')) {
- updatedTransaction.billable = transactionChanges.billable;
- }
-
- if (_.has(transactionChanges, 'category')) {
- updatedTransaction.category = transactionChanges.category;
- }
-
- if (_.has(transactionChanges, 'tag')) {
- updatedTransaction.tag = transactionChanges.tag;
- }
-
- if (shouldStopSmartscan && _.has(transaction, 'receipt') && !_.isEmpty(transaction.receipt) && lodashGet(transaction, 'receipt.state') !== CONST.IOU.RECEIPT_STATE.OPEN) {
- updatedTransaction.receipt.state = CONST.IOU.RECEIPT_STATE.OPEN;
- }
-
- updatedTransaction.pendingFields = {
- ...(_.has(transactionChanges, 'comment') && {comment: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
- ...(_.has(transactionChanges, 'created') && {created: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
- ...(_.has(transactionChanges, 'amount') && {amount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
- ...(_.has(transactionChanges, 'currency') && {currency: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
- ...(_.has(transactionChanges, 'merchant') && {merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
- ...(_.has(transactionChanges, 'waypoints') && {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
- ...(_.has(transactionChanges, 'billable') && {billable: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
- ...(_.has(transactionChanges, 'category') && {category: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
- ...(_.has(transactionChanges, 'tag') && {tag: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
- };
-
- return updatedTransaction;
-}
-
-/**
- * Retrieve the particular transaction object given its ID.
- *
- * @param {String} transactionID
- * @returns {Object}
- * @deprecated Use withOnyx() or Onyx.connect() instead
- */
-function getTransaction(transactionID) {
- return lodashGet(allTransactions, `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {});
-}
-
-/**
- * Return the comment field (referred to as description in the App) from the transaction.
- * The comment does not have its modifiedComment counterpart.
- *
- * @param {Object} transaction
- * @returns {String}
- */
-function getDescription(transaction) {
- return lodashGet(transaction, 'comment.comment', '');
-}
-
-/**
- * Return the amount field from the transaction, return the modifiedAmount if present.
- *
- * @param {Object} transaction
- * @param {Boolean} isFromExpenseReport
- * @returns {Number}
- */
-function getAmount(transaction, isFromExpenseReport) {
- // IOU requests cannot have negative values but they can be stored as negative values, let's return absolute value
- if (!isFromExpenseReport) {
- const amount = lodashGet(transaction, 'modifiedAmount', 0);
- if (amount) {
- return Math.abs(amount);
- }
- return Math.abs(lodashGet(transaction, 'amount', 0));
- }
-
- // Expense report case:
- // The amounts are stored using an opposite sign and negative values can be set,
- // we need to return an opposite sign than is saved in the transaction object
- let amount = lodashGet(transaction, 'modifiedAmount', 0);
- if (amount) {
- return -amount;
- }
-
- // To avoid -0 being shown, lets only change the sign if the value is other than 0.
- amount = lodashGet(transaction, 'amount', 0);
- return amount ? -amount : 0;
-}
-
-/**
- * Return the currency field from the transaction, return the modifiedCurrency if present.
- *
- * @param {Object} transaction
- * @returns {String}
- */
-function getCurrency(transaction) {
- const currency = lodashGet(transaction, 'modifiedCurrency', '');
- if (currency) {
- return currency;
- }
- return lodashGet(transaction, 'currency', CONST.CURRENCY.USD);
-}
-
-/**
- * Return the merchant field from the transaction, return the modifiedMerchant if present.
- *
- * @param {Object} transaction
- * @returns {String}
- */
-function getMerchant(transaction) {
- return lodashGet(transaction, 'modifiedMerchant', null) || lodashGet(transaction, 'merchant', '');
-}
-
-/**
- * Return the waypoints field from the transaction, return the modifiedWaypoints if present.
- *
- * @param {Object} transaction
- * @returns {String}
- */
-function getWaypoints(transaction) {
- return lodashGet(transaction, 'modifiedWaypoints', null) || lodashGet(transaction, ['comment', 'waypoints']);
-}
-
-/**
- * Return the category from the transaction. This "category" field has no "modified" complement.
- *
- * @param {Object} transaction
- * @return {String}
- */
-function getCategory(transaction) {
- return lodashGet(transaction, 'category', '');
-}
-
-/**
- * Return the billable field from the transaction. This "billable" field has no "modified" complement.
- *
- * @param {Object} transaction
- * @return {Boolean}
- */
-function getBillable(transaction) {
- return lodashGet(transaction, 'billable', false);
-}
-
-/**
- * Return the tag from the transaction. This "tag" field has no "modified" complement.
- *
- * @param {Object} transaction
- * @return {String}
- */
-function getTag(transaction) {
- return lodashGet(transaction, 'tag', '');
-}
-
-/**
- * Return the created field from the transaction, return the modifiedCreated if present.
- *
- * @param {Object} transaction
- * @returns {String}
- */
-function getCreated(transaction) {
- const created = lodashGet(transaction, 'modifiedCreated', '') || lodashGet(transaction, 'created', '');
- const createdDate = parseISO(created);
- if (isValid(createdDate)) {
- return format(createdDate, CONST.DATE.FNS_FORMAT_STRING);
- }
-
- return '';
-}
-
-/*
- * @param {Object} transaction
- * @param {Object} transaction.comment
- * @param {String} transaction.comment.type
- * @param {Object} [transaction.comment.customUnit]
- * @param {String} [transaction.comment.customUnit.name]
- * @returns {Boolean}
- */
-function isDistanceRequest(transaction) {
- const type = lodashGet(transaction, 'comment.type');
- const customUnitName = lodashGet(transaction, 'comment.customUnit.name');
- return type === CONST.TRANSACTION.TYPE.CUSTOM_UNIT && customUnitName === CONST.CUSTOM_UNITS.NAME_DISTANCE;
-}
-
-function isReceiptBeingScanned(transaction) {
- return _.contains([CONST.IOU.RECEIPT_STATE.SCANREADY, CONST.IOU.RECEIPT_STATE.SCANNING], transaction.receipt.state);
-}
-
-/**
- * Check if the transaction has a non-smartscanning receipt and is missing required fields
- *
- * @param {Object} transaction
- * @returns {Boolean}
- */
-function hasMissingSmartscanFields(transaction) {
- return hasReceipt(transaction) && !isDistanceRequest(transaction) && !isReceiptBeingScanned(transaction) && areRequiredFieldsEmpty(transaction);
-}
-
-/**
- * Check if the transaction has a defined route
- *
- * @param {Object} transaction
- * @returns {Boolean}
- */
-function hasRoute(transaction) {
- return !!lodashGet(transaction, 'routes.route0.geometry.coordinates');
-}
-
-/**
- * Get the transactions related to a report preview with receipts
- * Get the details linked to the IOU reportAction
- *
- * @param {Object} reportAction
- * @returns {Object}
- * @deprecated Use Onyx.connect() or withOnyx() instead
- */
-function getLinkedTransaction(reportAction = {}) {
- const transactionID = lodashGet(reportAction, ['originalMessage', 'IOUTransactionID'], '');
- return allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] || {};
-}
-
-function getAllReportTransactions(reportID) {
- // `reportID` from the `/CreateDistanceRequest` endpoint return's number instead of string for created `transaction`.
- // For reference, https://github.com/Expensify/App/pull/26536#issuecomment-1703573277.
- // We will update this in a follow-up Issue. According to this comment: https://github.com/Expensify/App/pull/26536#issuecomment-1703591019.
- return _.filter(allTransactions, (transaction) => `${transaction.reportID}` === `${reportID}`);
-}
-
-/**
- * Checks if a waypoint has a valid address
- * @param {Object} waypoint
- * @returns {Boolean} Returns true if the address is valid
- */
-function waypointHasValidAddress(waypoint) {
- if (!waypoint || !waypoint.address || typeof waypoint.address !== 'string' || waypoint.address.trim() === '') {
- return false;
- }
- return true;
-}
-
-/**
- * Converts the key of a waypoint to its index
- * @param {String} key
- * @returns {Number} waypoint index
- */
-function getWaypointIndex(key) {
- return Number(key.replace('waypoint', ''));
-}
-
-/**
- * Filters the waypoints which are valid and returns those
- * @param {Object} waypoints
- * @param {Boolean} reArrangeIndexes
- * @returns {Object} validated waypoints
- */
-function getValidWaypoints(waypoints, reArrangeIndexes = false) {
- const sortedIndexes = _.map(_.keys(waypoints), (key) => getWaypointIndex(key)).sort();
- const waypointValues = _.map(sortedIndexes, (index) => waypoints[`waypoint${index}`]);
- // Ensure the number of waypoints is between 2 and 25
- if (waypointValues.length < 2 || waypointValues.length > 25) {
- return {};
- }
-
- let lastWaypointIndex = -1;
-
- const validWaypoints = _.reduce(
- waypointValues,
- (acc, currentWaypoint, index) => {
- const previousWaypoint = waypointValues[lastWaypointIndex];
- // Check if the waypoint has a valid address
- if (!waypointHasValidAddress(currentWaypoint)) {
- return acc;
- }
-
- // Check for adjacent waypoints with the same address
- if (previousWaypoint && currentWaypoint.address === previousWaypoint.address) {
- return acc;
- }
-
- const validatedWaypoints = {...acc, [`waypoint${reArrangeIndexes ? lastWaypointIndex + 1 : index}`]: currentWaypoint};
-
- lastWaypointIndex += 1;
-
- return validatedWaypoints;
- },
- {},
- );
- return validWaypoints;
-}
-
-export {
- buildOptimisticTransaction,
- getUpdatedTransaction,
- getTransaction,
- getDescription,
- getAmount,
- getCurrency,
- getMerchant,
- getCreated,
- getCategory,
- getBillable,
- getTag,
- getLinkedTransaction,
- getAllReportTransactions,
- hasReceipt,
- hasRoute,
- isReceiptBeingScanned,
- getValidWaypoints,
- isDistanceRequest,
- getWaypoints,
- hasMissingSmartscanFields,
- getWaypointIndex,
- waypointHasValidAddress,
-};
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
new file mode 100644
index 000000000000..4a9ab448546a
--- /dev/null
+++ b/src/libs/TransactionUtils.ts
@@ -0,0 +1,386 @@
+import Onyx, {OnyxCollection} from 'react-native-onyx';
+import {format, parseISO, isValid} from 'date-fns';
+import CONST from '../CONST';
+import ONYXKEYS from '../ONYXKEYS';
+import DateUtils from './DateUtils';
+import * as NumberUtils from './NumberUtils';
+import {RecentWaypoint, ReportAction, Transaction} from '../types/onyx';
+import {Receipt, Comment, WaypointCollection} from '../types/onyx/Transaction';
+
+type AdditionalTransactionChanges = {comment?: string; waypoints?: WaypointCollection};
+
+type TransactionChanges = Partial & AdditionalTransactionChanges;
+
+let allTransactions: OnyxCollection = {};
+
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.TRANSACTION,
+ waitForCollectionCallback: true,
+ callback: (value) => {
+ if (!value) {
+ return;
+ }
+ allTransactions = Object.fromEntries(Object.entries(value).filter(([, transaction]) => !!transaction));
+ },
+});
+
+/**
+ * Optimistically generate a transaction.
+ *
+ * @param amount – in cents
+ * @param [existingTransactionID] When creating a distance request, an empty transaction has already been created with a transactionID. In that case, the transaction here needs to have it's transactionID match what was already generated.
+ */
+function buildOptimisticTransaction(
+ amount: number,
+ currency: string,
+ reportID: string,
+ comment = '',
+ created = '',
+ source = '',
+ originalTransactionID = '',
+ merchant = '',
+ receipt: Receipt = {},
+ filename = '',
+ existingTransactionID: string | null = null,
+ category = '',
+ tag = '',
+ billable = false,
+): Transaction {
+ // transactionIDs are random, positive, 64-bit numeric strings.
+ // Because JS can only handle 53-bit numbers, transactionIDs are strings in the front-end (just like reportActionID)
+ const transactionID = existingTransactionID ?? NumberUtils.rand64();
+
+ const commentJSON: Comment = {comment};
+ if (source) {
+ commentJSON.source = source;
+ }
+ if (originalTransactionID) {
+ commentJSON.originalTransactionID = originalTransactionID;
+ }
+
+ // For the SmartScan to run successfully, we need to pass the merchant field empty to the API
+ const defaultMerchant = !receipt || Object.keys(receipt).length === 0 ? CONST.TRANSACTION.DEFAULT_MERCHANT : '';
+
+ return {
+ transactionID,
+ amount,
+ currency,
+ reportID,
+ comment: commentJSON,
+ merchant: merchant || defaultMerchant,
+ created: created || DateUtils.getDBTime(),
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ receipt,
+ filename,
+ category,
+ tag,
+ billable,
+ };
+}
+
+function hasReceipt(transaction: Transaction | undefined | null): boolean {
+ return !!transaction?.receipt?.state;
+}
+
+function areRequiredFieldsEmpty(transaction: Transaction): boolean {
+ return (
+ transaction.modifiedMerchant === CONST.TRANSACTION.UNKNOWN_MERCHANT ||
+ transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT ||
+ (transaction.modifiedMerchant === '' &&
+ (transaction.merchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transaction.merchant === '' || transaction.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT)) ||
+ (transaction.modifiedAmount === 0 && transaction.amount === 0) ||
+ (transaction.modifiedCreated === '' && transaction.created === '')
+ );
+}
+
+/**
+ * Given the edit made to the money request, return an updated transaction object.
+ */
+function getUpdatedTransaction(transaction: Transaction, transactionChanges: TransactionChanges, isFromExpenseReport: boolean): Transaction {
+ // Only changing the first level fields so no need for deep clone now
+ const updatedTransaction = {...transaction};
+ let shouldStopSmartscan = false;
+
+ // The comment property does not have its modifiedComment counterpart
+ if (Object.hasOwn(transactionChanges, 'comment')) {
+ updatedTransaction.comment = {
+ ...updatedTransaction.comment,
+ comment: transactionChanges.comment,
+ };
+ }
+ if (Object.hasOwn(transactionChanges, 'created')) {
+ updatedTransaction.modifiedCreated = transactionChanges.created;
+ shouldStopSmartscan = true;
+ }
+ if (Object.hasOwn(transactionChanges, 'amount') && typeof transactionChanges.amount === 'number') {
+ updatedTransaction.modifiedAmount = isFromExpenseReport ? -transactionChanges.amount : transactionChanges.amount;
+ shouldStopSmartscan = true;
+ }
+ if (Object.hasOwn(transactionChanges, 'currency')) {
+ updatedTransaction.modifiedCurrency = transactionChanges.currency;
+ shouldStopSmartscan = true;
+ }
+
+ if (Object.hasOwn(transactionChanges, 'merchant')) {
+ updatedTransaction.modifiedMerchant = transactionChanges.merchant;
+ shouldStopSmartscan = true;
+ }
+
+ if (Object.hasOwn(transactionChanges, 'waypoints')) {
+ updatedTransaction.modifiedWaypoints = transactionChanges.waypoints;
+ shouldStopSmartscan = true;
+ }
+
+ if (Object.hasOwn(transactionChanges, 'billable') && typeof transactionChanges.billable === 'boolean') {
+ updatedTransaction.billable = transactionChanges.billable;
+ }
+
+ if (Object.hasOwn(transactionChanges, 'category') && typeof transactionChanges.category === 'string') {
+ updatedTransaction.category = transactionChanges.category;
+ }
+
+ if (Object.hasOwn(transactionChanges, 'tag') && typeof transactionChanges.tag === 'string') {
+ updatedTransaction.tag = transactionChanges.tag;
+ }
+
+ if (shouldStopSmartscan && transaction?.receipt && Object.keys(transaction.receipt).length > 0 && transaction?.receipt?.state !== CONST.IOU.RECEIPT_STATE.OPEN) {
+ updatedTransaction.receipt.state = CONST.IOU.RECEIPT_STATE.OPEN;
+ }
+
+ updatedTransaction.pendingFields = {
+ ...(Object.hasOwn(transactionChanges, 'comment') && {comment: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
+ ...(Object.hasOwn(transactionChanges, 'created') && {created: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
+ ...(Object.hasOwn(transactionChanges, 'amount') && {amount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
+ ...(Object.hasOwn(transactionChanges, 'currency') && {currency: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
+ ...(Object.hasOwn(transactionChanges, 'merchant') && {merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
+ ...(Object.hasOwn(transactionChanges, 'waypoints') && {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
+ ...(Object.hasOwn(transactionChanges, 'billable') && {billable: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
+ ...(Object.hasOwn(transactionChanges, 'category') && {category: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
+ ...(Object.hasOwn(transactionChanges, 'tag') && {tag: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
+ };
+
+ return updatedTransaction;
+}
+
+/**
+ * Retrieve the particular transaction object given its ID.
+ *
+ * @deprecated Use withOnyx() or Onyx.connect() instead
+ */
+function getTransaction(transactionID: string): Transaction | Record {
+ return allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? {};
+}
+
+/**
+ * Return the comment field (referred to as description in the App) from the transaction.
+ * The comment does not have its modifiedComment counterpart.
+ */
+function getDescription(transaction: Transaction): string {
+ return transaction?.comment?.comment ?? '';
+}
+
+/**
+ * Return the amount field from the transaction, return the modifiedAmount if present.
+ */
+function getAmount(transaction: Transaction, isFromExpenseReport: boolean): number {
+ // IOU requests cannot have negative values but they can be stored as negative values, let's return absolute value
+ if (!isFromExpenseReport) {
+ const amount = transaction?.modifiedAmount ?? 0;
+ if (amount) {
+ return Math.abs(amount);
+ }
+ return Math.abs(transaction?.amount ?? 0);
+ }
+
+ // Expense report case:
+ // The amounts are stored using an opposite sign and negative values can be set,
+ // we need to return an opposite sign than is saved in the transaction object
+ let amount = transaction?.modifiedAmount ?? 0;
+ if (amount) {
+ return -amount;
+ }
+
+ // To avoid -0 being shown, lets only change the sign if the value is other than 0.
+ amount = transaction?.amount ?? 0;
+ return amount ? -amount : 0;
+}
+
+/**
+ * Return the currency field from the transaction, return the modifiedCurrency if present.
+ */
+function getCurrency(transaction: Transaction): string {
+ const currency = transaction?.modifiedCurrency ?? '';
+ if (currency) {
+ return currency;
+ }
+ return transaction?.currency ?? CONST.CURRENCY.USD;
+}
+
+/**
+ * Return the merchant field from the transaction, return the modifiedMerchant if present.
+ */
+function getMerchant(transaction: Transaction): string {
+ return transaction?.modifiedMerchant ?? transaction?.merchant ?? '';
+}
+
+/**
+ * Return the waypoints field from the transaction, return the modifiedWaypoints if present.
+ */
+function getWaypoints(transaction: Transaction): WaypointCollection | undefined {
+ return transaction?.modifiedWaypoints ?? transaction?.comment?.waypoints;
+}
+
+/**
+ * Return the category from the transaction. This "category" field has no "modified" complement.
+ */
+function getCategory(transaction: Transaction): string {
+ return transaction?.category ?? '';
+}
+
+/**
+ * Return the billable field from the transaction. This "billable" field has no "modified" complement.
+ */
+function getBillable(transaction: Transaction): boolean {
+ return transaction?.billable ?? false;
+}
+
+/**
+ * Return the tag from the transaction. This "tag" field has no "modified" complement.
+ */
+function getTag(transaction: Transaction): string {
+ return transaction?.tag ?? '';
+}
+
+/**
+ * Return the created field from the transaction, return the modifiedCreated if present.
+ */
+function getCreated(transaction: Transaction): string {
+ const created = transaction?.modifiedCreated ?? transaction?.created ?? '';
+ const createdDate = parseISO(created);
+ if (isValid(createdDate)) {
+ return format(createdDate, CONST.DATE.FNS_FORMAT_STRING);
+ }
+
+ return '';
+}
+
+function isDistanceRequest(transaction: Transaction): boolean {
+ const type = transaction?.comment?.type;
+ const customUnitName = transaction?.comment?.customUnit?.name;
+ return type === CONST.TRANSACTION.TYPE.CUSTOM_UNIT && customUnitName === CONST.CUSTOM_UNITS.NAME_DISTANCE;
+}
+
+function isReceiptBeingScanned(transaction: Transaction): boolean {
+ return [CONST.IOU.RECEIPT_STATE.SCANREADY, CONST.IOU.RECEIPT_STATE.SCANNING].some((value) => value === transaction.receipt.state);
+}
+
+/**
+ * Check if the transaction has a non-smartscanning receipt and is missing required fields
+ */
+function hasMissingSmartscanFields(transaction: Transaction): boolean {
+ return hasReceipt(transaction) && !isDistanceRequest(transaction) && !isReceiptBeingScanned(transaction) && areRequiredFieldsEmpty(transaction);
+}
+
+/**
+ * Check if the transaction has a defined route
+ */
+function hasRoute(transaction: Transaction): boolean {
+ return !!transaction?.routes?.route0?.geometry?.coordinates;
+}
+
+/**
+ * Get the transactions related to a report preview with receipts
+ * Get the details linked to the IOU reportAction
+ *
+ * @deprecated Use Onyx.connect() or withOnyx() instead
+ */
+function getLinkedTransaction(reportAction: ReportAction): Transaction | Record {
+ let transactionID = '';
+
+ if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU) {
+ transactionID = reportAction.originalMessage?.IOUTransactionID ?? '';
+ }
+
+ return allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? {};
+}
+
+function getAllReportTransactions(reportID?: string): Transaction[] {
+ // `reportID` from the `/CreateDistanceRequest` endpoint return's number instead of string for created `transaction`.
+ // For reference, https://github.com/Expensify/App/pull/26536#issuecomment-1703573277.
+ // We will update this in a follow-up Issue. According to this comment: https://github.com/Expensify/App/pull/26536#issuecomment-1703591019.
+ const transactions: Transaction[] = Object.values(allTransactions ?? {}).filter((transaction): transaction is Transaction => transaction !== null);
+ return transactions.filter((transaction) => `${transaction.reportID}` === `${reportID}`);
+}
+
+/**
+ * Checks if a waypoint has a valid address
+ */
+function waypointHasValidAddress(waypoint: RecentWaypoint | null): boolean {
+ return !!waypoint?.address?.trim();
+}
+
+/**
+ * Converts the key of a waypoint to its index
+ */
+function getWaypointIndex(key: string): number {
+ return Number(key.replace('waypoint', ''));
+}
+
+/**
+ * Filters the waypoints which are valid and returns those
+ */
+function getValidWaypoints(waypoints: WaypointCollection, reArrangeIndexes = false): WaypointCollection {
+ const sortedIndexes = Object.keys(waypoints).map(getWaypointIndex).sort();
+ const waypointValues = sortedIndexes.map((index) => waypoints[`waypoint${index}`]);
+ // Ensure the number of waypoints is between 2 and 25
+ if (waypointValues.length < 2 || waypointValues.length > 25) {
+ return {};
+ }
+
+ let lastWaypointIndex = -1;
+
+ return waypointValues.reduce((acc, currentWaypoint, index) => {
+ const previousWaypoint = waypointValues[lastWaypointIndex];
+
+ // Check if the waypoint has a valid address
+ if (!waypointHasValidAddress(currentWaypoint)) {
+ return acc;
+ }
+
+ // Check for adjacent waypoints with the same address
+ if (previousWaypoint && currentWaypoint?.address === previousWaypoint.address) {
+ return acc;
+ }
+
+ const validatedWaypoints: WaypointCollection = {...acc, [`waypoint${reArrangeIndexes ? lastWaypointIndex + 1 : index}`]: currentWaypoint};
+
+ lastWaypointIndex += 1;
+
+ return validatedWaypoints;
+ }, {});
+}
+
+export {
+ buildOptimisticTransaction,
+ getUpdatedTransaction,
+ getTransaction,
+ getDescription,
+ getAmount,
+ getCurrency,
+ getMerchant,
+ getCreated,
+ getCategory,
+ getBillable,
+ getTag,
+ getLinkedTransaction,
+ getAllReportTransactions,
+ hasReceipt,
+ hasRoute,
+ isReceiptBeingScanned,
+ getValidWaypoints,
+ isDistanceRequest,
+ getWaypoints,
+ hasMissingSmartscanFields,
+ getWaypointIndex,
+ waypointHasValidAddress,
+};
diff --git a/src/libs/UserUtils.js b/src/libs/UserUtils.ts
similarity index 63%
rename from src/libs/UserUtils.js
rename to src/libs/UserUtils.ts
index 2d5930b0dfd8..751ca5b69609 100644
--- a/src/libs/UserUtils.js
+++ b/src/libs/UserUtils.ts
@@ -1,9 +1,14 @@
-import _ from 'underscore';
-import lodashGet from 'lodash/get';
+import {SvgProps} from 'react-native-svg';
+import {ValueOf} from 'type-fest';
import CONST from '../CONST';
import hashCode from './hashCode';
-import * as Expensicons from '../components/Icon/Expensicons';
+import {ConciergeAvatar, FallbackAvatar} from '../components/Icon/Expensicons';
import * as defaultAvatars from '../components/Icon/DefaultAvatars';
+import Login from '../types/onyx/Login';
+
+type AvatarRange = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24;
+
+type LoginListIndicator = ValueOf | '';
/**
* Searches through given loginList for any contact method / login with an error.
@@ -25,36 +30,26 @@ import * as defaultAvatars from '../components/Icon/DefaultAvatars';
* }
* }
* }}
- *
- * @param {Object} loginList
- * @param {Object} loginList.errorFields
- * @returns {Boolean}
*/
-function hasLoginListError(loginList) {
- return _.some(loginList, (login) => _.some(lodashGet(login, 'errorFields', {}), (field) => !_.isEmpty(field)));
+function hasLoginListError(loginList: Login): boolean {
+ const errorFields = loginList?.errorFields ?? {};
+ return Object.values(errorFields).some((field) => Object.keys(field ?? {}).length > 0);
}
/**
* Searches through given loginList for any contact method / login that requires
* an Info brick road status indicator. Currently this only applies if the user
* has an unvalidated contact method.
- *
- * @param {Object} loginList
- * @param {String} loginList.validatedDate
- * @returns {Boolean}
*/
-function hasLoginListInfo(loginList) {
- return _.some(loginList, (login) => _.isEmpty(login.validatedDate));
+function hasLoginListInfo(loginList: Login): boolean {
+ return !loginList.validatedDate;
}
/**
* Gets the appropriate brick road indicator status for a given loginList.
* Error status is higher priority, so we check for that first.
- *
- * @param {Object} loginList
- * @returns {String}
*/
-function getLoginListBrickRoadIndicator(loginList) {
+function getLoginListBrickRoadIndicator(loginList: Login): LoginListIndicator {
if (hasLoginListError(loginList)) {
return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
}
@@ -66,42 +61,35 @@ function getLoginListBrickRoadIndicator(loginList) {
/**
* Hashes provided string and returns a value between [0, range)
- * @param {String} text
- * @param {Number} range
- * @returns {Number}
*/
-function hashText(text, range) {
+function hashText(text: string, range: number): number {
return Math.abs(hashCode(text.toLowerCase())) % range;
}
/**
* Helper method to return the default avatar associated with the given accountID
- * @param {Number} [accountID]
- * @returns {String}
+ * @param [accountID]
+ * @returns
*/
-function getDefaultAvatar(accountID = -1) {
+function getDefaultAvatar(accountID = -1): React.FC {
if (accountID <= 0) {
- return Expensicons.FallbackAvatar;
+ return FallbackAvatar;
}
if (Number(accountID) === CONST.ACCOUNT_ID.CONCIERGE) {
- return Expensicons.ConciergeAvatar;
+ return ConciergeAvatar;
}
// There are 24 possible default avatars, so we choose which one this user has based
// on a simple modulo operation of their login number. Note that Avatar count starts at 1.
- const accountIDHashBucket = (accountID % CONST.DEFAULT_AVATAR_COUNT) + 1;
+ const accountIDHashBucket = ((accountID % CONST.DEFAULT_AVATAR_COUNT) + 1) as AvatarRange;
return defaultAvatars[`Avatar${accountIDHashBucket}`];
}
/**
* Helper method to return default avatar URL associated with login
- *
- * @param {Number} [accountID]
- * @param {Boolean} [isNewDot]
- * @returns {String}
*/
-function getDefaultAvatarURL(accountID = '', isNewDot = false) {
+function getDefaultAvatarURL(accountID: string | number = '', isNewDot = false): string {
if (Number(accountID) === CONST.ACCOUNT_ID.CONCIERGE) {
return CONST.CONCIERGE_ICON_URL;
}
@@ -115,26 +103,25 @@ function getDefaultAvatarURL(accountID = '', isNewDot = false) {
/**
* Given a user's avatar path, returns true if user doesn't have an avatar or if URL points to a default avatar
- * @param {String} [avatarURL] - the avatar source from user's personalDetails
- * @returns {Boolean}
+ * @param [avatarURL] - the avatar source from user's personalDetails
*/
-function isDefaultAvatar(avatarURL) {
- if (
- _.isString(avatarURL) &&
- (avatarURL.includes('images/avatars/avatar_') || avatarURL.includes('images/avatars/default-avatar_') || avatarURL.includes('images/avatars/user/default'))
- ) {
- return true;
- }
-
- // We use a hardcoded "default" Concierge avatar
- if (_.isString(avatarURL) && (avatarURL === CONST.CONCIERGE_ICON_URL_2021 || avatarURL === CONST.CONCIERGE_ICON_URL)) {
- return true;
+function isDefaultAvatar(avatarURL?: string): boolean {
+ if (typeof avatarURL === 'string') {
+ if (avatarURL.includes('images/avatars/avatar_') || avatarURL.includes('images/avatars/default-avatar_') || avatarURL.includes('images/avatars/user/default')) {
+ return true;
+ }
+
+ // We use a hardcoded "default" Concierge avatar
+ if (avatarURL === CONST.CONCIERGE_ICON_URL_2021 || avatarURL === CONST.CONCIERGE_ICON_URL) {
+ return true;
+ }
}
if (!avatarURL) {
// If null URL, we should also use a default avatar
return true;
}
+
return false;
}
@@ -142,11 +129,10 @@ function isDefaultAvatar(avatarURL) {
* Provided a source URL, if source is a default avatar, return the associated SVG.
* Otherwise, return the URL pointing to a user-uploaded avatar.
*
- * @param {String} avatarURL - the avatar source from user's personalDetails
- * @param {Number} accountID - the accountID of the user
- * @returns {String|Function}
+ * @param avatarURL - the avatar source from user's personalDetails
+ * @param accountID - the accountID of the user
*/
-function getAvatar(avatarURL, accountID) {
+function getAvatar(avatarURL: string, accountID: number): React.FC | string {
return isDefaultAvatar(avatarURL) ? getDefaultAvatar(accountID) : avatarURL;
}
@@ -154,25 +140,20 @@ function getAvatar(avatarURL, accountID) {
* Provided an avatar URL, if avatar is a default avatar, return NewDot default avatar URL.
* Otherwise, return the URL pointing to a user-uploaded avatar.
*
- * @param {String} avatarURL - the avatar source from user's personalDetails
- * @param {Number} accountID - the accountID of the user
- * @returns {String}
+ * @param avatarURL - the avatar source from user's personalDetails
+ * @param accountID - the accountID of the user
*/
-function getAvatarUrl(avatarURL, accountID) {
+function getAvatarUrl(avatarURL: string, accountID: number): string {
return isDefaultAvatar(avatarURL) ? getDefaultAvatarURL(accountID, true) : avatarURL;
}
/**
* Avatars uploaded by users will have a _128 appended so that the asset server returns a small version.
* This removes that part of the URL so the full version of the image can load.
- *
- * @param {String} [avatarURL]
- * @param {Number} [accountID]
- * @returns {String|Function}
*/
-function getFullSizeAvatar(avatarURL, accountID) {
+function getFullSizeAvatar(avatarURL: string, accountID: number): React.FC | string {
const source = getAvatar(avatarURL, accountID);
- if (!_.isString(source)) {
+ if (typeof source !== 'string') {
return source;
}
return source.replace('_128', '');
@@ -181,14 +162,10 @@ function getFullSizeAvatar(avatarURL, accountID) {
/**
* Small sized avatars end with _128.. This adds the _128 at the end of the
* source URL (before the file type) if it doesn't exist there already.
- *
- * @param {String} avatarURL
- * @param {Number} accountID
- * @returns {String|Function}
*/
-function getSmallSizeAvatar(avatarURL, accountID) {
+function getSmallSizeAvatar(avatarURL: string, accountID: number): React.FC | string {
const source = getAvatar(avatarURL, accountID);
- if (!_.isString(source)) {
+ if (typeof source !== 'string') {
return source;
}
@@ -207,10 +184,8 @@ function getSmallSizeAvatar(avatarURL, accountID) {
/**
* Generate a random accountID base on searchValue.
- * @param {String} searchValue
- * @returns {Number}
*/
-function generateAccountID(searchValue) {
+function generateAccountID(searchValue: string): number {
return hashText(searchValue, 2 ** 32);
}
diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts
index 80b15690ac46..b94c240b6e92 100644
--- a/src/libs/ValidationUtils.ts
+++ b/src/libs/ValidationUtils.ts
@@ -314,8 +314,8 @@ function isReservedRoomName(roomName: string): boolean {
/**
* Checks if the room name already exists.
*/
-function isExistingRoomName(roomName: string, reports: Report[], policyID: string): boolean {
- return reports.some((report) => report && report.policyID === policyID && report.reportName === roomName);
+function isExistingRoomName(roomName: string, reports: Record, policyID: string): boolean {
+ return Object.values(reports).some((report) => report && report.policyID === policyID && report.reportName === roomName);
}
/**
diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js
index 198ceb2b8172..152f285c84af 100644
--- a/src/libs/actions/IOU.js
+++ b/src/libs/actions/IOU.js
@@ -75,29 +75,11 @@ Onyx.connect({
},
});
-let didInitCurrency = false;
-Onyx.connect({
- key: ONYXKEYS.IOU,
- callback: (val) => {
- didInitCurrency = lodashGet(val, 'didInitCurrency');
- },
-});
-
-let shouldResetIOUAfterLogin = true;
let currentUserPersonalDetails = {};
Onyx.connect({
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (val) => {
currentUserPersonalDetails = lodashGet(val, userAccountID, {});
- if (!val || !shouldResetIOUAfterLogin || didInitCurrency) {
- return;
- }
- // eslint-disable-next-line no-use-before-define
- resetMoneyRequestInfo();
- shouldResetIOUAfterLogin = false;
- Onyx.merge(ONYXKEYS.IOU, {
- didInitCurrency: true,
- });
},
});
diff --git a/src/libs/actions/Network.ts b/src/libs/actions/Network.ts
index 212e44f6782d..fc83d23ac4a7 100644
--- a/src/libs/actions/Network.ts
+++ b/src/libs/actions/Network.ts
@@ -5,6 +5,10 @@ function setIsOffline(isOffline: boolean) {
Onyx.merge(ONYXKEYS.NETWORK, {isOffline});
}
+function setTimeSkew(skew: number) {
+ Onyx.merge(ONYXKEYS.NETWORK, {timeSkew: skew});
+}
+
function setShouldForceOffline(shouldForceOffline: boolean) {
Onyx.merge(ONYXKEYS.NETWORK, {shouldForceOffline});
}
@@ -16,4 +20,4 @@ function setShouldFailAllRequests(shouldFailAllRequests: boolean) {
Onyx.merge(ONYXKEYS.NETWORK, {shouldFailAllRequests});
}
-export {setIsOffline, setShouldForceOffline, setShouldFailAllRequests};
+export {setIsOffline, setShouldForceOffline, setShouldFailAllRequests, setTimeSkew};
diff --git a/src/libs/actions/QueuedOnyxUpdates.ts b/src/libs/actions/QueuedOnyxUpdates.ts
index ac94e6f335e3..1707bebd6cb2 100644
--- a/src/libs/actions/QueuedOnyxUpdates.ts
+++ b/src/libs/actions/QueuedOnyxUpdates.ts
@@ -1,27 +1,21 @@
import Onyx, {OnyxUpdate} from 'react-native-onyx';
-import ONYXKEYS from '../../ONYXKEYS';
// In this file we manage a queue of Onyx updates while the SequentialQueue is processing. There are functions to get the updates and clear the queue after saving the updates in Onyx.
let queuedOnyxUpdates: OnyxUpdate[] = [];
-Onyx.connect({
- key: ONYXKEYS.QUEUED_ONYX_UPDATES,
- callback: (val) => (queuedOnyxUpdates = val ?? []),
-});
/**
* @param updates Onyx updates to queue for later
*/
function queueOnyxUpdates(updates: OnyxUpdate[]): Promise {
- return Onyx.set(ONYXKEYS.QUEUED_ONYX_UPDATES, [...queuedOnyxUpdates, ...updates]);
-}
-
-function clear() {
- Onyx.set(ONYXKEYS.QUEUED_ONYX_UPDATES, null);
+ queuedOnyxUpdates = queuedOnyxUpdates.concat(updates);
+ return Promise.resolve();
}
function flushQueue(): Promise {
- return Onyx.update(queuedOnyxUpdates).then(clear);
+ return Onyx.update(queuedOnyxUpdates).then(() => {
+ queuedOnyxUpdates = [];
+ });
}
export {queueOnyxUpdates, flushQueue};
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index a6e115fe5d9c..b96ae8bec68d 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -301,7 +301,7 @@ function addActions(reportID, text = '', file) {
// Always prefer the file as the last action over text
const lastAction = attachmentAction || reportCommentAction;
- const currentTime = DateUtils.getDBTime();
+ const currentTime = DateUtils.getDBTimeWithSkew();
const lastCommentText = ReportUtils.formatReportLastMessageText(lastAction.message[0].text);
@@ -690,7 +690,7 @@ function navigateToAndOpenChildReport(childReportID = '0', parentReportAction =
'',
undefined,
undefined,
- CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS,
+ CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN,
parentReportAction.reportActionID,
parentReportID,
);
@@ -1454,10 +1454,13 @@ function saveReportActionDraftNumberOfLines(reportID, reportActionID, numberOfLi
* @param {String} reportID
* @param {String} previousValue
* @param {String} newValue
+ * @param {boolean} navigate
*/
-function updateNotificationPreferenceAndNavigate(reportID, previousValue, newValue) {
+function updateNotificationPreference(reportID, previousValue, newValue, navigate) {
if (previousValue === newValue) {
- Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(reportID));
+ if (navigate) {
+ Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(reportID));
+ }
return;
}
const optimisticData = [
@@ -1475,7 +1478,9 @@ function updateNotificationPreferenceAndNavigate(reportID, previousValue, newVal
},
];
API.write('UpdateReportNotificationPreference', {reportID, notificationPreference: newValue}, {optimisticData, failureData});
- Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(reportID));
+ if (navigate) {
+ Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(reportID));
+ }
}
/**
@@ -1835,7 +1840,7 @@ function showReportActionNotification(reportID, reportAction) {
const notificationParams = {
report,
reportAction,
- onClick: () => Navigation.navigate(ROUTES.getReportRoute(reportID)),
+ onClick: () => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)),
};
if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE) {
LocalNotification.showModifiedExpenseNotification(notificationParams);
@@ -2325,7 +2330,7 @@ export {
reconnect,
updateWelcomeMessage,
updateWriteCapabilityAndNavigate,
- updateNotificationPreferenceAndNavigate,
+ updateNotificationPreference,
subscribeToReportTypingEvents,
subscribeToReportLeavingEvents,
unsubscribeFromReportChannel,
diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js
index 91267b9b1053..6a8afb494e99 100644
--- a/src/libs/actions/Task.js
+++ b/src/libs/actions/Task.js
@@ -273,22 +273,6 @@ function completeTask(taskReport) {
},
];
- // Multiple report actions can link to the same child. Both share destination (task parent) and assignee report link to the same report action.
- // We need to find and update the other parent report action (in assignee report). More info https://github.com/Expensify/App/issues/23920#issuecomment-1663092717
- const assigneeReportAction = ReportUtils.getTaskParentReportActionIDInAssigneeReport(taskReport);
- if (!_.isEmpty(assigneeReportAction)) {
- const optimisticDataForClonedParentReportAction = ReportUtils.getOptimisticDataForParentReportAction(
- taskReportID,
- completedTaskReportAction.created,
- CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
- assigneeReportAction.reportID,
- assigneeReportAction.reportActionID,
- );
- if (!_.isEmpty(optimisticDataForClonedParentReportAction)) {
- optimisticData.push(optimisticDataForClonedParentReportAction);
- }
- }
-
API.write(
'CompleteTask',
{
@@ -359,22 +343,6 @@ function reopenTask(taskReport) {
},
];
- // Multiple report actions can link to the same child. Both share destination (task parent) and assignee report link to the same report action.
- // We need to find and update the other parent report action (in assignee report). More info https://github.com/Expensify/App/issues/23920#issuecomment-1663092717
- const assigneeReportAction = ReportUtils.getTaskParentReportActionIDInAssigneeReport(taskReport);
- if (!_.isEmpty(assigneeReportAction)) {
- const optimisticDataForClonedParentReportAction = ReportUtils.getOptimisticDataForParentReportAction(
- taskReportID,
- reopenedTaskReportAction.created,
- CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
- assigneeReportAction.reportID,
- assigneeReportAction.reportActionID,
- );
- if (!_.isEmpty(optimisticDataForClonedParentReportAction)) {
- optimisticData.push(optimisticDataForClonedParentReportAction);
- }
- }
-
API.write(
'ReopenTask',
{
@@ -825,6 +793,15 @@ function cancelTask(taskReportID, taskTitle, originalStateNum, originalStatusNum
},
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReport.reportID}`,
+ value: {
+ [parentReportAction.reportActionID]: {
+ pendingAction: null,
+ },
+ },
+ },
];
const failureData = [
@@ -843,6 +820,15 @@ function cancelTask(taskReportID, taskTitle, originalStateNum, originalStatusNum
[optimisticReportActionID]: null,
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReport.reportID}`,
+ value: {
+ [parentReportAction.reportActionID]: {
+ pendingAction: null,
+ },
+ },
+ },
];
API.write('CancelTask', {cancelledTaskReportActionID: optimisticReportActionID, taskReportID}, {optimisticData, successData, failureData});
@@ -892,6 +878,10 @@ function getTaskOwnerAccountID(taskReport) {
* @returns {Boolean}
*/
function canModifyTask(taskReport, sessionAccountID) {
+ if (ReportUtils.isCanceledTaskReport(taskReport)) {
+ return false;
+ }
+
if (sessionAccountID === getTaskOwnerAccountID(taskReport) || sessionAccountID === getTaskAssigneeAccountID(taskReport)) {
return true;
}
diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts
index fe1bc1621cfa..d8b87ca86904 100644
--- a/src/libs/actions/Transaction.ts
+++ b/src/libs/actions/Transaction.ts
@@ -108,7 +108,7 @@ function removeWaypoint(transactionID: string, currentIndex: string) {
const waypointValues = Object.values(existingWaypoints);
const removed = waypointValues.splice(index, 1);
- const isRemovedWaypointEmpty = removed.length > 0 && !TransactionUtils.waypointHasValidAddress(removed[0] ?? {});
+ const isRemovedWaypointEmpty = removed.length > 0 && !TransactionUtils.waypointHasValidAddress(removed[0] ?? null);
const reIndexedWaypoints: WaypointCollection = {};
waypointValues.forEach((waypoint, idx) => {
diff --git a/src/libs/fileDownload/FileUtils.js b/src/libs/fileDownload/FileUtils.js
index cee2b8877ef6..e508d096128d 100644
--- a/src/libs/fileDownload/FileUtils.js
+++ b/src/libs/fileDownload/FileUtils.js
@@ -157,7 +157,7 @@ const readFileAsync = (path, fileName) =>
return res.blob();
})
.then((blob) => {
- const file = new File([blob], cleanFileName(fileName));
+ const file = new File([blob], cleanFileName(fileName), {type: blob.type});
file.source = path;
// For some reason, the File object on iOS does not have a uri property
// so images aren't uploaded correctly to the backend
diff --git a/src/libs/getPlaidLinkTokenParameters/index.android.js b/src/libs/getPlaidLinkTokenParameters/index.android.js
deleted file mode 100644
index 4174e2b8905b..000000000000
--- a/src/libs/getPlaidLinkTokenParameters/index.android.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import CONST from '../../CONST';
-
-export default () => ({android_package: CONST.ANDROID_PACKAGE_NAME});
diff --git a/src/libs/getPlaidLinkTokenParameters/index.android.ts b/src/libs/getPlaidLinkTokenParameters/index.android.ts
new file mode 100644
index 000000000000..39d444abdba2
--- /dev/null
+++ b/src/libs/getPlaidLinkTokenParameters/index.android.ts
@@ -0,0 +1,8 @@
+import CONST from '../../CONST';
+import GetPlaidLinkTokenParameters from './types';
+
+const getPlaidLinkTokenParameters: GetPlaidLinkTokenParameters = () => ({
+ androidPackage: CONST.ANDROID_PACKAGE_NAME,
+});
+
+export default getPlaidLinkTokenParameters;
diff --git a/src/libs/getPlaidLinkTokenParameters/index.ios.js b/src/libs/getPlaidLinkTokenParameters/index.ios.js
deleted file mode 100644
index 88032c582d17..000000000000
--- a/src/libs/getPlaidLinkTokenParameters/index.ios.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import CONFIG from '../../CONFIG';
-
-export default () => ({redirect_uri: `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}partners/plaid/oauth_ios`});
diff --git a/src/libs/getPlaidLinkTokenParameters/index.ios.ts b/src/libs/getPlaidLinkTokenParameters/index.ios.ts
new file mode 100644
index 000000000000..330efad4839a
--- /dev/null
+++ b/src/libs/getPlaidLinkTokenParameters/index.ios.ts
@@ -0,0 +1,8 @@
+import CONFIG from '../../CONFIG';
+import GetPlaidLinkTokenParameters from './types';
+
+const getPlaidLinkTokenParameters: GetPlaidLinkTokenParameters = () => ({
+ redirectURI: `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}partners/plaid/oauth_ios`,
+});
+
+export default getPlaidLinkTokenParameters;
diff --git a/src/libs/getPlaidLinkTokenParameters/index.js b/src/libs/getPlaidLinkTokenParameters/index.js
deleted file mode 100644
index cd15926ee3e6..000000000000
--- a/src/libs/getPlaidLinkTokenParameters/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import ROUTES from '../../ROUTES';
-import CONFIG from '../../CONFIG';
-
-export default () => {
- const bankAccountRoute = window.location.href.includes('personal') ? ROUTES.BANK_ACCOUNT_PERSONAL : ROUTES.BANK_ACCOUNT;
- return {redirect_uri: `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}${bankAccountRoute}`};
-};
diff --git a/src/libs/getPlaidLinkTokenParameters/index.ts b/src/libs/getPlaidLinkTokenParameters/index.ts
new file mode 100644
index 000000000000..e47629e85d09
--- /dev/null
+++ b/src/libs/getPlaidLinkTokenParameters/index.ts
@@ -0,0 +1,11 @@
+import ROUTES from '../../ROUTES';
+import CONFIG from '../../CONFIG';
+import GetPlaidLinkTokenParameters from './types';
+
+const getPlaidLinkTokenParameters: GetPlaidLinkTokenParameters = () => {
+ const bankAccountRoute = window.location.href.includes('personal') ? ROUTES.BANK_ACCOUNT_PERSONAL : ROUTES.BANK_ACCOUNT;
+
+ return {redirectURI: `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}${bankAccountRoute}`};
+};
+
+export default getPlaidLinkTokenParameters;
diff --git a/src/libs/getPlaidLinkTokenParameters/types.ts b/src/libs/getPlaidLinkTokenParameters/types.ts
new file mode 100644
index 000000000000..1fd0eb51017f
--- /dev/null
+++ b/src/libs/getPlaidLinkTokenParameters/types.ts
@@ -0,0 +1,10 @@
+type PlaidLinkTokenParameters = {
+ androidPackage?: string;
+ redirectURI?: string;
+ allowDebit?: boolean;
+ bankAccountID?: number;
+};
+
+type GetPlaidLinkTokenParameters = () => PlaidLinkTokenParameters;
+
+export default GetPlaidLinkTokenParameters;
diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/index.js b/src/libs/setShouldShowComposeInputKeyboardAware/index.js
deleted file mode 100644
index a8ad5f54a65f..000000000000
--- a/src/libs/setShouldShowComposeInputKeyboardAware/index.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import * as Composer from '../actions/Composer';
-
-export default (shouldShow) => {
- Composer.setShouldShowComposeInput(shouldShow);
-};
diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/index.native.js b/src/libs/setShouldShowComposeInputKeyboardAware/index.native.ts
similarity index 61%
rename from src/libs/setShouldShowComposeInputKeyboardAware/index.native.js
rename to src/libs/setShouldShowComposeInputKeyboardAware/index.native.ts
index 147d21d51168..f5d690db644d 100644
--- a/src/libs/setShouldShowComposeInputKeyboardAware/index.native.js
+++ b/src/libs/setShouldShowComposeInputKeyboardAware/index.native.ts
@@ -1,8 +1,9 @@
-import {Keyboard} from 'react-native';
+import {EmitterSubscription, Keyboard} from 'react-native';
import * as Composer from '../actions/Composer';
+import SetShouldShowComposeInputKeyboardAware from './types';
-let keyboardDidHideListener = null;
-export default (shouldShow) => {
+let keyboardDidHideListener: EmitterSubscription | null = null;
+const setShouldShowComposeInputKeyboardAware: SetShouldShowComposeInputKeyboardAware = (shouldShow) => {
if (keyboardDidHideListener) {
keyboardDidHideListener.remove();
keyboardDidHideListener = null;
@@ -21,6 +22,8 @@ export default (shouldShow) => {
keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => {
Composer.setShouldShowComposeInput(true);
- keyboardDidHideListener.remove();
+ keyboardDidHideListener?.remove();
});
};
+
+export default setShouldShowComposeInputKeyboardAware;
diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/index.ts b/src/libs/setShouldShowComposeInputKeyboardAware/index.ts
new file mode 100644
index 000000000000..5ee36834ad5c
--- /dev/null
+++ b/src/libs/setShouldShowComposeInputKeyboardAware/index.ts
@@ -0,0 +1,8 @@
+import * as Composer from '../actions/Composer';
+import SetShouldShowComposeInputKeyboardAware from './types';
+
+const setShouldShowComposeInputKeyboardAware: SetShouldShowComposeInputKeyboardAware = (shouldShow) => {
+ Composer.setShouldShowComposeInput(shouldShow);
+};
+
+export default setShouldShowComposeInputKeyboardAware;
diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/types.ts b/src/libs/setShouldShowComposeInputKeyboardAware/types.ts
new file mode 100644
index 000000000000..7e3a604f562e
--- /dev/null
+++ b/src/libs/setShouldShowComposeInputKeyboardAware/types.ts
@@ -0,0 +1,3 @@
+type SetShouldShowComposeInputKeyboardAware = (shouldShow: boolean) => void;
+
+export default SetShouldShowComposeInputKeyboardAware;
diff --git a/src/pages/EditRequestReceiptPage.js b/src/pages/EditRequestReceiptPage.js
index f45085a052e9..c5dd69624159 100644
--- a/src/pages/EditRequestReceiptPage.js
+++ b/src/pages/EditRequestReceiptPage.js
@@ -37,11 +37,11 @@ function EditRequestReceiptPage({route, transactionID}) {
shouldEnableMaxHeight
testID={EditRequestReceiptPage.displayName}
>
-
+ {
+ if (isOffline) {
return;
}
Wallet.openEnablePaymentsPage();
- }
+ }, [isOffline]);
- render() {
- if (_.isEmpty(this.props.userWallet)) {
- return ;
- }
-
- return (
-
- {() => {
- if (this.props.userWallet.errorCode === CONST.WALLET.ERROR.KYC) {
- return (
- <>
- Navigation.goBack(ROUTES.SETTINGS_WALLET)}
- />
-
- >
- );
- }
-
- if (this.props.userWallet.shouldShowWalletActivationSuccess) {
- return ;
- }
-
- const currentStep = this.props.userWallet.currentStep || CONST.WALLET.STEP.ADDITIONAL_DETAILS;
+ if (_.isEmpty(userWallet)) {
+ return ;
+ }
+ return (
+
+ {() => {
+ if (userWallet.errorCode === CONST.WALLET.ERROR.KYC) {
return (
<>
- {(currentStep === CONST.WALLET.STEP.ADDITIONAL_DETAILS || currentStep === CONST.WALLET.STEP.ADDITIONAL_DETAILS_KBA) && }
- {currentStep === CONST.WALLET.STEP.ONFIDO && }
- {currentStep === CONST.WALLET.STEP.TERMS && }
- {currentStep === CONST.WALLET.STEP.ACTIVATE && }
+ Navigation.goBack(ROUTES.SETTINGS_WALLET)}
+ />
+
>
);
- }}
-
- );
- }
+ }
+
+ if (userWallet.shouldShowWalletActivationSuccess) {
+ return ;
+ }
+
+ const currentStep = userWallet.currentStep || CONST.WALLET.STEP.ADDITIONAL_DETAILS;
+
+ switch (currentStep) {
+ case CONST.WALLET.STEP.ADDITIONAL_DETAILS:
+ case CONST.WALLET.STEP.ADDITIONAL_DETAILS_KBA:
+ return ;
+ case CONST.WALLET.STEP.ONFIDO:
+ return ;
+ case CONST.WALLET.STEP.TERMS:
+ return ;
+ case CONST.WALLET.STEP.ACTIVATE:
+ return ;
+ default:
+ return null;
+ }
+ }}
+
+ );
}
+EnablePaymentsPage.displayName = 'EnablePaymentsPage';
EnablePaymentsPage.propTypes = propTypes;
EnablePaymentsPage.defaultProps = defaultProps;
-export default compose(
- withLocalize,
- withOnyx({
- userWallet: {
- key: ONYXKEYS.USER_WALLET,
+export default withOnyx({
+ userWallet: {
+ key: ONYXKEYS.USER_WALLET,
- // We want to refresh the wallet each time the user attempts to activate the wallet so we won't use the
- // stored values here.
- initWithStoredValues: false,
- },
- }),
- withNetwork(),
-)(EnablePaymentsPage);
+ // We want to refresh the wallet each time the user attempts to activate the wallet so we won't use the
+ // stored values here.
+ initWithStoredValues: false,
+ },
+})(EnablePaymentsPage);
diff --git a/src/pages/EnablePayments/TermsPage/ShortTermsForm.js b/src/pages/EnablePayments/TermsPage/ShortTermsForm.js
index a6f685fcb562..1b693add95b7 100644
--- a/src/pages/EnablePayments/TermsPage/ShortTermsForm.js
+++ b/src/pages/EnablePayments/TermsPage/ShortTermsForm.js
@@ -5,11 +5,26 @@ import Text from '../../../components/Text';
import * as Localize from '../../../libs/Localize';
import CONST from '../../../CONST';
import TextLink from '../../../components/TextLink';
+import userWalletPropTypes from '../userWalletPropTypes';
-function ShortTermsForm() {
+const propTypes = {
+ /** The user's wallet */
+ userWallet: userWalletPropTypes,
+};
+
+const defaultProps = {
+ userWallet: {},
+};
+
+function ShortTermsForm(props) {
return (
<>
- {Localize.translateLocal('termsStep.shortTermsForm.expensifyPaymentsAccount')}
+
+ {Localize.translateLocal('termsStep.shortTermsForm.expensifyPaymentsAccount', {
+ walletProgram:
+ props.userWallet.walletProgramID === CONST.WALLET.MTL_WALLET_PROGRAM_ID ? CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS : CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK,
+ })}
+
@@ -132,6 +147,8 @@ function ShortTermsForm() {
);
}
+ShortTermsForm.propTypes = propTypes;
+ShortTermsForm.defaultProps = defaultProps;
ShortTermsForm.displayName = 'ShortTermsForm';
export default ShortTermsForm;
diff --git a/src/pages/EnablePayments/TermsStep.js b/src/pages/EnablePayments/TermsStep.js
index e96d93dc4de9..39f4826ec0b2 100644
--- a/src/pages/EnablePayments/TermsStep.js
+++ b/src/pages/EnablePayments/TermsStep.js
@@ -15,8 +15,12 @@ import LongTermsForm from './TermsPage/LongTermsForm';
import FormAlertWithSubmitButton from '../../components/FormAlertWithSubmitButton';
import walletTermsPropTypes from './walletTermsPropTypes';
import * as ErrorUtils from '../../libs/ErrorUtils';
+import userWalletPropTypes from './userWalletPropTypes';
const propTypes = {
+ /** The user's wallet */
+ userWallet: userWalletPropTypes,
+
/** Comes from Onyx. Information about the terms for the wallet */
walletTerms: walletTermsPropTypes,
@@ -24,6 +28,7 @@ const propTypes = {
};
const defaultProps = {
+ userWallet: {},
walletTerms: {},
};
@@ -59,7 +64,7 @@ function TermsStep(props) {
style={styles.flex1}
contentContainerStyle={styles.ph5}
>
-
+
-
+
{Permissions.canUsePolicyRooms(props.betas) ? (
@@ -113,57 +114,55 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) {
Navigation.goBack(ROUTES.PRIVATE_NOTES_VIEW.getRoute(report.repotID, route.params.accountID))}
shouldShowBackButton
onCloseButtonPress={() => Navigation.dismissModal()}
/>
-
-
-
- {translate(
- Str.extractEmailDomain(lodashGet(personalDetailsList, [route.params.accountID, 'login'], '')) === CONST.EMAIL.GUIDES_DOMAIN
- ? 'privateNotes.sharedNoteMessage'
- : 'privateNotes.personalNoteMessage',
- )}
-
-
-
-
+ />
+
+
);
diff --git a/src/pages/PrivateNotes/PrivateNotesViewPage.js b/src/pages/PrivateNotes/PrivateNotesViewPage.js
index 4d79f7815ff9..6550c9d7d3c3 100644
--- a/src/pages/PrivateNotes/PrivateNotesViewPage.js
+++ b/src/pages/PrivateNotes/PrivateNotesViewPage.js
@@ -56,6 +56,16 @@ function PrivateNotesViewPage({route, personalDetailsList, session, report}) {
const isCurrentUserNote = Number(session.accountID) === Number(route.params.accountID);
const privateNote = lodashGet(report, ['privateNotes', route.params.accountID, 'note'], '');
+ const getFallbackRoute = () => {
+ const privateNotes = lodashGet(report, 'privateNotes', {});
+
+ if (_.keys(privateNotes).length === 1) {
+ return ROUTES.HOME;
+ }
+
+ return ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID);
+ };
+
return (
Navigation.goBack(getFallbackRoute())}
subtitle={isCurrentUserNote ? translate('privateNotes.myNote') : `${lodashGet(personalDetailsList, [route.params.accountID, 'login'], '')} note`}
shouldShowBackButton
onCloseButtonPress={() => Navigation.dismissModal()}
diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js
index 98dae2e94780..2bf903c88ee8 100644
--- a/src/pages/ReportDetailsPage.js
+++ b/src/pages/ReportDetailsPage.js
@@ -124,7 +124,7 @@ function ReportDetailsPage(props) {
});
}
- if (isUserCreatedPolicyRoom || canLeaveRoom || isThread) {
+ if (isUserCreatedPolicyRoom || canLeaveRoom) {
items.push({
key: CONST.REPORT_DETAILS_MENU_ITEM.LEAVE_ROOM,
translationKey: isThread ? 'common.leaveThread' : 'common.leaveRoom',
diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js
index ba4787f9b4b4..2ac58a13ce18 100644
--- a/src/pages/home/HeaderView.js
+++ b/src/pages/home/HeaderView.js
@@ -1,35 +1,38 @@
-import _ from 'underscore';
+import lodashGet from 'lodash/get';
+import PropTypes from 'prop-types';
import React from 'react';
import {View} from 'react-native';
-import PropTypes from 'prop-types';
-import lodashGet from 'lodash/get';
import {withOnyx} from 'react-native-onyx';
-import styles from '../../styles/styles';
+import _ from 'underscore';
+import GoogleMeetIcon from '../../../assets/images/google-meet.svg';
+import ZoomIcon from '../../../assets/images/zoom-icon.svg';
+import CONST from '../../CONST';
+import ONYXKEYS from '../../ONYXKEYS';
+import DisplayNames from '../../components/DisplayNames';
import Icon from '../../components/Icon';
import * as Expensicons from '../../components/Icon/Expensicons';
-import compose from '../../libs/compose';
-import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions';
import MultipleAvatars from '../../components/MultipleAvatars';
+import ParentNavigationSubtitle from '../../components/ParentNavigationSubtitle';
+import PressableWithoutFeedback from '../../components/Pressable/PressableWithoutFeedback';
import SubscriptAvatar from '../../components/SubscriptAvatar';
-import DisplayNames from '../../components/DisplayNames';
-import * as OptionsListUtils from '../../libs/OptionsListUtils';
+import TaskHeaderActionButton from '../../components/TaskHeaderActionButton';
+import Text from '../../components/Text';
+import ThreeDotsMenu from '../../components/ThreeDotsMenu';
+import Tooltip from '../../components/Tooltip';
import participantPropTypes from '../../components/participantPropTypes';
-import VideoChatButtonAndMenu from '../../components/VideoChatButtonAndMenu';
import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
-import CONST from '../../CONST';
+import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions';
+import * as OptionsListUtils from '../../libs/OptionsListUtils';
+import * as ReportActionsUtils from '../../libs/ReportActionsUtils';
import * as ReportUtils from '../../libs/ReportUtils';
-import Text from '../../components/Text';
-import Tooltip from '../../components/Tooltip';
+import * as Link from '../../libs/actions/Link';
+import * as Report from '../../libs/actions/Report';
+import * as Session from '../../libs/actions/Session';
+import * as Task from '../../libs/actions/Task';
+import compose from '../../libs/compose';
+import styles from '../../styles/styles';
import themeColors from '../../styles/themes/default';
import reportPropTypes from '../reportPropTypes';
-import ONYXKEYS from '../../ONYXKEYS';
-import ThreeDotsMenu from '../../components/ThreeDotsMenu';
-import * as Task from '../../libs/actions/Task';
-import PressableWithoutFeedback from '../../components/Pressable/PressableWithoutFeedback';
-import PinButton from '../../components/PinButton';
-import TaskHeaderActionButton from '../../components/TaskHeaderActionButton';
-import * as ReportActionsUtils from '../../libs/ReportActionsUtils';
-import ParentNavigationSubtitle from '../../components/ParentNavigationSubtitle';
const propTypes = {
/** Toggles the navigationMenu open and closed */
@@ -83,25 +86,20 @@ function HeaderView(props) {
const isAutomatedExpensifyAccount = ReportUtils.hasSingleParticipant(props.report) && ReportUtils.hasAutomatedExpensifyAccountIDs(participants);
const parentReportAction = ReportActionsUtils.getParentReportAction(props.report);
const isCanceledTaskReport = ReportUtils.isCanceledTaskReport(props.report, parentReportAction);
+ const lastVisibleMessage = ReportActionsUtils.getLastVisibleMessage(props.report.reportID);
+ const isEmptyChat = !props.report.lastMessageText && !props.report.lastMessageTranslationKey && !lastVisibleMessage.lastMessageText && !lastVisibleMessage.lastMessageTranslationKey;
// We hide the button when we are chatting with an automated Expensify account since it's not possible to contact
// these users via alternative means. It is possible to request a call with Concierge so we leave the option for them.
- const shouldShowCallButton = (isConcierge && props.guideCalendarLink) || (!isAutomatedExpensifyAccount && !isTaskReport);
const threeDotMenuItems = [];
if (isTaskReport && !isCanceledTaskReport) {
const canModifyTask = Task.canModifyTask(props.report, props.session.accountID);
- if (ReportUtils.isOpenTaskReport(props.report) && canModifyTask) {
- threeDotMenuItems.push({
- icon: Expensicons.Checkmark,
- text: props.translate('task.markAsComplete'),
- onSelected: () => Task.completeTask(props.report),
- });
- }
// Task is marked as completed
if (ReportUtils.isCompletedTaskReport(props.report) && canModifyTask) {
threeDotMenuItems.push({
icon: Expensicons.Checkmark,
+ iconFill: themeColors.icon,
text: props.translate('task.markAsIncomplete'),
onSelected: () => Task.reopenTask(props.report),
});
@@ -111,11 +109,75 @@ function HeaderView(props) {
if (props.report.stateNum !== CONST.REPORT.STATE_NUM.SUBMITTED && props.report.statusNum !== CONST.REPORT.STATUS.CLOSED && canModifyTask) {
threeDotMenuItems.push({
icon: Expensicons.Trashcan,
+ iconFill: themeColors.icon,
text: props.translate('common.cancel'),
onSelected: () => Task.cancelTask(props.report.reportID, props.report.reportName, props.report.stateNum, props.report.statusNum),
});
}
}
+
+ if (isChatThread && !isEmptyChat) {
+ if (props.report.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) {
+ threeDotMenuItems.push({
+ icon: Expensicons.ChatBubbles,
+ iconFill: themeColors.icon,
+ text: props.translate('common.joinThread'),
+ onSelected: () => Report.updateNotificationPreference(props.report.reportID, props.report.notificationPreference, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, false),
+ });
+ } else if (props.report.notificationPreference.length) {
+ threeDotMenuItems.push({
+ icon: Expensicons.ChatBubbles,
+ iconFill: themeColors.icon,
+ text: props.translate('common.leaveThread'),
+ onSelected: () => Report.leaveRoom(props.report.reportID),
+ });
+ }
+ }
+
+ if (!props.report.isPinned) {
+ threeDotMenuItems.push({
+ icon: Expensicons.Pin,
+ iconFill: themeColors.icon,
+ text: props.translate('common.pin'),
+ onSelected: Session.checkIfActionIsAllowed(() => Report.togglePinnedState(props.report.reportID, props.report.isPinned)),
+ });
+ } else {
+ threeDotMenuItems.push({
+ icon: Expensicons.Pin,
+ iconFill: themeColors.icon,
+ text: props.translate('common.unPin'),
+ onSelected: Session.checkIfActionIsAllowed(() => Report.togglePinnedState(props.report.reportID, props.report.isPinned)),
+ });
+ }
+
+ if (isConcierge && props.guideCalendarLink) {
+ threeDotMenuItems.push({
+ icon: Expensicons.Phone,
+ iconFill: themeColors.icon,
+ text: props.translate('videoChatButtonAndMenu.tooltip'),
+ onSelected: () => {
+ Link.openExternalLink(props.guideCalendarLink);
+ },
+ });
+ } else if (!isAutomatedExpensifyAccount && !isTaskReport) {
+ threeDotMenuItems.push({
+ icon: ZoomIcon,
+ iconFill: themeColors.icon,
+ text: props.translate('videoChatButtonAndMenu.zoom'),
+ onSelected: () => {
+ Link.openExternalLink(CONST.NEW_ZOOM_MEETING_URL);
+ },
+ });
+ threeDotMenuItems.push({
+ icon: GoogleMeetIcon,
+ iconFill: themeColors.icon,
+ text: props.translate('videoChatButtonAndMenu.googleMeet'),
+ onSelected: () => {
+ Link.openExternalLink(CONST.NEW_GOOGLE_MEET_MEETING_URL);
+ },
+ });
+ }
+
const shouldShowThreeDotsButton = !!threeDotMenuItems.length;
const shouldShowSubscript = ReportUtils.shouldReportShowSubscript(props.report);
@@ -206,13 +268,6 @@ function HeaderView(props) {
{isTaskReport && !props.isSmallScreenWidth && ReportUtils.isOpenTaskReport(props.report) && }
- {shouldShowCallButton && (
-
- )}
-
{shouldShowThreeDotsButton && (
report === defaultProps.report;
-
/**
* Get the currently viewed report ID as number
*
@@ -189,8 +180,6 @@ function ReportScreen({
const isTopMostReportId = currentReportID === getReportID(route);
const didSubscribeToReportLeavingEvents = useRef(false);
- const isDefaultReport = checkDefaultReport(report);
-
let headerView = (
{
setIsBannerVisible(false);
@@ -365,17 +355,8 @@ function ReportScreen({
// eslint-disable-next-line rulesdir/no-negated-variables
const shouldShowNotFoundPage = useMemo(
- () =>
- (!firstRenderRef.current &&
- !_.isEmpty(report) &&
- !isDefaultReport &&
- !report.reportID &&
- !isOptimisticDelete &&
- !reportMetadata.isLoadingInitialReportActions &&
- !isLoading &&
- !userLeavingStatus) ||
- shouldHideReport,
- [report, isLoading, shouldHideReport, isDefaultReport, isOptimisticDelete, userLeavingStatus, reportMetadata.isLoadingInitialReportActions],
+ () => (!firstRenderRef.current && !report.reportID && !isOptimisticDelete && !reportMetadata.isLoadingInitialReportActions && !isLoading && !userLeavingStatus) || shouldHideReport,
+ [report, reportMetadata, isLoading, shouldHideReport, isOptimisticDelete, userLeavingStatus],
);
return (
@@ -488,9 +469,9 @@ export default compose(
reportMetadata: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_METADATA}${getReportID(route)}`,
initialValue: {
+ isLoadingInitialReportActions: true,
isLoadingOlderReportActions: false,
isLoadingNewerReportActions: false,
- isLoadingInitialReportActions: false,
},
},
isComposerFullSize: {
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js
index 157ae66dc918..92f6c5a454e8 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.js
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js
@@ -144,7 +144,7 @@ export default [
getDescription: () => {},
},
{
- isAnonymousAction: false,
+ isAnonymousAction: true,
textTranslateKey: 'reportActionContextMenu.copyURLToClipboard',
icon: Expensicons.Copy,
successTextTranslateKey: 'reportActionContextMenu.copied',
@@ -157,7 +157,7 @@ export default [
getDescription: (selection) => selection,
},
{
- isAnonymousAction: false,
+ isAnonymousAction: true,
textTranslateKey: 'reportActionContextMenu.copyEmailToClipboard',
icon: Expensicons.Copy,
successTextTranslateKey: 'reportActionContextMenu.copied',
@@ -221,7 +221,7 @@ export default [
},
{
- isAnonymousAction: false,
+ isAnonymousAction: true,
textTranslateKey: 'reportActionContextMenu.copyLink',
icon: Expensicons.LinkCopy,
successIcon: Expensicons.Checkmark,
diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
index 4f09df7330ff..1c54bc572a34 100644
--- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
+++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
@@ -278,6 +278,7 @@ function PopoverReportActionContextMenu(_props, ref) {
instanceID,
runAndResetOnPopoverHide,
clearActiveReportAction,
+ contentRef,
}));
const reportAction = reportActionRef.current;
@@ -321,8 +322,6 @@ function PopoverReportActionContextMenu(_props, ref) {
onConfirm={confirmDeleteAndHideModal}
onCancel={hideDeleteModal}
onModalHide={() => {
- reportIDRef.current = '0';
- reportActionRef.current = {};
callbackWhenDeleteModalHide.current();
}}
prompt={translate('reportActionContextMenu.deleteConfirmation', {action: reportAction})}
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js
index 6c08b68cdc78..fea1f2e845a5 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js
+++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js
@@ -234,7 +234,10 @@ function SuggestionMention({
useEffect(() => {
calculateMentionSuggestion(selection.end);
- }, [selection, calculateMentionSuggestion]);
+
+ // We want this hook to run only on selection change.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selection]);
const updateShouldShowSuggestionMenuToFalse = useCallback(() => {
setSuggestionValues((prevState) => {
diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js
index fe1dcf248f90..2585481748f6 100644
--- a/src/pages/home/report/ReportActionItem.js
+++ b/src/pages/home/report/ReportActionItem.js
@@ -1,6 +1,6 @@
import _ from 'underscore';
import lodashGet from 'lodash/get';
-import React, {useState, useRef, useEffect, memo, useCallback, useContext} from 'react';
+import React, {useState, useRef, useEffect, memo, useCallback, useContext, useMemo} from 'react';
import {InteractionManager, View} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
@@ -61,12 +61,13 @@ import * as Session from '../../../libs/actions/Session';
import MoneyRequestView from '../../../components/ReportActionItem/MoneyRequestView';
import {hideContextMenu} from './ContextMenu/ReportActionContextMenu';
import * as PersonalDetailsUtils from '../../../libs/PersonalDetailsUtils';
-import ReportActionItemBasicMessage from './ReportActionItemBasicMessage';
import * as store from '../../../libs/actions/ReimbursementAccount/store';
import * as BankAccounts from '../../../libs/actions/BankAccounts';
import {ReactionListContext} from '../ReportScreenContext';
import usePrevious from '../../../hooks/usePrevious';
import Permissions from '../../../libs/Permissions';
+import themeColors from '../../../styles/themes/default';
+import ReportActionItemBasicMessage from './ReportActionItemBasicMessage';
import RenderHTML from '../../../components/RenderHTML';
import ReportAttachmentsContext from './ReportAttachmentsContext';
@@ -138,6 +139,9 @@ function ReportActionItem(props) {
const prevDraftMessage = usePrevious(props.draftMessage);
const originalReportID = ReportUtils.getOriginalReportID(props.report.reportID, props.action);
const originalReport = props.report.reportID === originalReportID ? props.report : ReportUtils.getReport(originalReportID);
+ const isReportActionLinked = props.linkedReportActionID === props.action.reportActionID;
+
+ const highlightedBackgroundColorIfNeeded = useMemo(() => (isReportActionLinked ? StyleUtils.getBackgroundColorStyle(themeColors.highlightBG) : {}), [isReportActionLinked]);
// When active action changes, we need to update the `isContextMenuActive` state
const isActiveReportActionForMenu = ReportActionContextMenu.isActiveReportAction(props.action.reportActionID);
@@ -197,6 +201,13 @@ function ReportActionItem(props) {
Report.expandURLPreview(props.report.reportID, props.action.reportActionID);
}, [props.action, props.report.reportID]);
+ useEffect(() => {
+ if (!props.draftMessage || !ReportActionsUtils.isDeletedAction(props.action)) {
+ return;
+ }
+ Report.saveReportActionDraft(props.report.reportID, props.action, '');
+ }, [props.draftMessage, props.action, props.report.reportID]);
+
// Hide the message if it is being moderated for a higher offense, or is hidden by a moderator
// Removed messages should not be shown anyway and should not need this flow
const latestDecision = lodashGet(props, ['action', 'message', 0, 'moderationDecision', 'decision'], '');
@@ -594,7 +605,7 @@ function ReportActionItem(props) {
disabled={Boolean(props.draftMessage)}
>
{(hovered) => (
-
+
{props.shouldDisplayNewMarker && }
-
+ ReportActions.clearReportActionErrors(props.report.reportID, props.action)}
pendingAction={props.draftMessage ? null : props.action.pendingAction}
@@ -643,7 +649,7 @@ function ReportActionItem(props) {
/>
)}
- {renderReportActionItem(hovered, isWhisper, hasErrors)}
+ {renderReportActionItem(hovered || isReportActionLinked, isWhisper, hasErrors)}
@@ -716,6 +722,7 @@ export default compose(
prevProps.report.managerID === nextProps.report.managerID &&
prevProps.report.managerEmail === nextProps.report.managerEmail &&
prevProps.shouldHideThreadDividerLine === nextProps.shouldHideThreadDividerLine &&
- lodashGet(prevProps.report, 'total', 0) === lodashGet(nextProps.report, 'total', 0),
+ lodashGet(prevProps.report, 'total', 0) === lodashGet(nextProps.report, 'total', 0) &&
+ prevProps.linkedReportActionID === nextProps.linkedReportActionID,
),
);
diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js
index 1f61b44841bc..24501e307759 100644
--- a/src/pages/home/report/ReportActionItemFragment.js
+++ b/src/pages/home/report/ReportActionItemFragment.js
@@ -1,5 +1,4 @@
import React, {memo} from 'react';
-import {ActivityIndicator, View} from 'react-native';
import PropTypes from 'prop-types';
import Str from 'expensify-common/lib/str';
import reportActionFragmentPropTypes from './reportActionFragmentPropTypes';
@@ -27,9 +26,6 @@ const propTypes = {
/** The message fragment needing to be displayed */
fragment: reportActionFragmentPropTypes.isRequired,
- /** Is this fragment an attachment? */
- isAttachment: PropTypes.bool,
-
/** If this fragment is attachment than has info? */
attachmentInfo: PropTypes.shape({
/** The file name of attachment */
@@ -48,9 +44,6 @@ const propTypes = {
/** Message(text) of an IOU report action */
iouMessage: PropTypes.string,
- /** Does this fragment belong to a reportAction that has not yet loaded? */
- loading: PropTypes.bool,
-
/** The reportAction's source */
source: PropTypes.oneOf(['Chronos', 'email', 'ios', 'android', 'web', 'email', '']),
@@ -76,7 +69,6 @@ const propTypes = {
};
const defaultProps = {
- isAttachment: false,
attachmentInfo: {
name: '',
size: 0,
@@ -84,7 +76,6 @@ const defaultProps = {
source: '',
},
iouMessage: '',
- loading: false,
isSingleLine: false,
source: '',
style: [],
@@ -96,20 +87,6 @@ const defaultProps = {
function ReportActionItemFragment(props) {
switch (props.fragment.type) {
case 'COMMENT': {
- // If this is an attachment placeholder, return the placeholder component
- if (props.isAttachment && props.loading) {
- return Str.isImage(props.attachmentInfo.name) ? (
- `} />
- ) : (
-
-
-
- );
- }
const {html, text} = props.fragment;
const isPendingDelete = props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && props.network.isOffline;
diff --git a/src/pages/home/report/ReportActionItemMessage.js b/src/pages/home/report/ReportActionItemMessage.js
index bc92889158d0..a3d8494c38de 100644
--- a/src/pages/home/report/ReportActionItemMessage.js
+++ b/src/pages/home/report/ReportActionItemMessage.js
@@ -54,14 +54,12 @@ function ReportActionItemMessage(props) {
))
diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js
index 3ceaf69b52f5..24ea8c59f545 100644
--- a/src/pages/home/report/ReportActionItemMessageEdit.js
+++ b/src/pages/home/report/ReportActionItemMessageEdit.js
@@ -205,12 +205,10 @@ function ReportActionItemMessageEdit(props) {
}
setDraft((prevDraft) => {
if (newDraftInput !== newDraft) {
- setSelection((prevSelection) => {
- const remainder = prevDraft.slice(prevSelection.end).length;
- return {
- start: newDraft.length - remainder,
- end: newDraft.length - remainder,
- };
+ const remainder = ComposerUtils.getCommonSuffixLength(prevDraft, newDraft);
+ setSelection({
+ start: newDraft.length - remainder,
+ end: newDraft.length - remainder,
});
}
return newDraft;
diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js
index ca0467143e98..162f28021b94 100644
--- a/src/pages/home/report/ReportActionItemSingle.js
+++ b/src/pages/home/report/ReportActionItemSingle.js
@@ -241,8 +241,6 @@ function ReportActionItemSingle(props) {
key={`person-${props.action.reportActionID}-${index}`}
accountID={actorAccountID}
fragment={fragment}
- isAttachment={props.action.isAttachment}
- isLoading={props.action.isLoading}
delegateAccountID={props.action.delegateAccountID}
isSingleLine
actorIcon={icon}
diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js
index 4940134abe7d..0af22d91ccb6 100644
--- a/src/pages/home/report/ReportActionsList.js
+++ b/src/pages/home/report/ReportActionsList.js
@@ -2,6 +2,8 @@ import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import _ from 'underscore';
+import {useRoute} from '@react-navigation/native';
+import lodashGet from 'lodash/get';
import CONST from '../../../CONST';
import InvertedFlatList from '../../../components/InvertedFlatList';
import {withPersonalDetails} from '../../../components/OnyxProvider';
@@ -118,6 +120,8 @@ function ReportActionsList({
}) {
const reportScrollManager = useReportScrollManager();
const {translate} = useLocalize();
+ const {isOffline} = useNetwork();
+ const route = useRoute();
const opacity = useSharedValue(0);
const userActiveSince = useRef(null);
const [currentUnreadMarker, setCurrentUnreadMarker] = useState(null);
@@ -125,6 +129,7 @@ function ReportActionsList({
const readActionSkipped = useRef(false);
const firstRenderRef = useRef(true);
const reportActionSize = useRef(sortedReportActions.length);
+ const linkedReportActionID = lodashGet(route, 'params.reportActionID', '');
// This state is used to force a re-render when the user manually marks a message as unread
// by using a timestamp you can force re-renders without having to worry about if another message was marked as unread before
@@ -305,6 +310,7 @@ function ReportActionsList({
reportAction={reportAction}
index={index}
report={report}
+ linkedReportActionID={linkedReportActionID}
hasOutstandingIOU={hasOutstandingIOU}
sortedReportActions={sortedReportActions}
mostRecentIOUReportActionID={mostRecentIOUReportActionID}
@@ -313,7 +319,7 @@ function ReportActionsList({
/>
);
},
- [report, hasOutstandingIOU, sortedReportActions, mostRecentIOUReportActionID, messageManuallyMarkedUnread, shouldHideThreadDividerLine, currentUnreadMarker],
+ [report, linkedReportActionID, hasOutstandingIOU, sortedReportActions, mostRecentIOUReportActionID, messageManuallyMarkedUnread, shouldHideThreadDividerLine, currentUnreadMarker],
);
// Native mobile does not render updates flatlist the changes even though component did update called.
diff --git a/src/pages/home/report/ReportActionsListItemRenderer.js b/src/pages/home/report/ReportActionsListItemRenderer.js
index f70714076ad2..40b9ee9142b7 100644
--- a/src/pages/home/report/ReportActionsListItemRenderer.js
+++ b/src/pages/home/report/ReportActionsListItemRenderer.js
@@ -33,11 +33,15 @@ const propTypes = {
/** Should we display the new marker on top of the comment? */
shouldDisplayNewMarker: PropTypes.bool.isRequired,
+
+ /** Linked report action ID */
+ linkedReportActionID: PropTypes.string,
};
const defaultProps = {
mostRecentIOUReportActionID: '',
hasOutstandingIOU: false,
+ linkedReportActionID: '',
};
function ReportActionsListItemRenderer({
@@ -49,6 +53,7 @@ function ReportActionsListItemRenderer({
mostRecentIOUReportActionID,
shouldHideThreadDividerLine,
shouldDisplayNewMarker,
+ linkedReportActionID,
}) {
const shouldDisplayParentAction =
reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED &&
@@ -67,6 +72,7 @@ function ReportActionsListItemRenderer({
shouldHideThreadDividerLine={shouldHideThreadDividerLine}
report={report}
action={reportAction}
+ linkedReportActionID={linkedReportActionID}
displayAsGroup={ReportActionsUtils.isConsecutiveActionMadeByPreviousActor(sortedReportActions, index)}
shouldDisplayNewMarker={shouldDisplayNewMarker}
shouldShowSubscriptAvatar={
diff --git a/src/pages/home/report/reportActionPropTypes.js b/src/pages/home/report/reportActionPropTypes.js
index e0c3aebe718c..4d4809cd781f 100644
--- a/src/pages/home/report/reportActionPropTypes.js
+++ b/src/pages/home/report/reportActionPropTypes.js
@@ -23,9 +23,6 @@ export default {
IOUTransactionID: PropTypes.string,
}),
- /** Whether we have received a response back from the server */
- isLoading: PropTypes.bool,
-
/** Error message that's come back from the server. */
error: PropTypes.string,
diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js
index 984654c6b506..2b82650a0729 100644
--- a/src/pages/home/sidebar/SidebarLinks.js
+++ b/src/pages/home/sidebar/SidebarLinks.js
@@ -141,7 +141,12 @@ class SidebarLinks extends React.PureComponent {
// or when clicking the active LHN row on large screens
// or when continuously clicking different LHNs, only apply to small screen
// since getTopmostReportId always returns on other devices
- if (this.props.isCreateMenuOpen || option.reportID === Navigation.getTopmostReportId() || (this.props.isSmallScreenWidth && this.props.isActiveReport(option.reportID))) {
+ const reportActionID = Navigation.getTopmostReportActionId();
+ if (
+ this.props.isCreateMenuOpen ||
+ (option.reportID === Navigation.getTopmostReportId() && !reportActionID) ||
+ (this.props.isSmallScreenWidth && this.props.isActiveReport(option.reportID) && !reportActionID)
+ ) {
return;
}
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(option.reportID));
diff --git a/src/pages/home/sidebar/SidebarScreen/index.js b/src/pages/home/sidebar/SidebarScreen/index.js
index 08888352ddff..5006136a633e 100755
--- a/src/pages/home/sidebar/SidebarScreen/index.js
+++ b/src/pages/home/sidebar/SidebarScreen/index.js
@@ -1,28 +1,13 @@
import React, {useCallback, useRef} from 'react';
-import {InteractionManager} from 'react-native';
-import {useFocusEffect} from '@react-navigation/native';
import sidebarPropTypes from './sidebarPropTypes';
import BaseSidebarScreen from './BaseSidebarScreen';
import FloatingActionButtonAndPopover from './FloatingActionButtonAndPopover';
import FreezeWrapper from '../../../../libs/Navigation/FreezeWrapper';
import withWindowDimensions from '../../../../components/withWindowDimensions';
-import StatusBar from '../../../../libs/StatusBar';
-import themeColors from '../../../../styles/themes/default';
function SidebarScreen(props) {
const popoverModal = useRef(null);
- useFocusEffect(
- useCallback(() => {
- const previousColor = StatusBar.getBackgroundColor();
- InteractionManager.runAfterInteractions(() => StatusBar.setBackgroundColor(themeColors.sidebar));
-
- return () => {
- InteractionManager.runAfterInteractions(() => StatusBar.setBackgroundColor(previousColor));
- };
- }, []),
- );
-
/**
* Method to hide popover when dragover.
*/
diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js
index 4de4e9bb9148..128782093718 100644
--- a/src/pages/iou/ReceiptSelector/index.native.js
+++ b/src/pages/iou/ReceiptSelector/index.native.js
@@ -99,7 +99,6 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator})
const appState = useRef(AppState.currentState);
const iouType = lodashGet(route, 'params.iouType', '');
- const reportID = lodashGet(route, 'params.reportID', '');
const pageIndex = lodashGet(route, 'params.pageIndex', 1);
const {translate} = useLocalize();
@@ -223,13 +222,13 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator})
return;
}
- IOU.navigateToNextPage(iou, iouType, reportID, report, route.path);
+ IOU.navigateToNextPage(iou, iouType, report, route.path);
})
.catch((error) => {
showCameraAlert();
Log.warn('Error taking photo', error);
});
- }, [flash, iouType, iou, report, reportID, translate, transactionID, route.path]);
+ }, [flash, iouType, iou, report, translate, transactionID, route.path]);
CameraPermission.getCameraPermissionStatus().then((permissionStatus) => {
setPermissions(permissionStatus);
diff --git a/src/pages/iou/WaypointEditor.js b/src/pages/iou/WaypointEditor.js
index 54269c197c1c..dda160d61795 100644
--- a/src/pages/iou/WaypointEditor.js
+++ b/src/pages/iou/WaypointEditor.js
@@ -250,7 +250,6 @@ function WaypointEditor({route: {params: {iouType = '', transactionID = '', wayp
state: null,
}}
predefinedPlaces={recentWaypoints}
- resultTypes=""
/>
diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.js b/src/pages/iou/steps/MoneyRequestAmountForm.js
index c4fc29957179..dab47a36f186 100644
--- a/src/pages/iou/steps/MoneyRequestAmountForm.js
+++ b/src/pages/iou/steps/MoneyRequestAmountForm.js
@@ -70,6 +70,7 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu
const textInput = useRef(null);
+ const decimals = CurrencyUtils.getCurrencyDecimals(currency);
const selectedAmountAsString = amount ? CurrencyUtils.convertToFrontendAmount(amount).toString() : '';
const [currentAmount, setCurrentAmount] = useState(selectedAmountAsString);
@@ -109,7 +110,7 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu
if (!currency || !_.isNumber(amount)) {
return;
}
- const amountAsStringForState = CurrencyUtils.convertToFrontendAmount(amount).toString();
+ const amountAsStringForState = amount ? CurrencyUtils.convertToFrontendAmount(amount).toString() : '';
setCurrentAmount(amountAsStringForState);
setSelection({
start: amountAsStringForState.length,
@@ -123,26 +124,43 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu
* Sets the selection and the amount accordingly to the value passed to the input
* @param {String} newAmount - Changed amount from user input
*/
- const setNewAmount = (newAmount) => {
- // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value
- // More info: https://github.com/Expensify/App/issues/16974
- const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount);
- // Use a shallow copy of selection to trigger setSelection
- // More info: https://github.com/Expensify/App/issues/16385
- if (!MoneyRequestUtils.validateAmount(newAmountWithoutSpaces)) {
- setSelection((prevSelection) => ({...prevSelection}));
+ const setNewAmount = useCallback(
+ (newAmount) => {
+ // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value
+ // More info: https://github.com/Expensify/App/issues/16974
+ const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount);
+ // Use a shallow copy of selection to trigger setSelection
+ // More info: https://github.com/Expensify/App/issues/16385
+ if (!MoneyRequestUtils.validateAmount(newAmountWithoutSpaces, decimals)) {
+ setSelection((prevSelection) => ({...prevSelection}));
+ return;
+ }
+ const checkInvalidAmount = isAmountValid(newAmountWithoutSpaces);
+ setIsInvalidAmount(checkInvalidAmount);
+ setFormError(checkInvalidAmount ? 'iou.error.invalidAmount' : '');
+ setCurrentAmount((prevAmount) => {
+ const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces);
+ const isForwardDelete = prevAmount.length > strippedAmount.length && forwardDeletePressedRef.current;
+ setSelection((prevSelection) => getNewSelection(prevSelection, isForwardDelete ? strippedAmount.length : prevAmount.length, strippedAmount.length));
+ return strippedAmount;
+ });
+ },
+ [decimals],
+ );
+
+ // Modifies the amount to match the decimals for changed currency.
+ useEffect(() => {
+ // If the changed currency supports decimals, we can return
+ if (MoneyRequestUtils.validateAmount(currentAmount, decimals)) {
return;
}
- const checkInvalidAmount = isAmountValid(newAmountWithoutSpaces);
- setIsInvalidAmount(checkInvalidAmount);
- setFormError(checkInvalidAmount ? 'iou.error.invalidAmount' : '');
- setCurrentAmount((prevAmount) => {
- const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces);
- const isForwardDelete = prevAmount.length > strippedAmount.length && forwardDeletePressedRef.current;
- setSelection((prevSelection) => getNewSelection(prevSelection, isForwardDelete ? strippedAmount.length : prevAmount.length, strippedAmount.length));
- return strippedAmount;
- });
- };
+
+ // If the changed currency doesn't support decimals, we can strip the decimals
+ setNewAmount(MoneyRequestUtils.stripDecimalsFromAmount(currentAmount));
+
+ // we want to update only when decimals change (setNewAmount also changes when decimals change).
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [setNewAmount]);
/**
* Update amount with number or Backspace pressed for BigNumberPad.
@@ -167,7 +185,7 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu
const newAmount = MoneyRequestUtils.addLeadingZero(`${currentAmount.substring(0, selection.start)}${key}${currentAmount.substring(selection.end)}`);
setNewAmount(newAmount);
},
- [currentAmount, selection, shouldUpdateSelection],
+ [currentAmount, selection, shouldUpdateSelection, setNewAmount],
);
/**
@@ -266,10 +284,11 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu
) : null}
diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
index 170ee042bffa..05b206ce4147 100755
--- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
+++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
@@ -143,7 +143,10 @@ function MoneyRequestParticipantsSelector({
if (newChatOptions.userToInvite && !OptionsListUtils.isCurrentUser(newChatOptions.userToInvite)) {
newSections.push({
undefined,
- data: [newChatOptions.userToInvite],
+ data: _.map([newChatOptions.userToInvite], (participant) => {
+ const isPolicyExpenseChat = lodashGet(participant, 'isPolicyExpenseChat', false);
+ return isPolicyExpenseChat ? OptionsListUtils.getPolicyExpenseReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, personalDetails);
+ }),
shouldShow: true,
indexOffset,
});
@@ -201,30 +204,8 @@ function MoneyRequestParticipantsSelector({
}
onAddParticipants(newSelectedOptions);
-
- const chatOptions = OptionsListUtils.getFilteredOptions(
- reports,
- personalDetails,
- betas,
- isOptionInList ? searchTerm : '',
- newSelectedOptions,
- CONST.EXPENSIFY_EMAILS,
-
- // If we are using this component in the "Request money" flow then we pass the includeOwnedWorkspaceChats argument so that the current user
- // sees the option to request money from their admin on their own Workspace Chat.
- iouType === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST,
-
- // We don't want to include any P2P options like personal details or reports that are not workspace chats for certain features.
- !isDistanceRequest,
- );
-
- setNewChatOptions({
- recentReports: chatOptions.recentReports,
- personalDetails: chatOptions.personalDetails,
- userToInvite: chatOptions.userToInvite,
- });
},
- [participants, onAddParticipants, reports, personalDetails, betas, searchTerm, iouType, isDistanceRequest],
+ [participants, onAddParticipants],
);
const headerMessage = OptionsListUtils.getHeaderMessage(
diff --git a/src/pages/iou/steps/NewRequestAmountPage.js b/src/pages/iou/steps/NewRequestAmountPage.js
index c9e2ca464303..ae319f5a73bb 100644
--- a/src/pages/iou/steps/NewRequestAmountPage.js
+++ b/src/pages/iou/steps/NewRequestAmountPage.js
@@ -22,7 +22,6 @@ import HeaderWithBackButton from '../../../components/HeaderWithBackButton';
import ScreenWrapper from '../../../components/ScreenWrapper';
import {iouPropTypes, iouDefaultProps} from '../propTypes';
import CONST from '../../../CONST';
-import FullScreenLoadingIndicator from '../../../components/FullscreenLoadingIndicator';
const propTypes = {
/** React Navigation route */
@@ -161,10 +160,6 @@ function NewRequestAmountPage({route, iou, report, selectedTab}) {
/>
);
- if (!lodashGet(iou, 'didInitCurrency', false)) {
- return ;
- }
-
// ScreenWrapper is only needed in edit mode because we have a dedicated route for the edit amount page (MoneyRequestEditAmountPage).
// The rest of the cases this component is rendered through which has it's own ScreenWrapper
if (!isEditing) {
diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js
index b8c817350a38..7e8baba5a9ce 100644
--- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js
+++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js
@@ -25,6 +25,7 @@ import ValidateCodeForm from './ValidateCodeForm';
import ROUTES from '../../../../ROUTES';
import FullscreenLoadingIndicator from '../../../../components/FullscreenLoadingIndicator';
import FullPageNotFoundView from '../../../../components/BlockingViews/FullPageNotFoundView';
+import CONST from '../../../../CONST';
const propTypes = {
/* Onyx Props */
@@ -131,7 +132,22 @@ class ContactMethodDetailsPage extends Component {
* @returns {string}
*/
getContactMethod() {
- return decodeURIComponent(lodashGet(this.props.route, 'params.contactMethod'));
+ const contactMethod = lodashGet(this.props.route, 'params.contactMethod');
+
+ // We find the number of times the url is encoded based on the last % sign and remove them.
+ const lastPercentIndex = contactMethod.lastIndexOf('%');
+ const encodePercents = contactMethod.substring(lastPercentIndex).match(new RegExp('25', 'g'));
+ let numberEncodePercents = encodePercents ? encodePercents.length : 0;
+ const beforeAtSign = contactMethod.substring(0, lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, (match) => {
+ if (numberEncodePercents > 0) {
+ numberEncodePercents--;
+ return '%';
+ }
+ return match;
+ });
+ const afterAtSign = contactMethod.substring(lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, '%');
+
+ return decodeURIComponent(beforeAtSign + afterAtSign);
}
/**
@@ -230,6 +246,7 @@ class ContactMethodDetailsPage extends Component {
const isDefaultContactMethod = this.props.session.email === loginData.partnerUserID;
const hasMagicCodeBeenSent = lodashGet(this.props.loginList, [contactMethod, 'validateCodeSent'], false);
const isFailedAddContactMethod = Boolean(lodashGet(loginData, 'errorFields.addedLogin'));
+ const isFailedRemovedContactMethod = Boolean(lodashGet(loginData, 'errorFields.deletedLogin'));
return (
{isFailedAddContactMethod && (
@@ -289,9 +306,9 @@ class ContactMethodDetailsPage extends Component {
{isDefaultContactMethod ? (
User.clearContactMethodErrors(contactMethod, 'defaultLogin')}
+ onClose={() => User.clearContactMethodErrors(contactMethod, isFailedRemovedContactMethod ? 'deletedLogin' : 'defaultLogin')}
>
{this.props.translate('contacts.yourDefaultContactMethod')}
diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js
index 300bd23cc2e5..bcea33d9c366 100644
--- a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js
+++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js
@@ -111,7 +111,6 @@ function BaseValidateCodeForm(props) {
const resendValidateCode = () => {
User.requestContactMethodValidateCode(props.contactMethod);
setValidateCode('');
- inputValidateCodeRef.current.clear();
inputValidateCodeRef.current.focus();
};
diff --git a/src/pages/settings/Profile/PersonalDetails/AddressPage.js b/src/pages/settings/Profile/PersonalDetails/AddressPage.js
index 7dadbb4608d9..391516e3d63b 100644
--- a/src/pages/settings/Profile/PersonalDetails/AddressPage.js
+++ b/src/pages/settings/Profile/PersonalDetails/AddressPage.js
@@ -1,6 +1,6 @@
import lodashGet from 'lodash/get';
import _ from 'underscore';
-import React, {useState, useCallback, useEffect} from 'react';
+import React, {useState, useCallback, useEffect, useMemo} from 'react';
import PropTypes from 'prop-types';
import {View} from 'react-native';
import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST';
@@ -71,16 +71,24 @@ function updateAddress(values) {
function AddressPage({privatePersonalDetails, route}) {
usePrivatePersonalDetails();
const {translate} = useLocalize();
+ const address = useMemo(() => lodashGet(privatePersonalDetails, 'address') || {}, [privatePersonalDetails]);
const countryFromUrl = lodashGet(route, 'params.country');
- const [currentCountry, setCurrentCountry] = useState(countryFromUrl || PersonalDetails.getCountryISO(lodashGet(privatePersonalDetails, 'address.country')));
- const isUSAForm = currentCountry === CONST.COUNTRY.US;
+ const [currentCountry, setCurrentCountry] = useState(address.country);
const zipSampleFormat = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, [currentCountry, 'samples'], '');
const zipFormat = translate('common.zipCodeExampleFormat', {zipSampleFormat});
-
- const address = lodashGet(privatePersonalDetails, 'address') || {};
+ const isUSAForm = currentCountry === CONST.COUNTRY.US;
const isLoadingPersonalDetails = lodashGet(privatePersonalDetails, 'isLoading', true);
const [street1, street2] = (address.street || '').split('\n');
const [state, setState] = useState(address.state);
+
+ useEffect(() => {
+ if (!address) {
+ return;
+ }
+ setState(address.state);
+ setCurrentCountry(address.country);
+ }, [address]);
+
/**
* @param {Function} translate - translate function
* @param {Boolean} isUSAForm - selected country ISO code is US
diff --git a/src/pages/settings/Report/NotificationPreferencePage.js b/src/pages/settings/Report/NotificationPreferencePage.js
index b6dbf5f42c83..a69b227470d6 100644
--- a/src/pages/settings/Report/NotificationPreferencePage.js
+++ b/src/pages/settings/Report/NotificationPreferencePage.js
@@ -54,7 +54,7 @@ function NotificationPreferencePage(props) {
/>
Report.updateNotificationPreferenceAndNavigate(props.report.reportID, props.report.notificationPreference, option.value)}
+ onSelectRow={(option) => Report.updateNotificationPreference(props.report.reportID, props.report.notificationPreference, option.value, true)}
hideSectionHeaders
optionHoveredStyle={{
...styles.hoveredComponentBG,
diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/VerifyStep.js b/src/pages/settings/Security/TwoFactorAuth/Steps/VerifyStep.js
index eb08a8da316b..7783b6c58ace 100644
--- a/src/pages/settings/Security/TwoFactorAuth/Steps/VerifyStep.js
+++ b/src/pages/settings/Security/TwoFactorAuth/Steps/VerifyStep.js
@@ -1,6 +1,7 @@
import React, {useEffect} from 'react';
import {withOnyx} from 'react-native-onyx';
import {ScrollView, View} from 'react-native';
+import PropTypes from 'prop-types';
import * as Session from '../../../../../libs/actions/Session';
import styles from '../../../../../styles/styles';
import Button from '../../../../../components/Button';
@@ -22,7 +23,14 @@ import {defaultAccount, TwoFactorAuthPropTypes} from '../TwoFactorAuthPropTypes'
const TROUBLESHOOTING_LINK = 'https://community.expensify.com/discussion/7736/faq-troubleshooting-two-factor-authentication-issues/p1?new=1';
-function VerifyStep({account = defaultAccount}) {
+const defaultProps = {
+ account: defaultAccount,
+ session: {
+ email: null,
+ },
+};
+
+function VerifyStep({account, session}) {
const {translate} = useLocalize();
const formRef = React.useRef(null);
@@ -61,7 +69,7 @@ function VerifyStep({account = defaultAccount}) {
* @returns {string}
*/
function buildAuthenticatorUrl() {
- return `otpauth://totp/Expensify:${account.primaryLogin}?secret=${account.twoFactorAuthSecretKey}&issuer=Expensify`;
+ return `otpauth://totp/Expensify:${account.primaryLogin || session.email}?secret=${account.twoFactorAuthSecretKey}&issuer=Expensify`;
}
return (
@@ -128,9 +136,20 @@ function VerifyStep({account = defaultAccount}) {
);
}
-VerifyStep.propTypes = TwoFactorAuthPropTypes;
+VerifyStep.propTypes = {
+ /** Information about the users account that is logging in */
+ account: TwoFactorAuthPropTypes.account,
+
+ /** Session of currently logged in user */
+ session: PropTypes.shape({
+ /** Email address */
+ email: PropTypes.string.isRequired,
+ }),
+};
+VerifyStep.defaultProps = defaultProps;
// eslint-disable-next-line rulesdir/onyx-props-must-have-default
export default withOnyx({
account: {key: ONYXKEYS.ACCOUNT},
+ session: {key: ONYXKEYS.SESSION},
})(VerifyStep);
diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js
index 5e57968ab77a..39495911b8dc 100644
--- a/src/pages/workspace/WorkspaceInvitePage.js
+++ b/src/pages/workspace/WorkspaceInvitePage.js
@@ -87,7 +87,7 @@ function WorkspaceInvitePage(props) {
// Update selectedOptions with the latest personalDetails and policyMembers information
const detailsMap = {};
- _.forEach(inviteOptions.personalDetails, (detail) => (detailsMap[detail.login] = OptionsListUtils.formatMemberForList(detail, false)));
+ _.forEach(inviteOptions.personalDetails, (detail) => (detailsMap[detail.login] = OptionsListUtils.formatMemberForList(detail)));
const newSelectedOptions = [];
_.forEach(selectedOptions, (option) => {
newSelectedOptions.push(_.has(detailsMap, option.login) ? {...detailsMap[option.login], isSelected: true} : option);
@@ -114,7 +114,7 @@ function WorkspaceInvitePage(props) {
// Filtering out selected users from the search results
const selectedLogins = _.map(selectedOptions, ({login}) => login);
const personalDetailsWithoutSelected = _.filter(personalDetails, ({login}) => !_.contains(selectedLogins, login));
- const personalDetailsFormatted = _.map(personalDetailsWithoutSelected, (personalDetail) => OptionsListUtils.formatMemberForList(personalDetail, false));
+ const personalDetailsFormatted = _.map(personalDetailsWithoutSelected, OptionsListUtils.formatMemberForList);
const hasUnselectedUserToInvite = userToInvite && !_.contains(selectedLogins, userToInvite.login);
sections.push({
@@ -128,7 +128,7 @@ function WorkspaceInvitePage(props) {
if (hasUnselectedUserToInvite) {
sections.push({
title: undefined,
- data: [OptionsListUtils.formatMemberForList(userToInvite, false)],
+ data: [OptionsListUtils.formatMemberForList(userToInvite)],
shouldShow: true,
indexOffset,
});
diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js
index 9eff4eba47ee..319099a9e1c8 100644
--- a/src/pages/workspace/WorkspaceMembersPage.js
+++ b/src/pages/workspace/WorkspaceMembersPage.js
@@ -322,11 +322,13 @@ function WorkspaceMembersPage(props) {
{props.translate('common.admin')}
) : null,
- avatar: {
- source: UserUtils.getAvatar(details.avatar, accountID),
- name: details.login,
- type: CONST.ICON_TYPE_AVATAR,
- },
+ icons: [
+ {
+ source: UserUtils.getAvatar(details.avatar, accountID),
+ name: details.login,
+ type: CONST.ICON_TYPE_AVATAR,
+ },
+ ],
errors: policyMember.errors,
pendingAction: policyMember.pendingAction,
});
diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js
index d2cffdd471d2..bdbcd1919257 100644
--- a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js
+++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js
@@ -2,6 +2,7 @@ import React from 'react';
import {Keyboard, View} from 'react-native';
import _ from 'underscore';
import lodashGet from 'lodash/get';
+import {withOnyx} from 'react-native-onyx';
import ONYXKEYS from '../../../ONYXKEYS';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
import styles from '../../../styles/styles';
@@ -19,13 +20,19 @@ import Form from '../../../components/Form';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
import getPermittedDecimalSeparator from '../../../libs/getPermittedDecimalSeparator';
+import * as BankAccounts from '../../../libs/actions/BankAccounts';
+import * as ReimbursementAccountProps from '../../ReimbursementAccount/reimbursementAccountPropTypes';
const propTypes = {
+ /** Bank account attached to free plan */
+ reimbursementAccount: ReimbursementAccountProps.reimbursementAccountPropTypes,
+
...policyPropTypes,
...withLocalizePropTypes,
};
const defaultProps = {
+ reimbursementAccount: {},
...policyDefaultProps,
};
@@ -34,6 +41,33 @@ class WorkspaceRateAndUnitPage extends React.Component {
super(props);
this.submit = this.submit.bind(this);
this.validate = this.validate.bind(this);
+
+ this.state = {
+ rate: 0,
+ unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
+ };
+ }
+
+ componentDidMount() {
+ this.resetRateAndUnit();
+
+ if (lodashGet(this.props, 'policy.customUnits', []).length !== 0) {
+ return;
+ }
+ // When this page is accessed directly from url, the policy.customUnits data won't be available,
+ // and we should trigger Policy.openWorkspaceReimburseView to get the data
+
+ BankAccounts.setReimbursementAccountLoading(true);
+ Policy.openWorkspaceReimburseView(this.props.policy.id);
+ }
+
+ componentDidUpdate(prevProps) {
+ // We should update rate input when rate data is fetched
+ if (prevProps.reimbursementAccount.isLoading === this.props.reimbursementAccount.isLoading) {
+ return;
+ }
+
+ this.resetRateAndUnit();
}
getUnitItems() {
@@ -43,6 +77,32 @@ class WorkspaceRateAndUnitPage extends React.Component {
];
}
+ getRateDisplayValue(value) {
+ const numValue = this.getNumericValue(value);
+ if (Number.isNaN(numValue)) {
+ return '';
+ }
+ return numValue.toString().replace('.', this.props.toLocaleDigit('.')).substring(0, value.length);
+ }
+
+ getNumericValue(value) {
+ const numValue = parseFloat(value.toString().replace(',', '.'));
+ if (Number.isNaN(numValue)) {
+ return NaN;
+ }
+ return numValue.toFixed(3);
+ }
+
+ resetRateAndUnit() {
+ const distanceCustomUnit = _.find(lodashGet(this.props, 'policy.customUnits', {}), (unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
+ const distanceCustomRate = _.find(lodashGet(distanceCustomUnit, 'rates', {}), (rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE);
+
+ this.setState({
+ rate: PolicyUtils.getUnitRateValue(distanceCustomRate, this.props.toLocaleDigit),
+ unit: lodashGet(distanceCustomUnit, 'attributes.unit', CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES),
+ });
+ }
+
saveUnitAndRate(unit, rate) {
const distanceCustomUnit = _.find(lodashGet(this.props, 'policy.customUnits', {}), (u) => u.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
if (!distanceCustomUnit) {
@@ -65,8 +125,8 @@ class WorkspaceRateAndUnitPage extends React.Component {
Policy.updateWorkspaceCustomUnitAndRate(this.props.policy.id, distanceCustomUnit, newCustomUnit, this.props.policy.lastModified);
}
- submit(values) {
- this.saveUnitAndRate(values.unit, values.rate);
+ submit() {
+ this.saveUnitAndRate(this.state.unit, this.state.rate);
Keyboard.dismiss();
Navigation.goBack(ROUTES.WORKSPACE_REIMBURSE.getRoute(this.props.policy.id));
}
@@ -126,13 +186,15 @@ class WorkspaceRateAndUnitPage extends React.Component {
autoCorrect={false}
keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD}
maxLength={12}
+ value={this.state.rate}
+ onChangeText={(value) => this.setState({rate: value})}
/>
this.setState({unit: value})}
/>
@@ -146,4 +208,13 @@ class WorkspaceRateAndUnitPage extends React.Component {
WorkspaceRateAndUnitPage.propTypes = propTypes;
WorkspaceRateAndUnitPage.defaultProps = defaultProps;
-export default compose(withPolicy, withLocalize, withNetwork())(WorkspaceRateAndUnitPage);
+export default compose(
+ withPolicy,
+ withLocalize,
+ withNetwork(),
+ withOnyx({
+ reimbursementAccount: {
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ },
+ }),
+)(WorkspaceRateAndUnitPage);
diff --git a/src/stories/Button.stories.js b/src/stories/Button.stories.js
index 52212e673f0f..9fec08800f0a 100644
--- a/src/stories/Button.stories.js
+++ b/src/stories/Button.stories.js
@@ -1,5 +1,8 @@
+/* eslint-disable react/jsx-props-no-spreading */
import React, {useCallback, useState} from 'react';
+import {View} from 'react-native';
import Button from '../components/Button';
+import Text from '../components/Text';
/**
* We use the Component Story Format for writing stories. Follow the docs here:
@@ -28,7 +31,6 @@ function PressOnEnter(props) {
}, []);
return (
+ Both buttons will trigger on press of Enter as the Enter event will bubble across all instances of button.
+
+
+
+
+ >
+ );
+}
+
Default.args = {
text: 'Save & Continue',
success: true,
@@ -53,5 +73,12 @@ PressOnEnter.args = {
success: true,
};
+PressOnEnterWithBubbling.args = {
+ pressOnEnter: true,
+ success: true,
+ medium: true,
+ allowBubble: true,
+};
+
export default story;
-export {Default, Loading, PressOnEnter};
+export {Default, Loading, PressOnEnter, PressOnEnterWithBubbling};
diff --git a/src/stories/ButtonWithDropdownMenu.stories.js b/src/stories/ButtonWithDropdownMenu.stories.js
index 7629a6ff0e17..88fa73c20fa1 100644
--- a/src/stories/ButtonWithDropdownMenu.stories.js
+++ b/src/stories/ButtonWithDropdownMenu.stories.js
@@ -21,9 +21,13 @@ function Template(args) {
const Default = Template.bind({});
Default.args = {
buttonText: 'Pay using Expensify',
+ onPress: (e, item) => {
+ alert(`Button ${item} is pressed.`);
+ },
+ pressOnEnter: true,
options: [
- {value: 1, text: 'One'},
- {value: 2, text: 'Two'},
+ {value: 'One', text: 'One'},
+ {value: 'Two', text: 'Two'},
],
};
diff --git a/src/styles/StyleUtils.ts b/src/styles/StyleUtils.ts
index 190f18f8d969..0832b6a3978c 100644
--- a/src/styles/StyleUtils.ts
+++ b/src/styles/StyleUtils.ts
@@ -577,7 +577,7 @@ function getEmojiPickerStyle(isSmallScreenWidth: boolean): ViewStyle | CSSProper
/**
* Generate the styles for the ReportActionItem wrapper view.
*/
-function getReportActionItemStyle(isHovered = false, isLoading = false): ViewStyle | CSSProperties {
+function getReportActionItemStyle(isHovered = false): ViewStyle | CSSProperties {
// TODO: Remove this "eslint-disable-next" once the theme switching migration is done and styles are fully typed (GH Issue: https://github.com/Expensify/App/issues/27337)
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return {
@@ -587,7 +587,7 @@ function getReportActionItemStyle(isHovered = false, isLoading = false): ViewSty
? themeColors.hoverComponentBG
: // Warning: Setting this to a non-transparent color will cause unread indicator to break on Android
themeColors.transparent,
- opacity: isLoading ? 0.5 : 1,
+ opacity: 1,
...styles.cursorInitial,
};
}
@@ -678,10 +678,10 @@ function extractValuesFromRGB(color: string): number[] | null {
* @returns The theme color as an RGB value.
*/
function getThemeBackgroundColor(bgColor: string = themeColors.appBG): string {
- const backdropOpacity = variables.modalFullscreenBackdropOpacity;
+ const backdropOpacity = variables.overlayOpacity;
const [backgroundRed, backgroundGreen, backgroundBlue] = extractValuesFromRGB(bgColor) ?? hexadecimalToRGBArray(bgColor) ?? [];
- const [backdropRed, backdropGreen, backdropBlue] = hexadecimalToRGBArray(themeColors.modalBackdrop) ?? [];
+ const [backdropRed, backdropGreen, backdropBlue] = hexadecimalToRGBArray(themeColors.overlay) ?? [];
const normalizedBackdropRGB = convertRGBToUnitValues(backdropRed, backdropGreen, backdropBlue);
const normalizedBackgroundRGB = convertRGBToUnitValues(backgroundRed, backgroundGreen, backgroundBlue);
const [red, green, blue] = convertRGBAToRGB(normalizedBackdropRGB, normalizedBackgroundRGB, backdropOpacity);
diff --git a/src/styles/styles.js b/src/styles/styles.js
index 04ed741545c0..a089454cbb1c 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -3680,6 +3680,7 @@ const styles = (theme) => ({
borderRadius: variables.componentBorderRadiusLarge,
},
userReportStatusEmoji: {
+ flexShrink: 0,
fontSize: variables.fontSizeNormal,
marginRight: 4,
},
diff --git a/src/styles/themes/default.js b/src/styles/themes/default.js
index 75db4be30e2b..173fda328d1f 100644
--- a/src/styles/themes/default.js
+++ b/src/styles/themes/default.js
@@ -53,7 +53,6 @@ const darkTheme = {
textMutedReversed: colors.darkIcons,
textError: colors.red,
offline: colors.darkIcons,
- modalBackdrop: colors.darkHighlightBackground,
modalBackground: colors.darkAppBackground,
cardBG: colors.darkHighlightBackground,
cardBorder: colors.darkHighlightBackground,
@@ -83,6 +82,7 @@ const darkTheme = {
QRLogo: colors.green400,
starDefaultBG: 'rgb(254, 228, 94)',
loungeAccessOverlay: colors.blue800,
+ selectionListIndicatorColor: colors.white,
mapAttributionText: colors.black,
};
diff --git a/src/styles/themes/light.js b/src/styles/themes/light.js
index 8bc149c5af08..c459f9f10da6 100644
--- a/src/styles/themes/light.js
+++ b/src/styles/themes/light.js
@@ -51,7 +51,6 @@ const lightTheme = {
textMutedReversed: colors.lightIcons,
textError: colors.red,
offline: colors.lightIcons,
- modalBackdrop: colors.lightHighlightBackground,
modalBackground: colors.lightAppBackground,
cardBG: colors.lightHighlightBackground,
cardBorder: colors.lightHighlightBackground,
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 9ee9b64e6467..a7191ce5b002 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -78,7 +78,6 @@ export default {
extraSmallMobileResponsiveWidthBreakpoint: 320,
extraSmallMobileResponsiveHeightBreakpoint: 667,
mobileResponsiveWidthBreakpoint: 800,
- modalFullscreenBackdropOpacity: 0.5,
tabletResponsiveWidthBreakpoint: 1024,
safeInsertPercentage: 0.7,
sideBarWidth: 375,
diff --git a/src/types/onyx/Network.ts b/src/types/onyx/Network.ts
index 5af4c1170c3f..32b084bbf2f7 100644
--- a/src/types/onyx/Network.ts
+++ b/src/types/onyx/Network.ts
@@ -7,6 +7,9 @@ type Network = {
/** Whether we should fail all network requests */
shouldFailAllRequests?: boolean;
+
+ /** Skew between the client and server clocks */
+ timeSkew?: number;
};
export default Network;
diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts
index dd53024a5426..18e296e9ea60 100644
--- a/src/types/onyx/Transaction.ts
+++ b/src/types/onyx/Transaction.ts
@@ -7,6 +7,10 @@ type WaypointCollection = Record;
type Comment = {
comment?: string;
waypoints?: WaypointCollection;
+ type?: string;
+ customUnit?: Record;
+ source?: string;
+ originalTransactionID?: string;
};
type GeometryType = 'LineString';
@@ -16,6 +20,12 @@ type Geometry = {
type?: GeometryType;
};
+type Receipt = {
+ receiptID?: number;
+ source?: string;
+ state?: ValueOf;
+};
+
type Route = {
distance: number | null;
geometry: Geometry;
@@ -30,7 +40,7 @@ type Transaction = {
comment: Comment;
created: string;
currency: string;
- errors: OnyxCommon.Errors;
+ errors?: OnyxCommon.Errors;
errorFields?: OnyxCommon.ErrorFields;
// The name of the file used for a receipt (formerly receiptFilename)
filename?: string;
@@ -38,20 +48,16 @@ type Transaction = {
modifiedAmount?: number;
modifiedCreated?: string;
modifiedCurrency?: string;
+ modifiedMerchant?: string;
+ modifiedWaypoints?: WaypointCollection;
pendingAction: OnyxCommon.PendingAction;
- pendingFields: {
- comment: string;
- };
- receipt: {
- receiptID?: number;
- source?: string;
- state?: ValueOf;
- };
+ receipt: Receipt;
reportID: string;
routes?: Routes;
transactionID: string;
tag: string;
+ pendingFields?: Partial<{[K in keyof Transaction]: ValueOf}>;
};
export default Transaction;
-export type {WaypointCollection};
+export type {WaypointCollection, Comment, Receipt};
diff --git a/src/types/onyx/UserWallet.ts b/src/types/onyx/UserWallet.ts
index 8624f16000c9..c6ab5dbf1f67 100644
--- a/src/types/onyx/UserWallet.ts
+++ b/src/types/onyx/UserWallet.ts
@@ -34,6 +34,9 @@ type UserWallet = {
/** The type of the linked account (debitCard or bankAccount) */
walletLinkedAccountType: WalletLinkedAccountType;
+ /** The wallet's programID, used to show the correct terms. */
+ walletProgramID?: string;
+
/** The user's bank account ID */
bankAccountID?: number;
diff --git a/tests/e2e/ADDING_TESTS.md b/tests/e2e/ADDING_TESTS.md
index 9704e4ea706d..dcd08aeee441 100644
--- a/tests/e2e/ADDING_TESTS.md
+++ b/tests/e2e/ADDING_TESTS.md
@@ -1,4 +1,51 @@
-# Add E2E Tests
+# Adding new E2E Tests
+
+## Running your new test in development mode
+
+Typically you'd run all the tests with `npm run test:e2e` on your machine,
+this will run the tests with some local settings, however that is not
+optimal when you add a new test for which you want to quickly test if it works, as it
+still runs the release version of the app.
+
+I recommend doing the following.
+
+> [!NOTE]
+> All of the steps can be executed at once by running XXX (todo)
+
+1. Rename `./index.js` to `./appIndex.js`
+2. Create a new `./index.js` with the following content:
+```js
+requrire("./src/libs/E2E/reactNativeLaunchingTest.js");
+```
+3. In `./src/libs/E2E/reactNativeLaunchingTest.js` change the main app import to the new `./appIndex.js` file:
+```diff
+- import '../../../index';
++ import '../../../appIndex';
+```
+
+> [!WARNING]
+> Make sure to not commit these changes to the repository!
+
+Now you can start the metro bundler in e2e mode with:
+
+```
+CAPTURE_METRICS=TRUE E2E_Testing=true npm start -- --reset-cache
+```
+
+Then we can execute our test with:
+
+```
+npm run test:e2e -- --development --skipInstallDeps --buildMode skip --includes "My new test name"
+```
+
+> - `--development` will run the tests with a local config, which will run the tests with fewer iterations
+> - `--skipInstallDeps` will skip the `npm install` step, which you probably don't need
+> - `--buildMode skip` will skip rebuilding the app, and just run the existing app
+> - `--includes "MyTestName"` will only run the test with the name "MyTestName"
+
+
+
+## Creating a new test
Tests are executed on device, inside the app code.
@@ -97,6 +144,10 @@ Done! When you now start the test runner, your new test will be executed as well
## Quickly test your test
To check your new test you can simply run `npm run test:e2e`, which uses the
-`--development` flag. This will run the tests on the branch you are currently on
-and will do fewer iterations.
+`--development` flag. This will run the tests on the branch you are currently on, runs fewer iterations and most importantly, it tries to reuse the existing APK and just patch into the new app bundle, instead of rebuilding the release app from scratch.
+
+## Debugging your test
+
+You can use regular console statements to debug your test. The output will be visible
+in logcat. I recommend opening the android studio logcat window and filter for `ReactNativeJS` to see the output you'd otherwise typically see in your metro bundler instance.
diff --git a/tests/e2e/config.js b/tests/e2e/config.js
index d322fb970b2d..d7844a29f3e4 100644
--- a/tests/e2e/config.js
+++ b/tests/e2e/config.js
@@ -21,7 +21,7 @@ const TEST_NAMES = {
* ```
*/
module.exports = {
- APP_PACKAGE: 'com.expensify.chat',
+ APP_PACKAGE: 'com.expensify.chat.adhoc',
APP_PATHS: {
baseline: './app-e2eRelease-baseline.apk',
diff --git a/tests/e2e/config.local.js b/tests/e2e/config.local.js
index cd0b04d7c3cf..0c38c3f1056f 100644
--- a/tests/e2e/config.local.js
+++ b/tests/e2e/config.local.js
@@ -1,8 +1,10 @@
module.exports = {
+ APP_PACKAGE: 'com.expensify.chat.dev',
+
WARM_UP_RUNS: 1,
RUNS: 8,
APP_PATHS: {
- baseline: './android/app/build/outputs/apk/e2eRelease/app-e2eRelease.apk',
- compare: './android/app/build/outputs/apk/e2eRelease/app-e2eRelease.apk',
+ baseline: './android/app/build/outputs/apk/e2e/release/app-e2e-release.apk',
+ compare: './android/app/build/outputs/apk/e2e/release/app-e2e-release.apk',
},
};
diff --git a/tests/e2e/testRunner.js b/tests/e2e/testRunner.js
index db421ae64ef1..2a5aee78715f 100644
--- a/tests/e2e/testRunner.js
+++ b/tests/e2e/testRunner.js
@@ -73,9 +73,9 @@ if (isDevMode) {
const restartApp = async () => {
Logger.log('Killing app …');
- await killApp('android');
+ await killApp('android', config.APP_PACKAGE);
Logger.log('Launching app …');
- await launchApp('android');
+ await launchApp('android', config.APP_PACKAGE);
};
const runTestsOnBranch = async (baselineOrCompare, branch) => {
@@ -89,6 +89,8 @@ const runTestsOnBranch = async (baselineOrCompare, branch) => {
const appExists = fs.existsSync(appPath);
if (!appExists) {
Logger.warn(`Build mode "${buildMode}" is not possible, because the app does not exist. Falling back to build mode "full".`);
+ Logger.note(`App path: ${appPath}`);
+
buildMode = 'full';
}
}
@@ -125,7 +127,7 @@ const runTestsOnBranch = async (baselineOrCompare, branch) => {
// Install app and reverse port
let progressLog = Logger.progressInfo('Installing app and reversing port');
- await installApp('android', appPath);
+ await installApp('android', config.APP_PACKAGE, appPath);
await reversePort();
progressLog.done();
diff --git a/tests/e2e/utils/installApp.js b/tests/e2e/utils/installApp.js
index 136602375f85..ff961940826a 100644
--- a/tests/e2e/utils/installApp.js
+++ b/tests/e2e/utils/installApp.js
@@ -7,16 +7,17 @@ const Logger = require('./logger');
* It removes the app first if it already exists, so it's a clean installation.
*
* @param {String} platform
+ * @param {String} packageName
* @param {String} path
* @returns {Promise}
*/
-module.exports = function (platform = 'android', path) {
+module.exports = function (platform = 'android', packageName = APP_PACKAGE, path) {
if (platform !== 'android') {
throw new Error(`installApp() missing implementation for platform: ${platform}`);
}
// Uninstall first, then install
- return execAsync(`adb uninstall ${APP_PACKAGE}`)
+ return execAsync(`adb uninstall ${packageName}`)
.catch((e) => {
// Ignore errors
Logger.warn('Failed to uninstall app:', e);
diff --git a/tests/e2e/utils/killApp.js b/tests/e2e/utils/killApp.js
index 9761ee7fc66e..bdef215bf752 100644
--- a/tests/e2e/utils/killApp.js
+++ b/tests/e2e/utils/killApp.js
@@ -1,11 +1,11 @@
const {APP_PACKAGE} = require('../config');
const execAsync = require('./execAsync');
-module.exports = function (platform = 'android') {
+module.exports = function (platform = 'android', packageName = APP_PACKAGE) {
if (platform !== 'android') {
throw new Error(`killApp() missing implementation for platform: ${platform}`);
}
// Use adb to kill the app
- return execAsync(`adb shell am force-stop ${APP_PACKAGE}`);
+ return execAsync(`adb shell am force-stop ${packageName}`);
};
diff --git a/tests/e2e/utils/launchApp.js b/tests/e2e/utils/launchApp.js
index dce17c7fbb3b..e0726d081086 100644
--- a/tests/e2e/utils/launchApp.js
+++ b/tests/e2e/utils/launchApp.js
@@ -1,11 +1,11 @@
const {APP_PACKAGE} = require('../config');
const execAsync = require('./execAsync');
-module.exports = function (platform = 'android') {
+module.exports = function (platform = 'android', packageName = APP_PACKAGE) {
if (platform !== 'android') {
throw new Error(`launchApp() missing implementation for platform: ${platform}`);
}
// Use adb to start the app
- return execAsync(`adb shell monkey -p ${APP_PACKAGE} -c android.intent.category.LAUNCHER 1`);
+ return execAsync(`adb shell monkey -p ${packageName} -c android.intent.category.LAUNCHER 1`);
};
diff --git a/tests/e2e/utils/logger.js b/tests/e2e/utils/logger.js
index aa198aec3004..1f2fff315bfc 100644
--- a/tests/e2e/utils/logger.js
+++ b/tests/e2e/utils/logger.js
@@ -61,19 +61,16 @@ const progressInfo = (textParam) => {
};
const info = (...args) => {
- console.debug('> ', ...args);
- log(...args);
+ log('> ', ...args);
};
const warn = (...args) => {
const lines = [`\n${COLOR_YELLOW}⚠️`, ...args, `${COLOR_RESET}\n`];
- console.debug(...lines);
log(...lines);
};
const note = (...args) => {
const lines = [`\n💡${COLOR_DIM}`, ...args, `${COLOR_RESET}\n`];
- console.debug(...lines);
log(...lines);
};
diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js
index 6f20e48835fd..437d37e625dd 100644
--- a/tests/unit/OptionsListUtilsTest.js
+++ b/tests/unit/OptionsListUtilsTest.js
@@ -1,5 +1,6 @@
import _ from 'underscore';
import Onyx from 'react-native-onyx';
+import {View} from 'react-native';
import * as OptionsListUtils from '../../src/libs/OptionsListUtils';
import * as ReportUtils from '../../src/libs/ReportUtils';
import ONYXKEYS from '../../src/ONYXKEYS';
@@ -1547,7 +1548,7 @@ describe('OptionsListUtils', () => {
});
it('formatMemberForList()', () => {
- const formattedMembers = _.map(PERSONAL_DETAILS, (personalDetail, key) => OptionsListUtils.formatMemberForList(personalDetail, key === '1'));
+ const formattedMembers = _.map(PERSONAL_DETAILS, (personalDetail, key) => OptionsListUtils.formatMemberForList(personalDetail, {isSelected: key === '1'}));
// We're only formatting items inside the array, so the order should be the same as the original PERSONAL_DETAILS array
expect(formattedMembers[0].text).toBe('Mister Fantastic');
@@ -1566,7 +1567,8 @@ describe('OptionsListUtils', () => {
// `rightElement` is always null
expect(_.every(formattedMembers, (personalDetail) => personalDetail.rightElement === null)).toBe(true);
- // The PERSONAL_DETAILS list doesn't specify `participantsList[n].avatar`, so the default one should be used
- expect(_.every(formattedMembers, (personalDetail) => Boolean(personalDetail.avatar.source))).toBe(true);
+ // Passing a config should override the other keys
+ const formattedMembersWithRightElement = _.map(PERSONAL_DETAILS, (personalDetail) => OptionsListUtils.formatMemberForList(personalDetail, {rightElement: }));
+ expect(_.every(formattedMembersWithRightElement, (personalDetail) => Boolean(personalDetail.rightElement))).toBe(true);
});
});
diff --git a/tests/utils/LHNTestUtils.js b/tests/utils/LHNTestUtils.js
index 43090cf024e2..7cb69b23a578 100644
--- a/tests/utils/LHNTestUtils.js
+++ b/tests/utils/LHNTestUtils.js
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import {render} from '@testing-library/react-native';
import ComposeProviders from '../../src/components/ComposeProviders';
import OnyxProvider from '../../src/components/OnyxProvider';
-import {LocaleContextProvider} from '../../src/components/withLocalize';
+import {LocaleContextProvider} from '../../src/components/LocaleContextProvider';
import SidebarLinksData from '../../src/pages/home/sidebar/SidebarLinksData';
import {EnvironmentProvider} from '../../src/components/withEnvironment';
import CONST from '../../src/CONST';