diff --git a/.eslintrc.js b/.eslintrc.js
index 75a74ed371c4..83e9479ce0c4 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -116,7 +116,7 @@ module.exports = {
},
{
selector: ['parameter', 'method'],
- format: ['camelCase'],
+ format: ['camelCase', 'PascalCase'],
},
],
'@typescript-eslint/ban-types': [
diff --git a/.github/actions/javascript/bumpVersion/index.js b/.github/actions/javascript/bumpVersion/index.js
index da08d1a060b6..830dbf626548 100644
--- a/.github/actions/javascript/bumpVersion/index.js
+++ b/.github/actions/javascript/bumpVersion/index.js
@@ -298,9 +298,6 @@ function getPreviousVersion(currentVersion, level) {
if (patch === 0) {
return getPreviousVersion(currentVersion, SEMANTIC_VERSION_LEVELS.MINOR);
}
- if (major === 1 && minor === 3 && patch === 83) {
- return getVersionStringFromNumber(major, minor, patch - 2, 0);
- }
return getVersionStringFromNumber(major, minor, patch - 1, 0);
}
diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js
index 22ad59ed9588..561b8e61bc21 100644
--- a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js
+++ b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js
@@ -998,9 +998,6 @@ function getPreviousVersion(currentVersion, level) {
if (patch === 0) {
return getPreviousVersion(currentVersion, SEMANTIC_VERSION_LEVELS.MINOR);
}
- if (major === 1 && minor === 3 && patch === 83) {
- return getVersionStringFromNumber(major, minor, patch - 2, 0);
- }
return getVersionStringFromNumber(major, minor, patch - 1, 0);
}
diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js
index 3aafda798c54..e42f97508bc5 100644
--- a/.github/actions/javascript/getDeployPullRequestList/index.js
+++ b/.github/actions/javascript/getDeployPullRequestList/index.js
@@ -961,9 +961,6 @@ function getPreviousVersion(currentVersion, level) {
if (patch === 0) {
return getPreviousVersion(currentVersion, SEMANTIC_VERSION_LEVELS.MINOR);
}
- if (major === 1 && minor === 3 && patch === 83) {
- return getVersionStringFromNumber(major, minor, patch - 2, 0);
- }
return getVersionStringFromNumber(major, minor, patch - 1, 0);
}
diff --git a/.github/actions/javascript/getPreviousVersion/index.js b/.github/actions/javascript/getPreviousVersion/index.js
index 6770ba99ba69..37db08db93e9 100644
--- a/.github/actions/javascript/getPreviousVersion/index.js
+++ b/.github/actions/javascript/getPreviousVersion/index.js
@@ -148,9 +148,6 @@ function getPreviousVersion(currentVersion, level) {
if (patch === 0) {
return getPreviousVersion(currentVersion, SEMANTIC_VERSION_LEVELS.MINOR);
}
- if (major === 1 && minor === 3 && patch === 83) {
- return getVersionStringFromNumber(major, minor, patch - 2, 0);
- }
return getVersionStringFromNumber(major, minor, patch - 1, 0);
}
diff --git a/.github/libs/versionUpdater.js b/.github/libs/versionUpdater.js
index b78178f443e6..78e8085621bd 100644
--- a/.github/libs/versionUpdater.js
+++ b/.github/libs/versionUpdater.js
@@ -118,9 +118,6 @@ function getPreviousVersion(currentVersion, level) {
if (patch === 0) {
return getPreviousVersion(currentVersion, SEMANTIC_VERSION_LEVELS.MINOR);
}
- if (major === 1 && minor === 3 && patch === 83) {
- return getVersionStringFromNumber(major, minor, patch - 2, 0);
- }
return getVersionStringFromNumber(major, minor, patch - 1, 0);
}
diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml
index f5a5dc5e1616..1105f78da27a 100644
--- a/.github/workflows/platformDeploy.yml
+++ b/.github/workflows/platformDeploy.yml
@@ -104,6 +104,13 @@ jobs:
name: android-sourcemap
path: android/app/build/generated/sourcemaps/react/release/*.map
+ - name: Upload Android version to GitHub artifacts
+ if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
+ uses: actions/upload-artifact@v3
+ with:
+ name: app-production-release.aab
+ path: android/app/build/outputs/bundle/productionRelease/app-production-release.aab
+
- name: Upload Android version to Browser Stack
if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@./android/app/build/outputs/bundle/productionRelease/app-production-release.aab"
@@ -238,6 +245,13 @@ jobs:
name: ios-sourcemap
path: main.jsbundle.map
+ - name: Upload iOS version to GitHub artifacts
+ if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
+ uses: actions/upload-artifact@v3
+ with:
+ name: New Expensify.ipa
+ path: /Users/runner/work/App/App/New Expensify.ipa
+
- name: Upload iOS version to Browser Stack
if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@/Users/runner/work/App/App/New Expensify.ipa"
diff --git a/.well-known/apple-app-site-association b/.well-known/apple-app-site-association
index 1e63fdcb2d52..b3adf0f59b9c 100644
--- a/.well-known/apple-app-site-association
+++ b/.well-known/apple-app-site-association
@@ -80,6 +80,10 @@
"/": "/search/*",
"comment": "Search"
},
+ {
+ "/": "/send/*",
+ "comment": "Send money"
+ },
{
"/": "/money2020/*",
"comment": "Money 2020"
diff --git a/android/app/build.gradle b/android/app/build.gradle
index dc7f06edcb45..b8f59db4aecf 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -90,8 +90,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001038402
- versionName "1.3.84-2"
+ versionCode 1001038601
+ versionName "1.3.86-1"
}
flavorDimensions "default"
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 7419d5b1e1a7..74e91caa91d5 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -70,6 +70,7 @@
+
@@ -88,6 +89,7 @@
+
diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock
index 27656eeb68f0..de99bbcb48ef 100644
--- a/docs/Gemfile.lock
+++ b/docs/Gemfile.lock
@@ -256,6 +256,7 @@ GEM
PLATFORMS
arm64-darwin-22
+ arm64-darwin-23
x86_64-darwin-20
x86_64-darwin-21
diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss
index 3ad2276713da..c887849ffd99 100644
--- a/docs/_sass/_main.scss
+++ b/docs/_sass/_main.scss
@@ -371,9 +371,26 @@ button {
flex-wrap: wrap;
}
+ h1 {
+ font-size: 1.5em;
+ padding: 20px 0 12px 0;
+ }
+
+ h2 {
+ font-size: 1.125em;
+ font-weight: 500;
+ font-family: "ExpensifyNewKansas", "Helvetica Neue", "Helvetica", Arial, sans-serif;
+ }
+
+ h3 {
+ font-size: 1em;
+ font-family: "ExpensifyNeue", "Helvetica Neue", "Helvetica", Arial, sans-serif;
+ }
+
h2,
h3 {
- font-family: "ExpensifyNewKansas", "Helvetica Neue", "Helvetica", Arial, sans-serif;
+ margin: 0;
+ padding: 12px 0 12px 0;
}
blockquote {
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Business-Bank-Account-(AUD).md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Business-Bank-Account-(AUD).md
deleted file mode 100644
index 1fa5734293ac..000000000000
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Business-Bank-Account-(AUD).md
+++ /dev/null
@@ -1,51 +0,0 @@
----
-title: Add-a-Business-Bank-Account-(AUD).md
-description: This article provides insight on setting up and using an Australian Business Bank account in Expensify.
----
-
-# How to add an Australian business bank account (for admins)
-A withdrawal account is the business bank account that you want to use to pay your employee reimbursements.
-
-_Your policy currency must be set to AUD and reimbursement setting set to Indirect to continue. If your main policy is used for something other than AUD, then you will need to create a new one and set that policy to AUD._
-
-To set this up, you’ll run through the following steps:
-
-1. Go to **Settings > Your Account > Payments** and click **Add Verified Bank Account**
-![Click the Verified Bank Account button in the bottom right-hand corner of the screen](https://help.expensify.com/assets/images/add-vba-australian-account.png){:width="100%"}
-
-2. Enter the required information to connect to your business bank account. If you don't know your Bank User ID/Direct Entry ID/APCA Number, please contact your bank and they will be able to provide this.
-![Enter your information in each of the required fields](https://help.expensify.com/assets/images/add-vba-australian-account-modal.png){:width="100%"}
-
-3. Link the withdrawal account to your policy by heading to **Settings > Policies > Group > [Policy name] > Reimbursement**
-4. Click **Direct reimbursement**
-5. Set the default withdrawal account for processing reimbursements
-6. Tell your employees to add their deposit accounts and start reimbursing.
-
-# How to delete a bank account
-If you’re no longer using a bank account you previously connected to Expensify, you can delete it by doing the following:
-
-1. Navigate to Settings > Accounts > Payments
-2. Click **Delete**
-![Click the Delete button](https://help.expensify.com/assets/images/delete-australian-bank-account.png){:width="100%"}
-
-You can complete this process either via the web app (on a computer), or via the mobile app.
-
-# Deep Dive
-## Bank-specific batch payment support
-
-If you are new to using Batch Payments in Australia, to reimburse your staff or process payroll, you may want to check out these bank-specific instructions for how to upload your .aba file:
-
-- ANZ Bank - [Import a file for payroll payments](https://www.anz.com.au/support/internet-banking/pay-transfer-business/payroll/import-file/)
-- CommBank - [Importing and using Direct Entry (EFT) files](https://www.commbank.com.au/business/pds/003-279-importing-a-de-file.pdf)
-- Westpac - [Importing Payment Files](https://www.westpac.com.au/business-banking/online-banking/support-faqs/import-files/)
-- NAB - [Quick Reference Guide - Upload a payment file](https://www.nab.com.au/business/online-banking/nab-connect/help)
-- Bendigo Bank - [Bulk payments user guide](https://www.bendigobank.com.au/globalassets/documents/business/bulk-payments-user-guide.pdf)
-- Bank of Queensland - [Payments file upload facility FAQ](https://www.boq.com.au/help-and-support/online-banking/ob-faqs-and-support/faq-pfuf)
-
-**Note:** Some financial institutions require an ABA file to include a *self-balancing transaction*. If you are unsure, please check with your bank to ensure whether to tick this option or not, as selecting an incorrect option will result in the ABA file not working with your bank's internet banking platform.
-
-## Enable Global Reimbursement
-
-If you have employees in other countries outside of Australia, you can now reimburse them directly using Global Reimbursement.
-
-To do this, you’ll first need to delete any existing Australian business bank accounts. Then, you’ll want to follow the instructions to enable Global Reimbursements
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md
index 7c789942a2b3..b59f68a65ce6 100644
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md
+++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md
@@ -1,5 +1,51 @@
---
-title: Business Bank Accounts - AUD
-description: Business Bank Accounts - AUD
+title: Add a Business Bank Account
+description: This article provides insight on setting up and using an Australian Business Bank account in Expensify.
---
-## Resource Coming Soon!
+
+# How to add an Australian business bank account (for admins)
+A withdrawal account is the business bank account that you want to use to pay your employee reimbursements.
+
+_Your policy currency must be set to AUD and reimbursement setting set to Indirect to continue. If your main policy is used for something other than AUD, then you will need to create a new one and set that policy to AUD._
+
+To set this up, you’ll run through the following steps:
+
+1. Go to **Settings > Your Account > Payments** and click **Add Verified Bank Account**
+![Click the Verified Bank Account button in the bottom right-hand corner of the screen](https://help.expensify.com/assets/images/add-vba-australian-account.png){:width="100%"}
+
+2. Enter the required information to connect to your business bank account. If you don't know your Bank User ID/Direct Entry ID/APCA Number, please contact your bank and they will be able to provide this.
+![Enter your information in each of the required fields](https://help.expensify.com/assets/images/add-vba-australian-account-modal.png){:width="100%"}
+
+3. Link the withdrawal account to your policy by heading to **Settings > Policies > Group > [Policy name] > Reimbursement**
+4. Click **Direct reimbursement**
+5. Set the default withdrawal account for processing reimbursements
+6. Tell your employees to add their deposit accounts and start reimbursing.
+
+# How to delete a bank account
+If you’re no longer using a bank account you previously connected to Expensify, you can delete it by doing the following:
+
+1. Navigate to Settings > Accounts > Payments
+2. Click **Delete**
+![Click the Delete button](https://help.expensify.com/assets/images/delete-australian-bank-account.png){:width="100%"}
+
+You can complete this process either via the web app (on a computer), or via the mobile app.
+
+# Deep Dive
+## Bank-specific batch payment support
+
+If you are new to using Batch Payments in Australia, to reimburse your staff or process payroll, you may want to check out these bank-specific instructions for how to upload your .aba file:
+
+- ANZ Bank - [Import a file for payroll payments](https://www.anz.com.au/support/internet-banking/pay-transfer-business/payroll/import-file/)
+- CommBank - [Importing and using Direct Entry (EFT) files](https://www.commbank.com.au/business/pds/003-279-importing-a-de-file.pdf)
+- Westpac - [Importing Payment Files](https://www.westpac.com.au/business-banking/online-banking/support-faqs/import-files/)
+- NAB - [Quick Reference Guide - Upload a payment file](https://www.nab.com.au/business/online-banking/nab-connect/help)
+- Bendigo Bank - [Bulk payments user guide](https://www.bendigobank.com.au/globalassets/documents/business/bulk-payments-user-guide.pdf)
+- Bank of Queensland - [Payments file upload facility FAQ](https://www.boq.com.au/help-and-support/online-banking/ob-faqs-and-support/faq-pfuf)
+
+**Note:** Some financial institutions require an ABA file to include a *self-balancing transaction*. If you are unsure, please check with your bank to ensure whether to tick this option or not, as selecting an incorrect option will result in the ABA file not working with your bank's internet banking platform.
+
+## Enable Global Reimbursement
+
+If you have employees in other countries outside of Australia, you can now reimburse them directly using Global Reimbursement.
+
+To do this, you’ll first need to delete any existing Australian business bank accounts. Then, you’ll want to follow the instructions to enable Global Reimbursements
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Deposit-Account-(AUD).md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUD.md
similarity index 83%
rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Deposit-Account-(AUD).md
rename to docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUD.md
index 7273e5ece879..6114e98883e0 100644
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Deposit-Account-(AUD).md
+++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUD.md
@@ -1,12 +1,12 @@
---
-title: Add a Deposit Account (AUD)
+title: Deposit Accounts (AUD)
description: Expensify allows you to add a personal bank account to receive reimbursements for your expenses. We never take money out of this account — it is only a place for us to deposit funds from your employer. This article covers deposit accounts for Australian banks.
---
## How-to add your Australian personal deposit account information
1. Confirm with your Policy Admin that they’ve set up Global Reimbursment
2. Set your default policy (by selecting the correct policy after clicking on your profile picture) before adding your deposit account.
-3. Go to *Settings > Account > Payments* and click *Add Deposit-Only Bank Account*
+3. Go to **Settings > Account > Payments** and click **Add Deposit-Only Bank Account**
![Click the Add Deposit-Only Bank Account button](https://help.expensify.com/assets/images/add-australian-deposit-only-account.png){:width="100%"}
4. Enter your BSB, account number and name. If your screen looks different than the image below, that means your company hasn't enabled reimbursements through Expensify. Please contact your administrator and ask them to enable reimbursements.
@@ -14,7 +14,7 @@ description: Expensify allows you to add a personal bank account to receive reim
![Fill in the required fields](https://help.expensify.com/assets/images/add-australian-deposit-only-account-modal.png){:width="100%"}
# How-to delete a bank account
-Bank accounts are easy to delete! Simply click the red “Delete” button in the bank account under *Settings > Account > Payments*.
+Bank accounts are easy to delete! Simply click the red **Delete** button in the bank account under **Settings > Account > Payments**.
![Click the Delete button](https://help.expensify.com/assets/images/delete-australian-bank-account.png){:width="100%"}
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUS.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUS.md
deleted file mode 100644
index 61e6dfd95e38..000000000000
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUS.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Deposit Accounts - AUD
-description: Deposit Accounts - AUD
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Pay-Per-Use-Subscription.md b/docs/articles/expensify-classic/billing-and-subscriptions/Pay-Per-Use-Subscription.md
index 77aca2a01678..1d689f5b0355 100644
--- a/docs/articles/expensify-classic/billing-and-subscriptions/Pay-Per-Use-Subscription.md
+++ b/docs/articles/expensify-classic/billing-and-subscriptions/Pay-Per-Use-Subscription.md
@@ -1,5 +1,29 @@
---
title: Pay-per-use Subscription
-description: Pay-per-use Subscription
+description: Learn more about your pay-per-use subscription.
---
-## Resource Coming Soon!
+# Overview
+Pay-per-use is a billing option for people who prefer to use Expensify month to month or on an as-needed basis. On a pay-per-use subscription, you will only pay for active users in that given month.
+
+**We recommend this billing setup for companies that use Expensify a few months out of the year**. If you have expenses to manage for more than 6 out of 12 months, an [**Annual Subscription**](https://help.expensify.com/articles/expensify-classic/billing-and-subscriptions/Annual-Subscription#gsc.tab=0) may better suit your needs.
+
+# How to start a pay-per-use subscription
+1. Create a Group Workspace if you haven’t already by going to **Settings > Workspaces > Group > New Workspace**
+2. Once you’ve created your Workspace, under the “Subscription” section on the Group Workspace page, select “Pay-per-use”.
+
+# FAQ
+
+## What is considered an active user?
+An active user is anyone who chats, creates, modifies, submits, approves, reimburses, or exports a report in Expensify. This includes actions taken by a Copilot and Workspace automation (such as Scheduled Submit and automated reimbursement). If no one on your Group Workspace uses Expensify in a given month, you will not be billed for that month.
+
+You can review the number of Active Users by selecting “View Activity” next to your billing receipt (**Settings > Account > Payments > Billing History**).
+
+## Why do I have pay-per-use users in addition to my Annual Subscription on my Expensify bill?
+If you have an Annual Subscription, but go above your set user count, we will charge at the pay-per-use rate for these ad-hoc users.
+
+If you expect to have an increased number of users for more than 3 out of 12 months, the most cost-effective approach is to increase your Annual Subscription size.
+
+## Will billing only be in USD currency?
+While USD is the default billing currency, we also have GBP, AUD, and NZD billing currencies. You can see the rates on our [pricing](https://www.expensify.com/pricing) page.
+
+
diff --git a/docs/articles/expensify-classic/expense-and-report-features/Expense-Rules.md b/docs/articles/expensify-classic/expense-and-report-features/Expense-Rules.md
index 304c93d1da6d..ae6a9ca77db1 100644
--- a/docs/articles/expensify-classic/expense-and-report-features/Expense-Rules.md
+++ b/docs/articles/expensify-classic/expense-and-report-features/Expense-Rules.md
@@ -1,5 +1,55 @@
---
title: Expense Rules
-description: Expense Rules
+description: Expense rules allow you to automatically categorize, tag, and report expenses based on the merchant's name.
+
---
-## Resource Coming Soon!
+# Overview
+Expense rules allow you to automatically categorize, tag, and report expenses based on the merchant’s name.
+
+# How to use Expense Rules
+**To create an expense rule, follow these steps:**
+1. Navigate to **Settings > Account > Expense Rules**
+2. Click on **New Rule**
+3. Fill in the required information to set up your rule
+
+When creating an expense rule, you will be able to apply the following rules to expenses:
+
+![Insert alt text for accessibility here](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_01.png){:width="100%"}
+
+- **Merchant:** Updates the merchant name, e.g., “Starbucks #238” could be changed to “Starbucks”
+- **Category:** Applies a workspace category to the expense
+- **Tag:** Applies a tag to the expense, e.g., a Department or Location
+- **Description:** Adds a description to the description field on the expense
+- **Reimbursability:** Determines whether the expense will be marked as reimbursable or non-reimbursable
+- **Billable**: Determines whether the expense is billable
+- **Add to a report named:** Adds the expense to a report with the name you type into the field. If no report with that name exists, a new report will be created
+
+## Tips on using Expense Rules
+- If you'd like to apply a rule to all expenses (“Universal Rule”) rather than just one merchant, simply enter a period [.] and nothing else into the **“When the merchant name contains:”** field. **Note:** Universal Rules will always take precedence over all other rules for category (more on this below).
+- You can apply a rule to previously entered expenses by checking the **Apply to existing matching expenses** checkbox. Click “Preview Matching Expenses” to see if your rule matches the intended expenses.
+- You can create expense rules while editing an expense. To do this, simply check the box **“Create a rule based on your changes"** at the time of editing. Note that the expense must be saved, reopened, and edited for this option to appear.
+
+
+![Insert alt text for accessibility here](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_02.png){:width="100%"}
+
+
+To delete an expense rule, go to **Settings > Account > Expense Rules**, scroll down to the rule you’d like to remove, and then click the trash can icon in the upper right corner of the rule:
+
+![Insert alt text for accessibility here](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_03.png){:width="100%"}
+
+# Deep Dive
+In general, your expense rules will be applied in order, from **top to bottom**, i.e., from the first rule. However, other settings can impact how expense rules are applied. Here is the hierarchy that determines how these are applied:
+1. A Universal Rule will **always** precede over any other expense category rules. Rules that would otherwise change the expense category will **not** override the Universal Rule.
+2. If Scheduled Submit and the setting “Enforce Default Report Title” are enabled on the workspace, this will take precedence over any rules trying to add the expense to a report.
+3. If the expense is from a Company Card that is forced to a workspace with strict rule enforcement, those rules will take precedence over individual expense rules.
+4. If you belong to a workspace that is tied to an accounting integration, the configuration settings for this connection may update your expense details upon export, even if the expense rules were successfully applied to the expense.
+
+
+# FAQ
+## How can I use Expense Rules to vendor match when exporting to an accounting package?
+When exporting non-reimbursable expenses to your connected accounting package, the payee field will list "Credit Card Misc." if the merchant name on the expense in Expensify is not an exact match to a vendor in the accounting package.
+When an exact match is unavailable, "Credit Card Misc." prevents multiple variations of the same vendor (e.g., Starbucks and Starbucks #1234, as is often seen in credit card statements) from being created in your accounting package.
+For repeated expenses, the best practice is to use Expense Rules, which will automatically update the merchant name without having to do it manually each time.
+This only works for connections to QuickBooks Online, Desktop, and Xero. Vendor matching cannot be performed in this manner for NetSuite or Sage Intacct due to limitations in the API of the accounting package.
+
+
diff --git a/docs/articles/expensify-classic/expensify-card/Set-Up-the-Card-for-Your-Company.md b/docs/articles/expensify-classic/expensify-card/Set-Up-the-Card-for-Your-Company.md
new file mode 100644
index 000000000000..8f87b36ef3d9
--- /dev/null
+++ b/docs/articles/expensify-classic/expensify-card/Set-Up-the-Card-for-Your-Company.md
@@ -0,0 +1,67 @@
+---
+title: Set Up the Card for your Company
+description: Details on setting up the Expensify Card for your company as an admin
+---
+# Overview
+
+If you’re an admin interested in rolling out the Expensify Card for your organization, you’re in the right place. This article will cover how to qualify and apply for the Expensify Card program and begin issuing cards to your employees.
+
+# How to qualify for the Expensify Card program
+
+There are three prerequisites to consider before applying for the Expensify Card:
+
+1. The email address associated with your account must be on a private domain
+2. You must claim your private domain in Expensify
+3. You must add and verify a US business bank account to your Expensify account
+
+To claim a domain, you must be a workspace admin with a company email address matching the domain you want to claim. After you create an account and set up a workspace, head to **Settings > Domains** to claim your domain.
+
+You can add a business bank account by navigating to **Settings > Account > Payments** and clicking Add Verified Bank Account. Follow the setup steps and complete the verification process as required.
+
+# How to apply for the Expensify Card
+
+Once you’ve claimed your domain and added a verified US business bank account, you can apply for the Expensify Card. There are multiple ways to apply for the card from the web:
+
+## From the home page
+
+1. Log into your Expensify account using your preferred web browser
+2. Head to your account’s home page
+3. On the task that says “Introducing the Expensify Card,” click **Enable my Expensify Cards** to get started
+
+## From the Company Cards page
+
+1. Log into your Expensify account using your preferred web browser
+2. Head to **Settings > Domains > _Domain Name_ > Company Cards**
+3. Click **Get the Card**
+
+After we receive your application, we’ll review it ASAP and send you a confirmation email with the next steps once we have them.
+
+# How to issue cards
+
+After you’ve been approved, it’s time to set limits for your employees. Setting a limit triggers an email and task on the home page requesting the employee’s shipping address. Once they enter their details, a card will be shipped to them. We’ll also create a virtual card for the employee that can be used immediately.
+
+To set a limit, head over to the Company Cards UI via **Settings > Domains > _Domain Name_ > Company Cards**. Click the **Edit Limit** button next to members who need a card assigned, and set a non-$0 to issue them a card.
+
+If you have a validated domain, you can set a limit for multiple members by setting a limit for an entire domain group via **Settings > Domains > _Domain Name_ > Groups**. Keep in mind that custom limits that are set on an individual basis will override the group limit.
+
+The Company Cards page will act as a hub to view all employees who have been issued a card and where you can view and edit the individual card limits. You’ll also be able to see anyone who has requested a card but doesn’t have one yet.
+
+# FAQ
+
+## Are there foreign transaction fees?
+
+There are no foreign transaction fees when using your Expensify Card for international purchases.
+
+## How does the Expensify Card affect my or my company's credit score?
+
+Applying for or using the Expensify Card will never have any positive or negative effect on your personal credit score or your business's credit score. We do not consider your or your business' credit score when determining approval and your card limit.
+
+## How much does the Expensify Card cost?
+
+The Expensify Card is a free corporate card, and no fees are associated with it. In addition, if you use the Expensify Card, you can save money on your Expensify subscription.
+
+## If I have staff outside the US, can they use the Expensify Card?
+
+As long as the verified bank account used to apply for the Expensify Card is a US bank account, your cardholders can be anywhere in the world.
+
+Otherwise, the Expensify Card is not available for customers using non-US banks. With that said, launching international support is a top priority for us. Let us know if you’re interested in contacting support, and we’ll reach out as soon as the Expensify Card is available outside the United States.
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 a7553e6ae179..d933e66cc2d1 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
@@ -3,18 +3,18 @@ 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
+# 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.
- See our [US-based VC-Backed Startups](https://help.expensify.com/articles/playbooks/Expensify-Playbook-for-US-based-VC-Backed-Startups) if you are more concerned with top-line revenue growth
-## Who you are
+# Who you are
As a small to medium-sized business owner, your main aim is to achieve success and grow your business. To achieve your goals, it is crucial that you make worthwhile investments in both your workforce and your business processes. This means providing your employees with the resources they need to generate revenue effectively, while also adopting measures to guarantee that expenses are compliant.
-## Step-by-step instructions for setting up Expensify
+# Step-by-step instructions for setting up Expensify
This playbook is built on best practices we’ve developed after processing expenses for tens of thousands of companies around the world. As such, use this playbook as your starting point, knowing that you can customize Expensify to suit your business needs. Every company is different, and your dedicated Setup Specialist is always one chat away with any questions you may have.
-### Step 1: Create your Expensify account
+## Step 1: Create your Expensify account
If you don't already have one, go to *[new.expensify.com](https://new.expensify.com)* and sign up for an account with your work email address. The account is free so don’t worry about the cost at this stage.
> _Employees really appreciate how easy it is to use, and the fact that the reimbursement drops right into their bank account. Since most employees are submitting expenses from their phones, the ease of use of the app is critical_
@@ -22,7 +22,7 @@ If you don't already have one, go to *[new.expensify.com](https://new.expensify.
> **Robyn Gresham**
> Senior Accounting Systems Manager at SunCommon
-### Step 2: Create a Control Policy
+## Step 2: Create a Control Policy
There are three policy types, but for your small business needs we recommend the *Control Plan* for the following reasons:
- *The Control Plan* is designed for organizations with a high volume of employee expense submissions, who also rely on compliance controls
@@ -40,7 +40,7 @@ To create your Control Policy:
The Control Plan also gives you access to a dedicated Setup Specialist. You can find yours by looking at your policy's *#admins* room in *[new.expensify.com](https://new.expensify.com)*, and in your company’s policy settings in the *Overview* tab, where you can chat with them and schedule an onboarding call to walk through any setup questions. The Control Plan bundled with the Expensify Card is only *$9 per user per month* (not taking into account cash back your earn) when you commit annually. That’s a 75% discount off the unbundled price point if you choose to use a different Corporate Card (or no) provider.
-### Step 3: Connect your accounting system
+## Step 3: Connect your accounting system
As a small to medium-sized business, it's important to maintain proper spend management to ensure the success and stability of your organization. This requires paying close attention to your expenses, streamlining your financial processes, and making sure that your financial information is accurate, compliant, and transparent. Include best practices such as:
- Every purchase is categorized into the correct account in your chart of accounts
@@ -65,7 +65,7 @@ Check out the links below for more information on how to connect to your account
*“Employees really appreciate how easy it is to use, and the fact that the reimbursement drops right into their bank account. Since most employees are submitting expenses from their phones, the ease of use of the app is critical.”*
- Robyn Gresham, Senior Accounting Systems Manager at SunCommon
-### Step 4: Set category-specific compliance controls
+## Step 4: Set category-specific compliance controls
Head over to the *Categories* tab to set compliance controls on your newly imported list of categories. More specifically, we recommend the following:
1. First, enable *People Must Categorize Expenses*. Employees must select a category for each expense, otherwise, in most cases, it’s more work on you and our accounting connections will simply reject any attempt to export.
@@ -78,7 +78,7 @@ Head over to the *Categories* tab to set compliance controls on your newly impor
3. Disable any irrelevant expense categories that aren’t associated with employee spend
4. Configure *auto-categorization*, located just below your category list in the same tab. The section is titled *Default Categories*. Just find the right category, and match it with the presented category groups to allow for MCC (merchant category code) automated category selection with every imported connected card transaction.
-### Step 5: Make sure tags are required, or defaults are set
+## Step 5: Make sure tags are required, or defaults are set
Tags in Expensify often relate to departments, projects/customers, classes, and so on. And in some cases they are *required* to be selected on every transactions. And in others, something like *departments* is a static field, meaning we could set it as an employee default and not enforce the tag selection with each expense.
*Make Tags Required*
@@ -89,7 +89,7 @@ In the tags tab in your policy settings, you’ll notice the option to enable th
*Set Tags as an Employee Default*
Separately, if your policy is connected to NetSuite or Sage Intacct, you can set departments, for example, as an employee default. All that means is we’ll apply the department (for example) that’s assigned to the employee record in your accounting package and apply that to every exported transaction, eliminating the need for the employee to have to manually select a department for each expense.
-### Step 6: Set rules for all expenses regardless of categorization
+## Step 6: Set rules for all expenses regardless of categorization
In the Expenses tab in your group Control policy, you’ll notice a *Violations* section designed to enforce top-level compliance controls that apply to every expense, for every employee in your policy. We recommend the following confiuration:
*Max Expense Age: 90 days (or leave it blank)*
@@ -105,7 +105,7 @@ Receipts are important, and in most cases you prefer an itemized receipt. Howeve
At this point, you’ve set enough compliance controls around categorical spend and general expenses for all employees, such that you can put trust in our solution to audit all expenses up front so you don’t have to. Next, let’s dive into how we can comfortably take on more automation, while relying on compliance controls to capture bad behavior (or better yet, instill best practices in our employees).
-### Step 7: Set up scheduled submit
+## Step 7: Set up scheduled submit
For an efficient company, we recommend setting up [Scheduled Submit](https://community.expensify.com/discussion/4476/how-to-enable-scheduled-submit-for-a-group-policy) on a *Daily* frequency:
- Click *Settings > Policies*
@@ -125,7 +125,7 @@ Expenses with violations will stay behind for the employee to fix, while expense
> Kevin Valuska
> AP/AR at Road Trippers
-### Step 8: Connect your business bank account (US only)
+## Step 8: Connect your business bank account (US only)
If you’re located in the US, you can utilize Expensify’s payment processing and reimbursement features.
*Note:* Before you begin, you’ll need the following to validate your business bank account:
@@ -145,7 +145,7 @@ Let’s walk through the process of linking your business bank account:
You only need to do this once: you are fully set up for not only reimbursing expense reports, but issuing Expensify Cards, collecting customer invoice payments online (if applicable), as well as paying supplier bills online.
-### Step 9: Invite employees and set an approval workflow
+## Step 9: Invite employees and set an approval workflow
*Select an Approval Mode*
We recommend you select *Advanced Approval* as your Approval Mode to set up a middle-management layer of a approval. If you have a single layer of approval, we recommend selecting [Submit & Approve](https://community.expensify.com/discussion/5643/deep-dive-submit-and-approve). But if *Advanced Approval* if your jam, keep reading!
@@ -159,13 +159,13 @@ In most cases, at this stage, approvers prefer to review all expenses for a few
In this case we recommend setting *Manually approve all expenses over: $0*
-### Step 10: Configure Auto-Approval
+## Step 10: Configure Auto-Approval
Knowing you have all the control you need to review reports, we recommend configuring auto-approval for *all reports*. Why? Because you’ve already put reports through an entire approval workflow, and manually triggering reimbursement is an unnecessary action at this stage.
1. Navigate to *Settings > Policies > Group > [Policy Name] > Reimbursement*
2. Set your *Manual Reimbursement threshold to $20,0000*
-### Step 11: Enable Domains and set up your corporate card feed for employees
+## Step 11: Enable Domains and set up your corporate card feed for employees
Expensify is optimized to work with corporate cards from all banks – or even better, use our own perfectly integrated *[Expensify Card](https://use.expensify.com/company-credit-card)*. The first step for connecting to any bank you use for corporate cards, and the Expensify Card is to validate your company’s domain in Domain settings.
To do this:
@@ -173,7 +173,7 @@ To do this:
- Click *Settings*
- Then select *Domains*
-#### If you have an existing corporate card
+### If you have an existing corporate card
Expensify supports direct card feeds from most financial institutions. Setting up a corporate card feed will pull in the transactions from the connected cards on a daily basis. To set this up, do the following:
1. Go to *Company Cards >* Select your bank
@@ -187,7 +187,7 @@ Expensify supports direct card feeds from most financial institutions. Setting u
As mentioned above, we’ll be able to pull in transactions as they post (daily) and handle receipt matching for you and your employees. One benefit of the Expensify Card for your company is being able to see transactions at the point of purchase which provides you with real-time compliance. We even send users push notifications to SmartScan their receipt when it’s required and generate IRS-compliant e-receipts as a backup wherever applicable.
-#### If you don't have a corporate card, use the Expensify Card (US only)
+### If you don't have a corporate card, use the Expensify Card (US only)
Expensify provides a corporate card with the following features:
- Up to 2% cash back (up to 4% in your first 3 months!)
@@ -214,7 +214,7 @@ Once the Expensify Cards have been assigned, each employee will be prompted to e
If you have an accounting system we directly integrate with, check out how we take automation a step further with [Continuous Reconciliation](https://community.expensify.com/discussion/7335/faq-what-is-the-expensify-card-auto-reconciliation-process). We’ll create an Expensify Card clearing and liability account for you. Each time settlement occurs, we’ll take the total amount of your purchases and create a journal entry that credits the settlement account and debits the liability account - saving you hours of manual reconciliation work at the end of your statement period.
-### Step 12: Set up Bill Pay and Invoicing
+## Step 12: Set up Bill Pay and Invoicing
As a small business, managing bills and invoices can be a complex and time-consuming task. Whether you receive bills from vendors or need to invoice clients, it's important to have a solution that makes the process simple, efficient, and cost-effective.
Here are some of the key benefits of using Expensify for bill payments and invoicing:
@@ -246,7 +246,7 @@ Reports, invoices, and bills are largely the same, in theory, just with differen
You’ll notice it’s a slightly different flow from creating a Bill. Here, you are adding the transactions tied to the Invoice, and establishing a due date for when it needs to get paid. If you need to apply any markups, you can do so from your policy settings under the Invoices tab. Your customers can pay their invoice in Expensify via ACH, or Check, or Credit Card.
-### Step 13: Run monthly, quarterly and annual reporting
+## Step 13: Run monthly, quarterly and annual reporting
At this stage, reporting is important and given that Expensify is the primary point of entry for all employee spend, we make reporting visually appealing and wildly customizable.
1. Head to the *Expenses* tab on the far left of your left-hand navigation
@@ -261,7 +261,7 @@ We recommend reporting:
![Expenses!](https://help.expensify.com/assets/images/playbook-expenses.png){:width="100%"}
-### Step 14: Set your Subscription Size and Add a Payment card
+## Step 14: Set your Subscription Size and Add a Payment card
Our pricing model is unique in the sense that you are in full control of your billing. Meaning, you have the ability to set a minimum number of employees you know will be active each month and you can choose which level of commitment fits best. We recommend setting your subscription to *Annual* to get an additional 50% off on your monthly Expensify bill. In the end, you've spent enough time getting your company fully set up with Expensify, and you've seen how well it supports you and your employees. Committing annually just makes sense.
To set your subscription, head to:
@@ -280,5 +280,5 @@ Now that we’ve gone through all of the steps for setting up your account, let
3. Enter your name, card number, postal code, expiration and CVV
4. Click *Accept Terms*
-## You’re all set!
+# You’re all set!
Congrats, you are all set up! If you need any assistance with anything mentioned above or would like to understand other features available in Expensify, reach out to your Setup Specialist directly in *[new.expensify.com](https://new.expensify.com)*. Don’t have one yet? Create a Control Policy, and we’ll automatically assign a dedicated Setup Specialist to you.
diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct.md b/docs/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct.md
index 3ee1c8656b4b..ac0a90ba6d37 100644
--- a/docs/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct.md
+++ b/docs/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct.md
@@ -1,5 +1,568 @@
---
-title: Coming Soon
-description: Coming Soon
+title: Sage Intacct
+description: Connect your Expensify workspace with Sage Intacct
---
-## Resource Coming Soon!
+# Overview
+Expensify’s seamless integration with Sage Intacct allows you to connect using either Role-based permissions or User-based permissions.
+
+Once connected to Intacct you’re able to automate report exports, customize your coding preferences, and utilize Sage Intacct’s advanced features. When you’ve configured these settings in Expensify correctly, you can use the integration's settings to automate many tasks, streamlining your workflow for increased efficiency.
+
+# How to connect to Sage Intacct
+We support setting up Sage Intacct with both User-based permissions and Role-based permissions for Expense Reports and Vendor Bills.
+- User-based Permissions - Expense Reports
+- User-based Permissions - Vendor Bills
+- Role-based Permissions - Expense Reports
+- Role-based Permissions - Vendor Bills
+
+
+## User-based Permissions - Expense Reports
+
+Please follow these steps if exporting as Expense Reports with **user-based permissions**.
+
+
+### Checklist of items to complete:
+1. Create a web services user and set up permissions.
+2. Enable the Time & Expenses module **(Required if exporting as Expense Reports)**.
+3. Set up Employees in Sage Intacct **(Required if exporting as Expense Reports)**.
+4. Set up Expense Types in Sage Intacct **(Required if exporting as Expense Reports)**.
+5. Enable Customization Services (only applicable if you don't already use Platform Services).
+6. Create a test workspace and download the [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage).
+7. Upload the Package in Sage Intacct.
+8. Add web services authorization.
+9. Enter credentials and connect Expensify and Sage Intacct.
+10. Configure integration sync options.
+11. Export a test report.
+12. Connect Sage Intacct to the production workspace.
+
+
+### Step 1: Create a web services user with user-based permissions
+
+_Note: If the steps in this section look different in your Sage Intacct instance, you likely use role-based permissions. If that's the case, see the steps below on creating a web services user for role-based permissions._
+To connect to Sage Intacct, you'll need to create a special web services user. This user is essential for tracking actions in Sage Intacct, such as exporting expense reports and credit card charges from Expensify. It also helps ensure smooth operations when new members join or leave your accounting team. The good news is that setting up this web services user won't cost you anything. Just follow these steps:
+Go to **Company > Web Services Users > New**
+Setup the user using these configurations:
+ - **User ID:** "xmlgateway_expensify"
+ - **Last Name and First Name:** "Expensify"
+ - **Email Address:** Your shared accounting team email
+ - **User Type:** "Business"
+ - **Admin Privileges:** "Full"
+ - **Status:** "Active"
+Once you've created the user, you'll need to set the correct permissions. To set those, go to the **subscription** link for this user in the user list, **click on the checkbox** next to the Application/Module and then click on the **Permissions** link to modify those.
+
+These are the permissions required for a user to export reimbursable expenses as Expense Reports:
+- **Administration (All)**
+- **Company (Read-only)**
+- **Cash Management (All)**
+- **General Ledger (All)**
+- **Time & Expense (All)**
+- **Projects (Read-only)** (only needed if using Projects and Customers)
+- **Accounts Payable (All)** (only needed for exporting non-reimbursable expenses as vendor bills)
+
+**Note:** you can set permissions for each Application/Module by selecting the radio button next to the desired Permission and clicking **Save**.
+
+
+### Step 2: Enable the Time & Expenses Module (Only required if exporting reimbursable expenses as Expense Reports)
+The Time & Expenses (T&E) module is often included in your Sage Intacct instance, but if it wasn't part of your initial Sage Intacct setup, you may need to enable it. **Enabling the T&E module is a paid subscription through Sage Intacct. For information on the costs of enabling this module, please contact your Sage Intacct account manager**. It's necessary for our integration and only takes a few minutes to configure.
+1. In Sage Intacct, go to the **Company menu > Subscriptions > Time & Expenses** and toggle the switch to subscribe.
+2. After enabling T&E, configure it as follows:
+ - Ensure that **Expense types** is checked:
+ - Under **Auto-numbering sequences** set the following:
+ - **Expense Report:** EXP
+ - **Employee:** EMP
+ - **Duplicate Numbers:** Select “Do not allow creation”
+
+ - To create the EXP sequence, **click on the down arrow on the expense report line and select **Add**:
+ - **Sequence ID:** EXP
+ - **Print Title:** EXPENSE REPORT
+ - **Starting Number:** 1
+ - **Next Number:** 2
+3. Select **Advanced Settings** and configure the following:
+- **Fixed Number Length:** 4
+- **Fixed Prefix:** EXP
+4. Click **Save**
+5. Under Expense Report approval settings, ensure that **Enable expense report approval** is unchecked
+6. Click **Save** to confirm your configurations.
+
+
+### Step 3: Set up Employees in Sage Intacct (Only required if exporting reimbursable expenses as Expense Reports)
+To set up Employees in Sage Intacct, follow these steps:
+1. Navigate to **Time & Expenses** and click the plus button next to **Employees**.
+ - If you don't see the Time & Expense option in the top ribbon, you may need to adjust your settings. Go to **Company > Roles > Time & Expenses** and enable all permissions.
+2. To create an employee, you'll need to provide the following information:
+ - **Employee ID**
+ - **Primary contact name**
+ - **Email address**
+ - In the **Primary contact name** field, click the dropdown arrow.
+ - Select the employee if they've already been created.
+ - Otherwise, click **+ Add** to create a new employee.
+ - Fill in their **Primary Email Address** along with any other required information.
+
+
+### Step 4: Set up Expense Types in Sage Intacct (Only required if exporting reimbursable expenses as Expense Reports)
+
+Expense Types provide a user-friendly way to display the names of your expense accounts to your employees. They are essential for our integration. To set up Expense Types, follow these steps:
+1. **Setup Your Chart of Accounts:** Before configuring Expense Types, ensure your Chart of Accounts is set up. You can set up accounts in bulk by going to **Company > Open Setup > Company Setup Checklist > click Import**.
+2. **Set up Expense Types:**
+ - Go to **Time & Expense**.
+ - Open Setup and click the plus button next to **Expense Types**.
+3. For each Expense Type, provide the following information:
+ - **Expense Type**
+ - **Description**
+ - **Account Number** (from your General Ledger)
+This step is necessary if you are exporting reimbursable expenses as Expense Reports.
+
+
+### Step 5: Enable Customization Services
+To enable Customization Services go to **Company > Subscriptions > Customization Services**.
+ - If you already have Platform Services enabled, you can skip this step.
+
+
+### Step 6: Create a Test Workspace in Expensify and Download the [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage)
+Creating a test workspace in Expensify allows you to have a sandbox environment for testing before implementing the integration live. If you are already using Expensify, creating a test workspace ensures that your existing group workspace rules and approval workflows remain intact. Here's how to set it up:
+1. Go to **expensify.com > Settings > Workspaces > New Workspace**.
+2. Name the workspace something like "Sage Intacct Test Workspace."
+3. Go to **Connections > Sage Intacct > Connect to Sage Intacct**.
+4. Select **Download Package** (You only need to download the file; we'll upload it from your Downloads folder later).
+
+
+### Step 7: Upload Package in Sage Intacct
+
+
+If you use **Customization Services**:
+1. Go to **Customization Services > Custom Packages > New Package**.
+2. Click on **Choose File** and select the Package file from your downloads folder.
+3. Click **Import**.
+
+
+If you use **Platform Services**:
+1. Go to **Platform Services > Custom Packages > New Package**.
+2. Click on **Choose File** and select the Package file from your downloads folder.
+3. Click **Import**.
+
+
+### Step 8: Add Web Services Authorization
+1. Go to **Company > Company Info > Security** in Intacct and click **Edit**.
+2. Scroll down to **Web Services Authorizations** and add "expensify" (all lower case) as a Sender ID.
+
+
+### Step 9: Enter Credentials and Connect Expensify and Sage Intacct
+
+
+1. Go back to **Settings > Workspaces > Group > [Workspace Name] > Connections > Configure**.
+2. Click **Connect to Sage Intacct** and enter the credentials you've set for your web services user.
+3. Click **Send** once you're done.
+
+Next, you’ll configure the Export, Coding, and Advanced tabs of the connection configuration in Expensify.
+
+
+## User-based Permissions - Vendor Bills
+In this setup guide, we'll take you through the steps to establish your connection for Vendor Bills with user-based permissions. Please follow this checklist of items to complete:
+1. Create a web services user and set up permissions.
+2. Enable Customization Services (only required if you don't already use Platform Services).
+3. Create a test workspace in Expensify and download the [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage)
+4. Upload the Package in Sage Intacct.
+5. Add web services authorization.
+6. Enter credentials and connect Expensify and Sage Intacct.
+7. Configure integration sync options.
+
+
+### Step 1: Create a web services user with user-based permissions
+**Note:** If the steps in this section look different in your Sage Intacct instance, you likely use role-based permissions. If that's the case, see the steps below on creating a web services user for role-based permissions.
+To connect to Sage Intacct, it's necessary to set up a web services user. This user simplifies tracking activity within Sage Intacct, such as exporting expense reports and credit card charges from Expensify. It also ensures a seamless transition when someone joins or leaves your accounting department. Setting up the web services user is free of charge. Please follow these steps:
+1. Go to **Company > Web Services Users > New**.
+2. Configure the user as shown in the screenshot below, making sure to follow these guidelines:
+ - **User ID:** "xmlgateway_expensify"
+ - **Last Name and First Name:** "Expensify"
+ - **Email Address:** Your shared accounting team email
+ - **User Type:** "Business"
+ - **Admin Privileges:** "Full"
+ - **Status:** "Active"
+
+
+Once you've created the user, you'll need to set the correct permissions. To do this, follow these steps:
+1. Go to the subscription link for this user in the user list.
+2. Click on the checkbox next to the Application/Module you want to modify permissions for.
+3. Click on the **Permissions** link to make modifications.
+
+These are the permissions the user needs to have if exporting reimbursable expenses as Vendor Bills:
+- **Administration (All)**
+- **Company (Read-only)**
+- **Cash Management (All)**
+- **General Ledger (All)**
+- **Accounts Payable (All)**
+- **Projects (Read-only)** (required if you're going to be using Projects and Customers)
+
+**Note:** that selecting the radio button next to the Permission you want and clicking **Save** will set the permission for that particular Application/Module.
+
+
+### Step 2: Enable Customization Services (only applicable if you don't already use Platform Services)
+To enable Customization Services go to **Company > Subscriptions > Customization Services**.
+ - If you already have Platform Services enabled, you can skip this step.
+
+### Step 3: Create a Test Workspace in Expensify and Download [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage)
+Creating a test workspace in Expensify allows you to establish a sandbox environment for testing before implementing the integration in a live environment. If you're already using Expensify, creating a test workspace ensures that your existing company workspace rules and approval workflows remain intact. Here's how to set it up:
+1. Go to **expensify.com > Settings > Workspaces > Groups > New Workspace**.
+2. Name the workspace something like "Sage Intacct Test Workspace."
+3. Go to **Connections > Sage Intacct > Connect to Sage Intacct**.
+4. Select "I've completed these" if you've downloaded the [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage) and completed the previous steps in Sage Intacct.
+5. Select **Download Package** (You only need to download the file; we'll upload it from your Downloads folder later).
+
+### Step 4: Upload the Package in Sage Intacct
+If you use **Customization Services**:
+
+1. Go to **Customization Services > Custom Packages > New Package**.
+2. Click on **Choose File** and select the Package file from your downloads folder.
+3. Click **Import**.
+
+
+If you use **Platform Services**:
+
+1. Go to **Platform Services > Custom Packages > New Package**.
+2. Click on **Choose File** and select the Package file from your downloads folder.
+3. Click **Import**.
+
+### Step 5: Add Web Services Authorization
+1. Go to **Company > Company Info > Security** in Intacct and click **Edit**.
+2. Scroll down to **Web Services Authorizations** and add "expensify" (all lowercase) as a Sender ID.
+
+### Step 6: Enter Credentials and Connect Expensify with Sage Intacct
+1. Go back to **Settings > Workspaces > Groups > [Workspace Name] > Connections > Configure**.
+2. Click on **Connect to Sage Intacct**.
+3. Enter the credentials that you've previously set for your web services user.
+4. Click **Send** once you've finished entering the credentials.
+
+Next, you’ll configure the Export, Coding, and Advanced tabs of the connection configuration in Expensify.
+
+
+
+## Role-based Permissions - Expense Reports
+
+For this setup guide, we're going to walk you through how to get your connection up and running as Expense Reports with role-based permissions.
+
+### Checklist of items to complete:
+
+1. Create web services user and set up permissions
+2. Enable Time & Expenses module
+3. Set up Employees in Sage Intacct
+4. Set up Expense Types in Sage Intacct
+5. Enable Customization Services (only applicable if you don't already use Platform Services)
+6. Create a test workspace and download the [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage)
+7. Upload the Package in Sage Intacct
+8. Add web services authorization
+9. Enter credentials and connect Expensify and Sage Intacct
+10. Configure integration sync options
+
+
+### Step 1: Create a web services user with role-based permissions
+
+In Sage Intacct, click **Company**, then click on the **+** button next to **Roles**.
+
+Name the role, then click **Save**.
+
+Go to **Roles > Subscriptions** for the "Expensify" role you just created.
+
+Set the permissions for this role by clicking the checkbox and then clicking on the **Permissions** hyperlink.
+
+These are the permissions required for a user to export reimbursable expenses as Expense Reports:
+- **Administration (All)**
+- **Company (Read-only)**
+- **Cash Management (All)**
+- **General Ledger (All)**
+- **Time & Expense (All)**
+- **Projects (Read-only)** (only needed if using Projects and Customers)
+- **Accounts Payable (All)** (only needed for exporting non-reimbursable expenses as vendor bills)
+
+Now, you'll need to create a web services user and assign this role to that user.
+
+- **Company > Web Services Users > New**
+- Set up the user like the screenshot below, making sure to do the following:
+ - User ID: “xmlgateway_expensify"
+ - Last name and First name: "Expensify"
+ - Email address: your shared accounting team email
+ - User type: "Business"
+ - Admin privileges: "Full"
+ - Status: "Active"
+
+To assign the role, go to **Roles Information**:
+
+- Click the **+** button, then find the "Expensify" role and click **Save**.
+
+### Step 2: Enable the Time & Expenses module (Only required if exporting reimbursable expenses as Expense Reports)
+
+The T&E module often comes standard on your Sage Intacct instance, but you may need to enable it if it was not a part of your initial Sage Intacct implementation. Enabling the T&E module is a paid subscription through Sage Intacct. Please reach out to your Sage Intacct account manager with any questions on the costs of enabling this module. It's required for our integration and takes just a few minutes to configure.
+
+In Sage Intacct, click on the **Company** menu > **Subscriptions** > **Time & Expenses** and click the toggle to subscribe.
+
+Once you've enabled T&E, you'll need to configure it properly:
+- Ensure that **Expense types** is checked.
+- Under Auto-numbering sequences, please set the following:
+ - To create the EXP sequence, click on the down arrow on the expense report line > **Add**
+ - Sequence ID: EXP
+ - Print Title: EXPENSE REPORT
+ - Starting Number: 1
+ - Next Number: 2
+ - Once you've done this, select **Advanced Settings**
+ - Fixed Number Length: 4
+ - Fixed Prefix: EXP
+ - Once you've done this, hit **Save**
+- Under Expense Report approval settings, make sure the "Enable expense report approval" is unchecked.
+- Click **Save**!
+
+### Step 3: Set up Employees in Sage Intacct (Only required if exporting reimbursable expenses as Expense Reports)
+
+In order to set up Employees, go to **Time & Expenses** > click the plus button next to **Employees**. If you don't see Time & Expense in the top ribbon, you may need to adjust your settings by going to **Company > Roles > Time & Expenses > Enable all permissions**. To create an employee, you'll need to insert the following information:
+- Employee ID
+- Primary contact name
+- Email address (click the dropdown arrow in the Primary contact name field) > select the employee if they've already been created. Otherwise click **+ Add** > fill in their Primary Email Address along with any other information you require.
+
+### Step 4: Set up Expense Types in Sage Intacct (only required if exporting reimbursable expenses as Expense Reports)
+
+Expense Types are a user-friendly way of displaying the names of your expense accounts to your employees. They are required for our integration. In order to set up Expense Types, you'll first need to setup your Chart of Accounts (these can be set up in bulk by going to **Company > Open Setup > Company Setup Checklist > click Import**).
+
+Once you've setup your Chart of Accounts, to set Expense Types, go to **Time & Expense** > **Open Setup** > click the plus button next to **Expense Types**. For each Expense Type, you'll need to include the following information:
+- Expense Type
+- Description
+- Account Number (from your GL)
+
+### Step 5: Enable Customization Services
+
+To enable, go **Company > Subscriptions > Customization Services** (if you already have Platform Services enabled, you will skip this step).
+
+### Step 6: Create a test workspace in Expensify and download [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage)
+
+The test workspace will be used as a sandbox environment where we can test before going live with the integration. If you're already using Expensify, creating a test workspace will ensure that your existing group workspace rules, approval workflow, etc remain intact. In order to set this up:
+
+- Go to **expensify.com > Settings > Workspaces > New Workspace**
+- Name the workspace something like "Sage Intacct Test Workspace"
+- Go to **Connections > Sage Intacct > Connect to Sage Intacct**
+- Select **Download Package** (All you need to do is download the file. We'll upload it from your Downloads folder later).
+
+### Step 7: Upload Package in Sage Intacct
+
+If you use Customization Services:
+
+- **Customization Services > Custom Packages > New Package > Choose File >** select the Package file from your downloads folder > Import
+
+If you use Platform Services:
+
+- **Platform Services > Custom Packages > New Package > Choose File >** select the Package file from your downloads folder > Import
+
+### Step 8: Add web services authorization
+
+- Go to **Company > Company Info > Security** in Intacct and click Edit. Next, scroll down to Web Services authorizations and add "expensify" (this must be all lower case) as a Sender ID.
+
+### Step 9: Enter credentials and connect Expensify and Sage Intacct
+
+- Now, go back to **Settings > Workspaces > Group > [Workspace Name] > Connections > Configure > Connect to Sage Intacct** and enter the credentials that you've set for your web services user. Click Send once you're done.
+
+Next, follow the links in the related articles section below to complete the configuration for the Export, Coding, and Advanced tabs of the connection settings.
+
+## Role-based Permissions - Vendor Bills
+
+Follow the steps below to set up Sage Intacct with role-based permissions and export Vendor Bills:
+
+### Checklist of items to complete:
+
+1. Create a web services user and configure permissions.
+2. Enable Customization Services (if not using Platform Services).
+3. Create a test workspace in Expensify and download the [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage).
+4. Upload the Package in Sage Intacct.
+5. Add web services authorization.
+6. Enter credentials and connect Expensify and Sage Intacct.
+7. Configure integration sync options.
+
+
+### Step 1: Create a web services user with role-based permissions
+
+In Sage Intacct:
+- Navigate to "Company" and click the **+** button next to "Roles."
+- Name the role and click **Save**.
+- Go to "Roles" > "Subscriptions" for the "Expensify" role you created.
+- Set the permissions for this role by clicking the checkbox and then clicking on the Permissions hyperlink
+
+
+These are the permissions required for a user to export reimbursable expenses as Vendor Bills:
+- **Administration (All)**
+- **Company (Read-only)**
+- **Cash Management (All)**
+- **General Ledger (All)**
+- **Time & Expense (All)**
+- **Projects (Read-only)** (only needed if using Projects and Customers)
+- **Accounts Payable (All)** (only needed for exporting non-reimbursable expenses as vendor bills)
+
+
+- Create a web services user:
+ - Go to **Company > Web Services Users > New**
+ - Configure the user as follows:
+ - User ID: "xmlgateway_expensify"
+ - Last Name and First Name: "Expensify"
+ - Email Address: Your shared accounting team email
+ - User Type: "Business"
+ - Admin Privileges: "Full"
+ - Status: "Active"
+ - To assign the role, go to "Roles Information", click the **+** button, find the "Expensify" role, and click **Save**
+
+### Step 2: Enable Customization Services
+
+Only required if you don't already use Platform Services:
+- To enable, go to **Company > Subscriptions > Customization Services**
+
+### Step 3: Create a test workspace in Expensify and download the [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage)
+
+Create a test workspace in Expensify:
+- Go to **Settings > Workspaces** and click **New Workspace** on the Expensify website.
+- Name the workspace something like "Sage Intacct Test Workspace."
+- Once created, navigate to **Settings > Workspaces > [Group Workspace Name] > Connections > Accounting Integrations > Connect to Sage Intacct**
+- Select **Create a new Sage Intacct connection/Connect to Sage Intacct**
+- Select **Download Package** (We'll upload it from your Downloads folder later.)
+
+### Step 4: Upload Package in Sage Intacct
+
+If you use **Customization Services**:
+- Go to **Customization Services > Custom Packages > New Package > Choose File > select the Package file from your downloads folder > Import**.
+
+If you use **Platform Services**:
+- Go to **Platform Services > Custom Packages > New Package > Choose File > select the Package file from your downloads folder > Import**.
+
+### Step 5: Add web services authorization
+
+- Go to **Company > Company Info > Security** in Intacct and click **Edit**
+- Scroll down to **Web Services Authorizations** and add **expensify** (all lowercase) as a Sender ID.
+
+### Step 6: Enter credentials and connect Expensify and Sage Intacct
+
+Now, go back to **Settings > Workspaces > [Group Workspace Name] > Connections > Accounting Integrations > Configure > Connect to Sage Intacct** and enter the credentials you set for your web services user. Click **Send** when finished.
+
+### Step 7: Configure your connection
+
+Once the initial sync completes, you may receive the error "No Expense Types Found" if you're not using the Time and Expenses module. Close the error dialogue, and your configuration options will appear. Switch the reimbursable export option to **Vendor Bills** and click **Save** before completing your configuration.
+
+Next, refer to the related articles section below to finish configuring the Export, Coding, and Advanced tabs of the connection configuration.
+
+# How to configure export settings
+
+When you connect Intacct with Expensify, you can configure how information appears once exported. To do this, Admins who are connected to Intacct can go to **Settings > Workspaces > Group > [Workspace Name] > Connections**, and then click on **Configure** under Intacct. This is where you can set things up the way you want.
+
+
+## Preferred Exporter
+
+Any workspace admin can export to Sage Intacct, but only the preferred exporter will see reports that are ready for export in their Inbox.
+
+
+
+## Date
+
+Choose which date you would like your Expense Reports or Vendor Bills to use when exported.
+
+- **Date of last expense:** Uses the date on the most recent expense added to the report.
+- **Exported date:** Is the date you export the report to Sage Intacct.
+- **Submitted date:** Is the date the report creator originally submitted the report.
+
+All export options except credit cards use the date in the drop-down. Credit card transactions use the transaction date.
+
+## Reimbursable Expenses
+
+Depending on your initial setup, your **reimbursable expenses** will be exported as either **Expense Reports** or **Vendor Bills** to Sage Intacct.
+
+## Non-Reimbursable Expenses
+
+**Non-reimbursable expenses** will export separately from reimbursable expenses, either as **Vendor Bills**, or as **credit card charges** to the account you select. It is not an option to export non-reimbursable expenses as **Journal** entries.
+
+
+If you are centrally managing your company cards through Domain Control, you can export expenses from each individual card to a specific account in Intacct.
+Please note, Credit Card Transactions cannot be exported to Sage Intacct at the top-level if you have **Multi-Currency** enabled, so you will need to select an entity in the configuration of your Expensify Workspace by going to **Settings > Workspaces > Groups > [Workspace Name] > Connections > Configure**.
+
+## Exporting Negative Expenses
+
+You can export negative expenses successfully to Intacct regardless of which Export Option you choose. The one thing to keep in mind is that if you have Expense Reports selected as your export option, the **total** of the report can not be negative.
+
+# How to configure coding settings
+
+The appearance of your expense data in Sage Intacct depends on how you've configured it in Expensify. It's important to understand each available option to achieve the desired results.
+
+## Expense Types
+
+Categories are always enabled and are the primary means of matching expenses to the correct accounts in Sage Intact. The Categories in Expensify depend on your **Reimbursable** export options:
+- If your Reimbursable export option is set to **Expense Reports** (the default), your Categories will be your **Expense Types**.
+- If your Reimbursable export option is set to **Vendor Bills**, your Categories will be your **Chart of Accounts** (also known as GL Codes or Account Codes).
+
+You can disable unnecessary categories from your **Settings > Workspaces > Group > [Workspace Name] > Categories** page if your list is too extensive. Note that every expense must be coded with a Category, or it will not export. Also, when you first set up the integration, your existing categories will be overwritten.
+
+## Billable Expenses
+
+Enabling Billable expenses allows you to map your expense types or accounts to items in Sage Intacct. To do this, you'll need to enable the correct permissions on your Sage Intacct user or role. This may vary based on the modules you use in Sage Intacct, so you should enable read-only permissions for relevant modules such as Projects, Purchasing, Inventory Control, and Order Entry.
+
+Once permissions are set, you can map your categories (expense types or accounts, depending on your export settings) to specific items, which will then export to Sage Intacct. When an expense is marked as Billable in Expensify, users must select the correct billable Category (Item), or there will be an error during export.
+
+## Dimensions - Departments, Classes, and Locations
+
+If you enable these dimensions, you can choose from three data options:
+- Not pulled into Expensify: Employee default (available when the reimbursable export option is set to Expense Reports)
+- Pulled into Expensify and selectable on reports/expenses: Tags (useful for cross-charging between Departments or Locations)
+- Report Fields (applies at the header level, useful when an employee's Location varies from one report to another)
+
+Please note that the term "tag" may appear instead of "Department" on your reports, so ensure that "Projects" is not disabled in your Tags configuration within your workspace settings. Make sure it's enabled within your coding settings of the Intacct configuration settings. When multiple options are available, the term will default to Tags.
+
+## Customers and Projects
+
+These settings are particularly relevant to billable expenses and can be configured as Tags or Report Fields.
+
+## Tax
+
+As of September 2023, our Sage Intacct integration supports native VAT and GST tax. To enable this feature, open the Sage Intacct configuration settings in your workspace, go to the Coding tab, and enable Tax. For existing Sage Intacct connectings, simply resync your workspace and the tax toggle will appear. For new Sage Intacct connections, the tax toggle will be available when you complete the integration steps.
+Having this option enabled will then import your native tax rates from Sage Intacct into Expensify. From there, you can select default rates for each category.
+
+## User-Defined Dimensions
+
+You can add User-Defined Dimensions (UDD) to your workspace by locating the "Integration Name" in Sage Intacct. Please note that you must be logged in as an administrator in Sage Intacct to find the required fields.
+
+To find the Integration Name in Sage Intacct:
+1. Go to **Platform Services > Objects > List**
+2. Set "filter by application" to "user-defined dimensions."
+
+Now, in Expensify, navigate to **Settings > Workspaces > Group > [Workspace Name] > Connections**, and click **Configure** under Sage Intacct. On the Coding tab, enable the toggle next to User Defined Dimensions. Enter the "Integration name" and choose whether to import it into Expensify as an expense-level Tag or as a Report Field, then click **Save**.
+
+You'll now see the values for your custom segment available under Tags settings or Report Fields settings in Expensify.
+
+
+
+# How to configure advanced settings
+In multi-entity environments, you'll find a dropdown at the top of the sync options menu, where you can choose to sync with the top-level or a specific entity in your Sage Intacct instance. If you sync at the top level, we pull in employees and dimensions shared at the top level and export transactions to the top level. Otherwise, we sync information with the selected entity.
+## Auto Sync
+When a non-reimbursable report is finally approved, it will be automatically exported to Sage Intacct. Typically, non-reimbursable expenses will sync to the next open period in Sage Intacct by default. If your company uses Expensify's ACH reimbursement, reimbursable expenses will be held back and exported to Sage when the report is reimbursed.
+## Inviting Employees
+Enabling **Invite Employees** allows the integration to automatically add your employees to your workspace and create an Expensify account for them if they don't have one.
+If you have your domain verified on your account, ensure that the Expensify account connected to Sage Intacct is an admin on your domain.
+When you toggle on "Invite Employees" on the Advanced tab, all employees in Sage Intacct who haven't been invited to the Expensify group workspace you're connecting will receive an email invitation to join the group workspace. Approval workflow will default to Manager Approval and can be further configured on the People settings page.
+## Import Sage Intacct Approvals
+When the "Import Sage Intacct Approvals" setting is enabled, Expensify will automatically set each user's manager listed in Sage Intacct as their first approver in Expensify. If no manager exists in Sage Intacct, the approver can be set in the Expensify People table. You can also add a second level of approval to your Sage Intacct integration by setting a final approver in Expensify.
+Please note that if you need to add or edit an optional final approver, you will need to select the **Manager Approval** option in the workflow. Here is how each option works:
+- **Basic Approval:** All users submit to one user.
+- **Manager Approval:** Each user submits to the manager (imported from Sage Intacct). Each manager forwards to one final approver (optional).
+- **Configure Manually:** Import employees only, configure workflow in Expensify.
+
+
+## Sync Reimbursed Reports
+When using Expensify ACH, reimbursable reports exported to Intacct are exported:
+- As Vendor Bills to the default Accounts Payable account set in your Intacct Accounts Payable module configuration, OR
+- As Expense Reports to the Employee Liabilities account in your Time & Expenses module configuration.
+When ACH reimbursement is enabled, the "Sync Reimbursed Reports" feature will additionally export a Bill Payment to the selected Cash and Cash Equivalents account listed. If **Auto Sync** is enabled, the payment will be created when the report is reimbursed; otherwise, it will be created the next time you manually sync the workspace.
+Intacct requires that the target account for the Bill Payment be a Cash and Cash Equivalents account type. If you aren't seeing the account you want in that list, please first confirm that the category on the account is Cash and Cash Equivalents.
+
+
+# FAQ
+## What if my report isn't automatically exported to Sage Intacct?
+There are a number of factors that can cause automatic export to fail. If this happens, the preferred exporter will receive an email and an Inbox task outlining the issue and any associated error messages.
+The same information will be populated in the comments section of the report.
+The fastest way to find a resolution for a specific error is to search the Community, and if you get stuck, give us a shout!
+Once you've resolved any errors, you can manually export the report to Sage Intacct.
+## How can I make sure that I final approve reports before they're exported to Sage Intacct?
+Make sure your approval workflow is configured correctly so that all reports are reviewed by the appropriate people within Expensify before exporting to Sage Intacct.
+Also, if you have verified your domain, consider strictly enforcing expense workspace workflows. You can set this up via Settings > Domains > [Domain Name] > Groups.
+
+
+## If I enable Auto Sync, what happens to existing approved and reimbursed reports?
+If your workspace has been connected to Intacct with Auto Sync disabled, you can safely turn on Auto Sync without affecting existing reports which have not been exported.
+If a report has been exported to Intacct and reimbursed via ACH in Expensify, we'll automatically mark it as paid in Intacct during the next sync.
+If a report has been exported to Intacct and marked as paid in Intacct, we'll automatically mark it as reimbursed in Expensify during the next sync.
+If a report has not been exported to Intacct, it will not be exported to Intacct automatically.
diff --git a/docs/articles/expensify-classic/integrations/other-integrations/Google-Apps-SSO.md b/docs/articles/expensify-classic/integrations/other-integrations/Google-Apps-SSO.md
index 9fd745838caf..a034d13dd143 100644
--- a/docs/articles/expensify-classic/integrations/other-integrations/Google-Apps-SSO.md
+++ b/docs/articles/expensify-classic/integrations/other-integrations/Google-Apps-SSO.md
@@ -22,6 +22,7 @@ To enable Expensify for your Google Apps domain and add an “Expenses” link t
6. Click **Finish**. You can configure access for specific Organizational Units later if needed.
7. All account holders on your domain can now access Expensify from the Google Apps directory by clicking **More** and choosing **Expensify**.
8. Now, follow the steps below to sync your users with Expensify automatically.
+
# How to Sync Users from Google Apps to Expensify
To sync your Google Apps users to your Expensify Workspace, follow these steps:
1. Follow the above steps to install Expensify in your Google Apps directory.
diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Grab.md b/docs/articles/expensify-classic/integrations/travel-integrations/Grab.md
deleted file mode 100644
index 3ee1c8656b4b..000000000000
--- a/docs/articles/expensify-classic/integrations/travel-integrations/Grab.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Coming Soon
-description: Coming Soon
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/TrainLine.md b/docs/articles/expensify-classic/integrations/travel-integrations/TrainLine.md
deleted file mode 100644
index 3ee1c8656b4b..000000000000
--- a/docs/articles/expensify-classic/integrations/travel-integrations/TrainLine.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Coming Soon
-description: Coming Soon
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md b/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md
index 14ade143a35b..1a567dbe6fa3 100644
--- a/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md
+++ b/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md
@@ -6,7 +6,7 @@ description: A help article that covers Third Party Payment options including Pa
Expensify offers convenient third party payment options that allow you to streamline the process of reimbursing expenses and managing your finances. With these options, you can pay your expenses and get reimbursed faster and more efficiently. In this guide, we'll walk you through the steps to set up and use Expensify's third party payment options.
-## Overview
+# Overview
Expensify offers integration with various third party payment providers, making it easy to reimburse employees and manage your expenses seamlessly. Some of the key benefits of using third-party payment options in Expensify include:
@@ -14,7 +14,7 @@ Expensify offers integration with various third party payment providers, making
- Secure Transactions: Benefit from the security features and protocols provided by trusted payment providers.
- Centralized Expense Management: Consolidate all your expenses and payments within Expensify for a more efficient financial workflow.
-## Setting Up Third Party Payments
+# Setting Up Third Party Payments
To get started with third party payments in Expensify, follow these steps:
@@ -30,7 +30,7 @@ To get started with third party payments in Expensify, follow these steps:
6. **Verify Your Account**: Confirm your linked account to ensure it's correctly integrated with Expensify.
-## Using Third Party Payments
+# Using Third Party Payments
Once you've set up your third party payment option, you can start using it to reimburse expenses and manage payments:
@@ -42,22 +42,18 @@ Once you've set up your third party payment option, you can start using it to re
4. **Track Payment Status**: You can track the status of payments and view transaction details within your Expensify account.
-## FAQ’s
+# FAQ’s
-### Q: Are there any fees associated with using third party payment options in Expensify?
+## Q: Are there any fees associated with using third party payment options in Expensify?
A: The fees associated with third party payments may vary depending on the payment provider you choose. Be sure to review the terms and conditions of your chosen provider for details on any applicable fees.
-### Q: Can I use multiple third party payment providers with Expensify?
+## Q: Can I use multiple third party payment providers with Expensify?
A: Expensify allows you to link multiple payment providers if needed. You can select the most suitable payment method for each expense report.
-### Q: Is there a limit on the amount I can reimburse using third party payments?
+## Q: Is there a limit on the amount I can reimburse using third party payments?
A: The reimbursement limit may depend on the policies and settings configured within your Expensify account and the limits imposed by your chosen payment provider.
With Expensify's third party payment options, you can simplify your expense management and reimbursement processes. By following the steps outlined in this guide, you can set up and use third party payments efficiently.
-
-
-
-
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 996d7896502f..17c7a60b8e5a 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
@@ -4,16 +4,16 @@ description: Best Practices for Admins settings up Expensify Chat
redirect_from: articles/other/Expensify-Chat-For-Admins/
---
-## Overview
+# Overview
Expensify Chat is an incredible way to build a community and foster long-term relationships between event producers and attendees, or attendees with each other. Admins are a huge factor in the success of the connections built in Expensify Chat during the events, as they are generally the drivers of the conference schedule, and help ensure safety and respect is upheld by all attendees both on and offline.
-## Getting Started
+# Getting Started
We’ve rounded up some resources to get you set up on Expensify Chat and ready to start connecting with your session attendees:
- [How to get set up and start using Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-use-chat-in-expensify)
- [How to format text in Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-format-text)
- [How to flag content and/or users for moderation](https://help.expensify.com/articles/other/Everything-About-Chat#flagging-content-as-offensive)
-## Admin Best Practices
+# Admin Best Practices
In order to get the most out of Expensify Chat, we created a list of best practices for admins to review in order to use the tool to its fullest capabilities.
**During the conference:**
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 20e15aaa6c72..30eeb4158902 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
@@ -4,19 +4,19 @@ description: Best Practices for Conference Attendees
redirect_from: articles/other/Expensify-Chat-For-Conference-Attendees/
---
-## Overview
+# Overview
Expensify Chat is the best way to meet and network with other event attendees. No more hunting down your contacts by walking the floor or trying to find someone in crowds at a party. Instead, you can use Expensify Chat to network and collaborate with others throughout the conference.
To help get you set up for a great event, we’ve created a guide to help you get the most out of using Expensify Chat at the event you’re attending.
-## Getting Started
+# Getting Started
We’ve rounded up some resources to get you set up on Expensify Chat and ready to start connecting with your fellow attendees:
- [How to get set up and start using Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-use-chat-in-expensify)
- [How to format text in Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-format-text)
- [How to flag content and/or users for moderation](https://help.expensify.com/articles/other/Everything-About-Chat#flagging-content-as-offensive)
-## Chat Best Practices
+# Chat Best Practices
To get the most out of your experience at your conference and engage people in a meaningful conversation that will fulfill your goals instead of turning people off, here are some tips on what to do and not to do as an event attendee using Expensify Chat:
**Do:**
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 3e19cf6fe26a..652fc2ee4d2b 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
@@ -4,17 +4,17 @@ description: Best Practices for Conference Speakers
redirect_from: articles/other/Expensify-Chat-For-Conference-Speakers/
---
-## Overview
+# Overview
Are you a speaker at an event? Great! We're delighted to provide you with an extraordinary opportunity to connect with your session attendees using Expensify Chat — before, during, and after the event. Expensify Chat offers a powerful platform for introducing yourself and your topic, fostering engaging discussions about your presentation, and maintaining the conversation with attendees even after your session is over.
-## Getting Started
+# Getting Started
We’ve rounded up some resources to get you set up on Expensify Chat and ready to start connecting with your session attendees:
- [How to get set up and start using Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-use-chat-in-expensify)
- [How to format text in Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-format-text)
- [How to flag content and/or users for moderation](https://help.expensify.com/articles/other/Everything-About-Chat#flagging-content-as-offensive)
-## Setting Up a Chatroom for Your Session: Checklist
+# Setting Up a Chatroom for Your Session: Checklist
To make the most of Expensify Chat for your session, here's a handy checklist:
- Confirm that your session has an Expensify Chat room, and have the URL link ready to share with attendees in advance.
- You can find the link by clicking on the avatar for your chatroom > “Share Code” > “Copy URL to dashboard”
@@ -22,7 +22,7 @@ To make the most of Expensify Chat for your session, here's a handy checklist:
- Consider having a session moderator with you on the day to assist with questions and discussions while you're presenting.
- Include the QR code for your session's chat room in your presentation slides. Displaying it prominently on every slide ensures that attendees can easily join the chat throughout your presentation.
-## Tips to Enhance Engagement Around Your Session
+# Tips to Enhance Engagement Around Your Session
By following these steps and utilizing Expensify Chat, you can elevate your session to promote valuable interactions with your audience, and leave a lasting impact beyond the conference. We can't wait to see your sessions thrive with the power of Expensify Chat!
**Before the event:**
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 a81aef2044a2..caeccd1920b1 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
@@ -3,10 +3,10 @@ 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
+# 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.
-## Who you are
+# Who you are
As a conference organizer, you’re expected to amaze and inspire attendees. You want attendees to get to the right place on time, engage with the speakers, and create relationships with each other that last long after the conference is done. Enter Expensify Chat, a free feature that allows attendees to interact with organizers and other attendees in realtime. With Expensify Chat, you can:
- Communicate logistics and key information
@@ -21,20 +21,20 @@ Sounds good? Great! In order to ensure your team, your speakers, and your attend
*Let’s get started!*
-## Support
+# Support
Connect with your dedicated account manager in any new.expensify.com #admins room. Your account manager is excited to brainstorm the best ways to make the most out of your event and work through any questions you have about the setup steps below.
We also have a number of [moderation tools](https://help.expensify.com/articles/other/Everything-About-Chat#flagging-content-as-offensive) available to admins to help make sure your event is seamless, safe, and fun!
-## Step by step instructions for setting up your conference on Expensify Chat
+# Step by step instructions for setting up your conference on Expensify Chat
Based on our experience running conferences atop Expensify Chat, we recommend the following simple steps:
-### Step 1: Create your event workspace in Expensify
+## Step 1: Create your event workspace in Expensify
To create your event workspace in Expensify:
1. In [new.expensify.com](https://new.expensify.com): “+” > “New workspace”
1. Name the workspace (e.g. “ExpensiCon”)
-### Step 2: Set up all the Expensify Chat rooms you want to feature at your event
+## Step 2: Set up all the Expensify Chat rooms you want to feature at your event
**Protip**: Your Expensify account manager can complete this step with you. Chat them in #admins on new.expensify.com to coordinate!
To create a new chat room:
@@ -54,7 +54,7 @@ For an easy-to-follow event, we recommend creating these chat rooms:
**Protip** Check out our [moderation tools](https://help.expensify.com/articles/other/Everything-About-Chat#flagging-content-as-offensive) to help flag comments deemed to be spam, inconsiderate, intimidating, bullying, harassment, assault. On any comment just click the flag icon to moderate conversation.
-### Step 3: Add chat room QR codes to the applicable session slide deck
+## Step 3: Add chat room QR codes to the applicable session slide deck
Gather QR codes:
1. Go to [new.expensify.com](https://new.expensify.com)
1. Click into a room and click the room name or avatar in the top header
@@ -63,7 +63,7 @@ Gather QR codes:
Add the QR code to every slide so that if folks forget to scan the QR code at the beginning of the presentation, they can still join the discussion.
-### Step 4: Plan out your messaging and cadence before the event begins
+## Step 4: Plan out your messaging and cadence before the event begins
Expensify Chat is a great place to provide updates leading up to your event -- share news, get folks excited about speakers, and let attendees know of crucial event information like recommended attire, travel info, and more. For example, you might consider:
**Prep your announcements:**
@@ -80,15 +80,15 @@ Expensify Chat is a great place to provide updates leading up to your event -- s
**Protip**: Your account manager can help you create this document, and would be happy to send each message at the appointed time for you.
-### Step 5: Share Expensify Chat How-To Resources with Speakers, Attendees, Admins
+## Step 5: Share Expensify Chat How-To Resources with Speakers, Attendees, Admins
We’ve created a few helpful best practice docs for your speakers, admins, and attendees to help navigate using Expensify Chat at your event. Feel free to share the links below with them!
- [Expensify Chat for Conference Attendees](https://help.expensify.com/articles/other/Expensify-Chat-For-Conference-Attendees)
- [Expensify Chat for Conference Speakers](https://help.expensify.com/articles/other/Expensify-Chat-For-Conference-Speakers)
- [Expensify Chat for Admins](https://help.expensify.com/articles/other/Expensify-Chat-For-Admins)
-### Step 6: Follow up with attendees after the event
+## Step 6: Follow up with attendees after the event
Continue the connections by using Expensify Chat to keep your conference community connected. Encourage attendees to share photos, their favorite memories, funny stories, and more.
-## Conclusion
+# Conclusion
Once you have completed the above steps you are ready to host your conference on Expensify Chat! Let your account manager know any questions you have over in your [new.expensify.com](https://new.expensify.com) #admins room and start driving activity in your Expensify Chat rooms. Once you’ve reviewed this doc you should have the foundations in place, so a great next step is to start training your speakers on how to use Expensify Chat for their sessions. Coordinate with your account manager to make sure everything goes smoothly!
diff --git a/docs/articles/new-expensify/integrations/accounting-integrations/Xero b/docs/articles/new-expensify/integrations/accounting-integrations/Xero.md
similarity index 100%
rename from docs/articles/new-expensify/integrations/accounting-integrations/Xero
rename to docs/articles/new-expensify/integrations/accounting-integrations/Xero.md
diff --git a/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_1.png b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_1.png
new file mode 100644
index 000000000000..d4e73beb16b3
Binary files /dev/null and b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_1.png differ
diff --git a/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_2.png b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_2.png
new file mode 100644
index 000000000000..45956a586d98
Binary files /dev/null and b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_2.png differ
diff --git a/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_3.png b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_3.png
new file mode 100644
index 000000000000..32aae12d3687
Binary files /dev/null and b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_3.png differ
diff --git a/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_4.png b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_4.png
new file mode 100644
index 000000000000..ccd9335025bf
Binary files /dev/null and b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_4.png differ
diff --git a/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_5.png b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_5.png
new file mode 100644
index 000000000000..5363935f0ab5
Binary files /dev/null and b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_5.png differ
diff --git a/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_6.png b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_6.png
new file mode 100644
index 000000000000..739446de8383
Binary files /dev/null and b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_6.png differ
diff --git a/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_7.png b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_7.png
new file mode 100644
index 000000000000..21a1d3416858
Binary files /dev/null and b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_7.png differ
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index dac53193fdc6..78abf8074155 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -305,7 +305,10 @@ platform :ios do
export_compliance_contains_proprietary_cryptography: false,
# We do not show any third party content
- content_rights_contains_third_party_content: false
+ content_rights_contains_third_party_content: false,
+
+ # Indicate that our key has admin permissions
+ content_rights_has_rights: true
},
release_notes: {
'en-US' => "Improvements and bug fixes"
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 90b3a0d4536f..32d356a96cf8 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 1.3.84
+ 1.3.86CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.3.84.2
+ 1.3.86.1ITSAppUsesNonExemptEncryptionLSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index b847dee090bc..7fa3d841d5d8 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 1.3.84
+ 1.3.86CFBundleSignature????CFBundleVersion
- 1.3.84.2
+ 1.3.86.1
diff --git a/package-lock.json b/package-lock.json
index 2e0f77f8ef3f..b575c151364d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.3.84-2",
+ "version": "1.3.86-1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.3.84-2",
+ "version": "1.3.86-1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -43,7 +43,6 @@
"@types/node": "^18.14.0",
"@ua/react-native-airship": "^15.2.6",
"awesome-phonenumber": "^5.4.0",
- "babel-plugin-transform-remove-console": "^6.9.4",
"babel-polyfill": "^6.26.0",
"canvas-size": "^1.2.6",
"core-js": "^3.32.0",
@@ -51,7 +50,7 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#009c2ab79bf7ddeab0eea7a3a4c0d9cc4277c34b",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#bdbdf44825658500ba581d3e86237d7b8996cc2e",
"fbjs": "^3.0.2",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
@@ -138,8 +137,6 @@
"@babel/runtime": "^7.20.0",
"@electron/notarize": "^1.2.3",
"@jest/globals": "^29.5.0",
- "@kie/act-js": "^2.0.1",
- "@kie/mock-github": "^1.0.0",
"@octokit/core": "4.0.4",
"@octokit/plugin-paginate-rest": "3.1.0",
"@octokit/plugin-throttling": "4.1.0",
@@ -222,7 +219,7 @@
"react-native-performance-flipper-reporter": "^2.0.0",
"react-native-svg-transformer": "^1.0.0",
"react-test-renderer": "18.2.0",
- "reassure": "^0.9.0",
+ "reassure": "^0.10.1",
"setimmediate": "^1.0.5",
"shellcheck": "^1.1.0",
"style-loader": "^2.0.0",
@@ -2539,16 +2536,21 @@
"integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA=="
},
"node_modules/@babel/runtime": {
- "version": "7.22.3",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.3.tgz",
- "integrity": "sha512-XsDuspWKLUsxwCp6r7EhsExHtYfbe5oAGQ19kqngTdCPUoPQzOPdUbD/pB9PJiwb2ptYKQDjSJT3R6dC+EPqfQ==",
+ "version": "7.23.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz",
+ "integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==",
"dependencies": {
- "regenerator-runtime": "^0.13.11"
+ "regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/runtime/node_modules/regenerator-runtime": {
+ "version": "0.14.0",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
+ "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
+ },
"node_modules/@babel/template": {
"version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz",
@@ -2623,13 +2625,13 @@
"license": "Apache-2.0"
},
"node_modules/@callstack/reassure-cli": {
- "version": "0.9.0",
- "resolved": "https://registry.npmjs.org/@callstack/reassure-cli/-/reassure-cli-0.9.0.tgz",
- "integrity": "sha512-auoxqyilxkT5mDdEPJqRRY+ZGlrihJjFQpopcFd/15ng76OPVka3L48RMEY2wXkFXLaOOs6enNGb596jYPuEtQ==",
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@callstack/reassure-cli/-/reassure-cli-0.10.0.tgz",
+ "integrity": "sha512-CYgOGOAWcFgA2GrJw6RJAvImCpHCpPbtPoYMDol7esjlRCX2QwIKG7/9byq47hML57w94fhFAa76KD84YlgMBg==",
"dev": true,
"dependencies": {
- "@callstack/reassure-compare": "0.5.0",
- "@callstack/reassure-logger": "0.3.0",
+ "@callstack/reassure-compare": "0.6.0",
+ "@callstack/reassure-logger": "0.3.1",
"chalk": "4.1.2",
"simple-git": "^3.16.0",
"yargs": "^17.6.2"
@@ -2759,12 +2761,12 @@
}
},
"node_modules/@callstack/reassure-compare": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/@callstack/reassure-compare/-/reassure-compare-0.5.0.tgz",
- "integrity": "sha512-3sBeJ/+Hxjdb01KVb8LszO1kcJ8TXcrVnerUj+LYn2dkBOohAMqGYaOvCeoWsVEHJ+MIOzmvAGBJQRu69RoJdQ==",
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@callstack/reassure-compare/-/reassure-compare-0.6.0.tgz",
+ "integrity": "sha512-P3nmv36CJrQSXg0+z6EuEV/0xAbvxWbAZ7diQHnkbvqk2z8PKRXpkcthrRUpe02wLewa0MLxBUJwLenFnhxIRg==",
"dev": true,
"dependencies": {
- "@callstack/reassure-logger": "0.3.0",
+ "@callstack/reassure-logger": "0.3.1",
"markdown-builder": "^0.9.0",
"markdown-table": "^2.0.0",
"zod": "^3.20.2"
@@ -2777,9 +2779,9 @@
"dev": true
},
"node_modules/@callstack/reassure-logger": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/@callstack/reassure-logger/-/reassure-logger-0.3.0.tgz",
- "integrity": "sha512-JX5o+8qkIbIRL+cQn9XlQYdv9p/3L6J70zZX6NYi9j0VrSS9PZIRfo8ujMdLSqUNV6HZN1ay59RzuncLjVu0aQ==",
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@callstack/reassure-logger/-/reassure-logger-0.3.1.tgz",
+ "integrity": "sha512-IUsNrxVMdt0zgD2IN2snGVGUG8Yc6F3SWaPF8RXUn8qi7XZuYC6WevEL+mIKmlbTTa7qlX9brkn0pJoXAjfcPQ==",
"dev": true,
"dependencies": {
"chalk": "4.1.2"
@@ -2856,12 +2858,12 @@
}
},
"node_modules/@callstack/reassure-measure": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/@callstack/reassure-measure/-/reassure-measure-0.5.0.tgz",
- "integrity": "sha512-KwlmNYcspBOp7FIw6XOz5O9mnKB4cWCCyM6vG4nFUPHSWQ6yVdRkawVvoPIV5qJ2hw7zCzdtqRrLWQSTF4eKlg==",
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@callstack/reassure-measure/-/reassure-measure-0.6.0.tgz",
+ "integrity": "sha512-phXY5DAtKhnu8dA2pmnl+pqFOfrVEFFDJOi4AnObwIcpDSn3IUXgNSe7rSi+JP/mXNWMLoUS8rnH4iIFDyf7qQ==",
"dev": true,
"dependencies": {
- "@callstack/reassure-logger": "0.3.0",
+ "@callstack/reassure-logger": "0.3.1",
"mathjs": "^11.5.0"
},
"peerDependencies": {
@@ -5460,7 +5462,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@kie/act-js/-/act-js-2.3.0.tgz",
"integrity": "sha512-Q9k0b05uA46jXKWmVfoGDW+0xsCcE7QPiHi8IH7h41P36DujHKBj4k28RCeIEx3IwUCxYHKwubN8DH4Vzc9XcA==",
- "dev": true,
"hasInstallScript": true,
"dependencies": {
"@kie/mock-github": "^2.0.0",
@@ -5480,7 +5481,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@kie/mock-github/-/mock-github-2.0.0.tgz",
"integrity": "sha512-od6UyICJYKMnz9HgEWCQAFT/JsCpKkLp+JQH8fV23tf+ZmmQI1dK3C20k6aO5uJhAHA0yOcFtbKFVF4+8i3DTg==",
- "dev": true,
"dependencies": {
"@octokit/openapi-types-ghec": "^18.0.0",
"ajv": "^8.11.0",
@@ -5495,14 +5495,12 @@
"node_modules/@kie/act-js/node_modules/@octokit/openapi-types-ghec": {
"version": "18.1.1",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types-ghec/-/openapi-types-ghec-18.1.1.tgz",
- "integrity": "sha512-5Ri7FLYX4gJSdG+G0Q8QDca/gOLfkPN4YR2hkbVg6hEL+0N62MIsJPTyNaT9pGEXCLd1KbYV6Lh3T2ggsmyBJw==",
- "dev": true
+ "integrity": "sha512-5Ri7FLYX4gJSdG+G0Q8QDca/gOLfkPN4YR2hkbVg6hEL+0N62MIsJPTyNaT9pGEXCLd1KbYV6Lh3T2ggsmyBJw=="
},
"node_modules/@kie/act-js/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
- "dev": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -5516,7 +5514,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
- "dev": true,
"engines": {
"node": ">=6"
}
@@ -5525,7 +5522,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@kie/mock-github/-/mock-github-1.1.0.tgz",
"integrity": "sha512-fD+utlOiyZSOutOcXL0G9jfjbtvOO44PLUyTfgfkrm1+575R/dbvU6AcJfjc1DtHeRv7FC7f4ebyU+a1wgL6CA==",
- "dev": true,
"dependencies": {
"@octokit/openapi-types-ghec": "^14.0.0",
"ajv": "^8.11.0",
@@ -5541,7 +5537,6 @@
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
- "dev": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -5555,7 +5550,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
- "dev": true,
"engines": {
"node": ">=6"
}
@@ -5564,7 +5558,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
"integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==",
- "dev": true,
"dependencies": {
"debug": "^4.1.1"
}
@@ -5572,8 +5565,7 @@
"node_modules/@kwsites/promise-deferred": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz",
- "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
- "dev": true
+ "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="
},
"node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.4",
@@ -5952,7 +5944,6 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@@ -5966,7 +5957,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -5976,7 +5966,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@@ -6107,8 +6096,7 @@
"node_modules/@octokit/openapi-types-ghec": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types-ghec/-/openapi-types-ghec-14.0.0.tgz",
- "integrity": "sha512-xhd9oEvn2aroGn+sk09Ptx/76Y7aKU0EIgHukHPCU1+rGJreO36baEEk6k8ZPblieHNM39FcykJQmtDrETm0KA==",
- "dev": true
+ "integrity": "sha512-xhd9oEvn2aroGn+sk09Ptx/76Y7aKU0EIgHukHPCU1+rGJreO36baEEk6k8ZPblieHNM39FcykJQmtDrETm0KA=="
},
"node_modules/@octokit/plugin-paginate-rest": {
"version": "3.1.0",
@@ -21213,7 +21201,6 @@
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.10.tgz",
"integrity": "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==",
- "dev": true,
"engines": {
"node": ">=6.0"
}
@@ -21844,7 +21831,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
- "dev": true,
"license": "MIT"
},
"node_modules/array-includes": {
@@ -23287,7 +23273,6 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/bin-links/-/bin-links-4.0.2.tgz",
"integrity": "sha512-jxJ0PbXR8eQyPlExCvCs3JFnikvs1Yp4gUJt6nmgathdOwvur+q22KWC3h20gvWl4T/14DXKj2IlkJwwZkZPOw==",
- "dev": true,
"dependencies": {
"cmd-shim": "^6.0.0",
"npm-normalize-package-bin": "^3.0.0",
@@ -23302,7 +23287,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
- "dev": true,
"engines": {
"node": ">=14"
},
@@ -23314,7 +23298,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz",
"integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==",
- "dev": true,
"dependencies": {
"imurmurhash": "^0.1.4",
"signal-exit": "^4.0.1"
@@ -23397,7 +23380,6 @@
},
"node_modules/body-parser": {
"version": "1.20.0",
- "dev": true,
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
@@ -23422,7 +23404,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -23432,7 +23413,6 @@
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
@@ -23442,7 +23422,6 @@
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
@@ -23455,7 +23434,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "dev": true,
"license": "MIT"
},
"node_modules/bonjour-service": {
@@ -24531,7 +24509,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
- "dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
@@ -24960,7 +24937,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-6.0.1.tgz",
"integrity": "sha512-S9iI9y0nKR4hwEQsVWpyxld/6kRfGepGfzff83FcaiEBpmvlbA2nnGe7Cylgrx2f/p1P5S5wpRm9oL8z1PbS3Q==",
- "dev": true,
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
@@ -25403,7 +25379,6 @@
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
@@ -25416,7 +25391,6 @@
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -25435,7 +25409,6 @@
},
"node_modules/content-type": {
"version": "1.0.4",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -25450,7 +25423,6 @@
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -25460,7 +25432,6 @@
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/copy-concurrently": {
@@ -30218,8 +30189,8 @@
},
"node_modules/expensify-common": {
"version": "1.0.0",
- "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#009c2ab79bf7ddeab0eea7a3a4c0d9cc4277c34b",
- "integrity": "sha512-mD9p6Qj8FfvLdb6JLXvF0UNqLN6ymssUU67Fm37zmK18hd1Abw+vR/pQkNpHR2iv+KRs8HdcyuZ0vaOec4VrFQ==",
+ "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#bdbdf44825658500ba581d3e86237d7b8996cc2e",
+ "integrity": "sha512-/kXD/18YQJY/iWB5MOSN0ixB1mpxUA+NXEYgKjac1tJd+DoY3K6+bDeu++Qfqg26LCNfvjTkjkDGZVdGsJQ7Hw==",
"license": "MIT",
"dependencies": {
"classnames": "2.3.1",
@@ -30315,7 +30286,6 @@
},
"node_modules/express": {
"version": "4.18.1",
- "dev": true,
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
@@ -30358,7 +30328,6 @@
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
@@ -30368,14 +30337,12 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "dev": true,
"license": "MIT"
},
"node_modules/express/node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -30577,7 +30544,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
- "dev": true,
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
@@ -30662,7 +30628,6 @@
},
"node_modules/fastq": {
"version": "1.13.0",
- "dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@@ -30896,7 +30861,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
"integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
@@ -30915,7 +30879,6 @@
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
@@ -30925,7 +30888,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "dev": true,
"license": "MIT"
},
"node_modules/find-babel-config": {
@@ -31112,7 +31074,6 @@
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz",
"integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==",
- "dev": true,
"funding": [
{
"type": "individual",
@@ -31373,23 +31334,22 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fraction.js": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz",
- "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==",
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.4.tgz",
+ "integrity": "sha512-pwiTgt0Q7t+GHZA4yaLjObx4vXmmdcS0iSJ19o8d/goUGgItX9UZWKWNnLHehxviD8wU2IWRsnR8cD5+yOJP2Q==",
"dev": true,
"engines": {
"node": "*"
},
"funding": {
"type": "patreon",
- "url": "https://www.patreon.com/infusion"
+ "url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fragment-cache": {
@@ -31450,7 +31410,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
- "dev": true,
"license": "ISC",
"dependencies": {
"minipass": "^3.0.0"
@@ -31715,7 +31674,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "devOptional": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -33535,7 +33493,6 @@
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.10"
@@ -33860,7 +33817,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -33936,7 +33892,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -37363,7 +37318,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
- "dev": true,
"license": "ISC"
},
"node_modules/json5": {
@@ -38283,20 +38237,20 @@
}
},
"node_modules/mathjs": {
- "version": "11.8.0",
- "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-11.8.0.tgz",
- "integrity": "sha512-I7r8HCoqUGyEiHQdeOCF2m2k9N+tcOHO3cZQ3tyJkMMBQMFqMR7dMQEboBMJAiFW2Um3PEItGPwcOc4P6KRqwg==",
+ "version": "11.11.2",
+ "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-11.11.2.tgz",
+ "integrity": "sha512-SL4/0Fxm9X4sgovUpJTeyVeZ2Ifnk4tzLPTYWDyR3AIx9SabnXYqtCkyJtmoF3vZrDPKGkLvrhbIL4YN2YbXLQ==",
"dev": true,
"dependencies": {
- "@babel/runtime": "^7.21.0",
+ "@babel/runtime": "^7.23.1",
"complex.js": "^2.1.1",
"decimal.js": "^10.4.3",
"escape-latex": "^1.2.0",
- "fraction.js": "^4.2.0",
+ "fraction.js": "4.3.4",
"javascript-natural-sort": "^0.7.1",
"seedrandom": "^3.0.5",
"tiny-emitter": "^2.1.0",
- "typed-function": "^4.1.0"
+ "typed-function": "^4.1.1"
},
"bin": {
"mathjs": "bin/cli.js"
@@ -38879,7 +38833,6 @@
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -39119,7 +39072,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
"integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==",
- "dev": true,
"license": "MIT"
},
"node_modules/merge-options": {
@@ -39155,7 +39107,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -39165,7 +39116,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -41062,7 +41012,6 @@
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
- "dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
@@ -41113,7 +41062,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"minipass": "^3.0.0",
@@ -41210,7 +41158,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
- "dev": true,
"license": "MIT",
"bin": {
"mkdirp": "bin/cmd.js"
@@ -41446,7 +41393,6 @@
"version": "13.3.3",
"resolved": "https://registry.npmjs.org/nock/-/nock-13.3.3.tgz",
"integrity": "sha512-z+KUlILy9SK/RjpeXDiDUEAq4T94ADPHE3qaRkf66mpEhzc/ytOMm3Bwdrbq6k1tMWkbdujiKim3G2tfQARuJw==",
- "dev": true,
"dependencies": {
"debug": "^4.1.0",
"json-stringify-safe": "^5.0.1",
@@ -41676,7 +41622,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz",
"integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==",
- "dev": true,
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
@@ -42888,7 +42833,6 @@
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
"integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/path-type": {
@@ -43605,7 +43549,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz",
"integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==",
- "dev": true,
"engines": {
"node": ">= 8"
}
@@ -43633,7 +43576,6 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
@@ -43921,7 +43863,6 @@
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz",
"integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==",
- "dev": true,
"dependencies": {
"side-channel": "^1.0.4"
},
@@ -43982,7 +43923,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -44064,7 +44004,6 @@
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
"integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
- "dev": true,
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
@@ -44080,7 +44019,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -44090,7 +44028,6 @@
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
@@ -45942,7 +45879,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-4.0.0.tgz",
"integrity": "sha512-yILWifhaSEEytfXI76kB9xEEiG1AiozaCJZ83A87ytjRiN+jVibXjedjCRNjoZviinhG+4UkalO3mWTd8u5O0Q==",
- "dev": true,
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
@@ -46189,14 +46125,14 @@
"integrity": "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg=="
},
"node_modules/reassure": {
- "version": "0.9.0",
- "resolved": "https://registry.npmjs.org/reassure/-/reassure-0.9.0.tgz",
- "integrity": "sha512-FIf0GPchyPGItsrW5Wwff/NWVrfOcCUuJJSs4Nur6iRdQt8yvmCpcba4UyemdZ1KaFTIW1gKbAV3u2tuA7zmtQ==",
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/reassure/-/reassure-0.10.1.tgz",
+ "integrity": "sha512-+GANr5ojh32NZu1YGfa6W8vIJm3iOIZJUvXT5Gc9fQyre7okYsCzyBq9WsHbnAQDjNq1g9SsM/4bwcVET9OIqA==",
"dev": true,
"dependencies": {
- "@callstack/reassure-cli": "0.9.0",
+ "@callstack/reassure-cli": "0.10.0",
"@callstack/reassure-danger": "0.1.1",
- "@callstack/reassure-measure": "0.5.0"
+ "@callstack/reassure-measure": "0.6.0"
}
},
"node_modules/recast": {
@@ -47097,7 +47033,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
- "dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@@ -47192,7 +47127,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -47927,7 +47861,6 @@
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.19.0.tgz",
"integrity": "sha512-hyH2p9Ptxjf/xPuL7HfXbpYt9gKhC1yWDh3KYIAYJJePAKV7AEjLN4xhp7lozOdNiaJ9jlVvAbBymVlcS2jRiA==",
- "dev": true,
"dependencies": {
"@kwsites/file-exists": "^1.1.1",
"@kwsites/promise-deferred": "^1.1.1",
@@ -49131,7 +49064,6 @@
"resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
"integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==",
"dev": true,
- "license": "MIT",
"engines": {
"node": ">=0.10.0"
}
@@ -49470,7 +49402,6 @@
"version": "6.1.15",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz",
"integrity": "sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==",
- "dev": true,
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
@@ -49487,7 +49418,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
- "dev": true,
"engines": {
"node": ">=8"
}
@@ -50319,7 +50249,6 @@
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
@@ -50391,9 +50320,9 @@
}
},
"node_modules/typed-function": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.1.0.tgz",
- "integrity": "sha512-DGwUl6cioBW5gw2L+6SMupGwH/kZOqivy17E4nsh1JI9fKF87orMmlQx3KISQPmg3sfnOUGlwVkroosvgddrlg==",
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.1.1.tgz",
+ "integrity": "sha512-Pq1DVubcvibmm8bYcMowjVnnMwPVMeh0DIdA8ad8NZY2sJgapANJmiigSUwlt+EgXxpfIv8MWrQXTIzkfYZLYQ==",
"dev": true,
"engines": {
"node": ">= 14"
@@ -53266,9 +53195,9 @@
}
},
"node_modules/zod": {
- "version": "3.21.4",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
- "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==",
+ "version": "3.22.4",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
+ "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -54803,11 +54732,18 @@
"integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA=="
},
"@babel/runtime": {
- "version": "7.22.3",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.3.tgz",
- "integrity": "sha512-XsDuspWKLUsxwCp6r7EhsExHtYfbe5oAGQ19kqngTdCPUoPQzOPdUbD/pB9PJiwb2ptYKQDjSJT3R6dC+EPqfQ==",
+ "version": "7.23.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz",
+ "integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==",
"requires": {
- "regenerator-runtime": "^0.13.11"
+ "regenerator-runtime": "^0.14.0"
+ },
+ "dependencies": {
+ "regenerator-runtime": {
+ "version": "0.14.0",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
+ "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
+ }
}
},
"@babel/template": {
@@ -54871,13 +54807,13 @@
"dev": true
},
"@callstack/reassure-cli": {
- "version": "0.9.0",
- "resolved": "https://registry.npmjs.org/@callstack/reassure-cli/-/reassure-cli-0.9.0.tgz",
- "integrity": "sha512-auoxqyilxkT5mDdEPJqRRY+ZGlrihJjFQpopcFd/15ng76OPVka3L48RMEY2wXkFXLaOOs6enNGb596jYPuEtQ==",
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@callstack/reassure-cli/-/reassure-cli-0.10.0.tgz",
+ "integrity": "sha512-CYgOGOAWcFgA2GrJw6RJAvImCpHCpPbtPoYMDol7esjlRCX2QwIKG7/9byq47hML57w94fhFAa76KD84YlgMBg==",
"dev": true,
"requires": {
- "@callstack/reassure-compare": "0.5.0",
- "@callstack/reassure-logger": "0.3.0",
+ "@callstack/reassure-compare": "0.6.0",
+ "@callstack/reassure-logger": "0.3.1",
"chalk": "4.1.2",
"simple-git": "^3.16.0",
"yargs": "^17.6.2"
@@ -54973,12 +54909,12 @@
}
},
"@callstack/reassure-compare": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/@callstack/reassure-compare/-/reassure-compare-0.5.0.tgz",
- "integrity": "sha512-3sBeJ/+Hxjdb01KVb8LszO1kcJ8TXcrVnerUj+LYn2dkBOohAMqGYaOvCeoWsVEHJ+MIOzmvAGBJQRu69RoJdQ==",
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@callstack/reassure-compare/-/reassure-compare-0.6.0.tgz",
+ "integrity": "sha512-P3nmv36CJrQSXg0+z6EuEV/0xAbvxWbAZ7diQHnkbvqk2z8PKRXpkcthrRUpe02wLewa0MLxBUJwLenFnhxIRg==",
"dev": true,
"requires": {
- "@callstack/reassure-logger": "0.3.0",
+ "@callstack/reassure-logger": "0.3.1",
"markdown-builder": "^0.9.0",
"markdown-table": "^2.0.0",
"zod": "^3.20.2"
@@ -54991,9 +54927,9 @@
"dev": true
},
"@callstack/reassure-logger": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/@callstack/reassure-logger/-/reassure-logger-0.3.0.tgz",
- "integrity": "sha512-JX5o+8qkIbIRL+cQn9XlQYdv9p/3L6J70zZX6NYi9j0VrSS9PZIRfo8ujMdLSqUNV6HZN1ay59RzuncLjVu0aQ==",
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@callstack/reassure-logger/-/reassure-logger-0.3.1.tgz",
+ "integrity": "sha512-IUsNrxVMdt0zgD2IN2snGVGUG8Yc6F3SWaPF8RXUn8qi7XZuYC6WevEL+mIKmlbTTa7qlX9brkn0pJoXAjfcPQ==",
"dev": true,
"requires": {
"chalk": "4.1.2"
@@ -55051,12 +54987,12 @@
}
},
"@callstack/reassure-measure": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/@callstack/reassure-measure/-/reassure-measure-0.5.0.tgz",
- "integrity": "sha512-KwlmNYcspBOp7FIw6XOz5O9mnKB4cWCCyM6vG4nFUPHSWQ6yVdRkawVvoPIV5qJ2hw7zCzdtqRrLWQSTF4eKlg==",
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@callstack/reassure-measure/-/reassure-measure-0.6.0.tgz",
+ "integrity": "sha512-phXY5DAtKhnu8dA2pmnl+pqFOfrVEFFDJOi4AnObwIcpDSn3IUXgNSe7rSi+JP/mXNWMLoUS8rnH4iIFDyf7qQ==",
"dev": true,
"requires": {
- "@callstack/reassure-logger": "0.3.0",
+ "@callstack/reassure-logger": "0.3.1",
"mathjs": "^11.5.0"
}
},
@@ -56877,7 +56813,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@kie/act-js/-/act-js-2.3.0.tgz",
"integrity": "sha512-Q9k0b05uA46jXKWmVfoGDW+0xsCcE7QPiHi8IH7h41P36DujHKBj4k28RCeIEx3IwUCxYHKwubN8DH4Vzc9XcA==",
- "dev": true,
"requires": {
"@kie/mock-github": "^2.0.0",
"adm-zip": "^0.5.10",
@@ -56893,7 +56828,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@kie/mock-github/-/mock-github-2.0.0.tgz",
"integrity": "sha512-od6UyICJYKMnz9HgEWCQAFT/JsCpKkLp+JQH8fV23tf+ZmmQI1dK3C20k6aO5uJhAHA0yOcFtbKFVF4+8i3DTg==",
- "dev": true,
"requires": {
"@octokit/openapi-types-ghec": "^18.0.0",
"ajv": "^8.11.0",
@@ -56908,14 +56842,12 @@
"@octokit/openapi-types-ghec": {
"version": "18.1.1",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types-ghec/-/openapi-types-ghec-18.1.1.tgz",
- "integrity": "sha512-5Ri7FLYX4gJSdG+G0Q8QDca/gOLfkPN4YR2hkbVg6hEL+0N62MIsJPTyNaT9pGEXCLd1KbYV6Lh3T2ggsmyBJw==",
- "dev": true
+ "integrity": "sha512-5Ri7FLYX4gJSdG+G0Q8QDca/gOLfkPN4YR2hkbVg6hEL+0N62MIsJPTyNaT9pGEXCLd1KbYV6Lh3T2ggsmyBJw=="
},
"fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
- "dev": true,
"requires": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -56925,8 +56857,7 @@
"totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
- "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
- "dev": true
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="
}
}
},
@@ -56934,7 +56865,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@kie/mock-github/-/mock-github-1.1.0.tgz",
"integrity": "sha512-fD+utlOiyZSOutOcXL0G9jfjbtvOO44PLUyTfgfkrm1+575R/dbvU6AcJfjc1DtHeRv7FC7f4ebyU+a1wgL6CA==",
- "dev": true,
"requires": {
"@octokit/openapi-types-ghec": "^14.0.0",
"ajv": "^8.11.0",
@@ -56950,7 +56880,6 @@
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
- "dev": true,
"requires": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -56960,8 +56889,7 @@
"totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
- "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
- "dev": true
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="
}
}
},
@@ -56969,7 +56897,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
"integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==",
- "dev": true,
"requires": {
"debug": "^4.1.1"
}
@@ -56977,8 +56904,7 @@
"@kwsites/promise-deferred": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz",
- "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
- "dev": true
+ "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="
},
"@leichtgewicht/ip-codec": {
"version": "2.0.4",
@@ -57260,7 +57186,6 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "dev": true,
"requires": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
@@ -57269,14 +57194,12 @@
"@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
- "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "dev": true
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="
},
"@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "dev": true,
"requires": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
@@ -57388,8 +57311,7 @@
"@octokit/openapi-types-ghec": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types-ghec/-/openapi-types-ghec-14.0.0.tgz",
- "integrity": "sha512-xhd9oEvn2aroGn+sk09Ptx/76Y7aKU0EIgHukHPCU1+rGJreO36baEEk6k8ZPblieHNM39FcykJQmtDrETm0KA==",
- "dev": true
+ "integrity": "sha512-xhd9oEvn2aroGn+sk09Ptx/76Y7aKU0EIgHukHPCU1+rGJreO36baEEk6k8ZPblieHNM39FcykJQmtDrETm0KA=="
},
"@octokit/plugin-paginate-rest": {
"version": "3.1.0",
@@ -68302,8 +68224,7 @@
"adm-zip": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.10.tgz",
- "integrity": "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==",
- "dev": true
+ "integrity": "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ=="
},
"agent-base": {
"version": "6.0.2",
@@ -68781,8 +68702,7 @@
"array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
- "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
- "dev": true
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"array-includes": {
"version": "3.1.6",
@@ -69838,7 +69758,6 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/bin-links/-/bin-links-4.0.2.tgz",
"integrity": "sha512-jxJ0PbXR8eQyPlExCvCs3JFnikvs1Yp4gUJt6nmgathdOwvur+q22KWC3h20gvWl4T/14DXKj2IlkJwwZkZPOw==",
- "dev": true,
"requires": {
"cmd-shim": "^6.0.0",
"npm-normalize-package-bin": "^3.0.0",
@@ -69849,14 +69768,12 @@
"signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
- "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
- "dev": true
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="
},
"write-file-atomic": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz",
"integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==",
- "dev": true,
"requires": {
"imurmurhash": "^0.1.4",
"signal-exit": "^4.0.1"
@@ -69929,7 +69846,6 @@
},
"body-parser": {
"version": "1.20.0",
- "dev": true,
"requires": {
"bytes": "3.1.2",
"content-type": "~1.0.4",
@@ -69948,14 +69864,12 @@
"bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
- "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
- "dev": true
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="
},
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dev": true,
"requires": {
"ms": "2.0.0"
}
@@ -69964,7 +69878,6 @@
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
- "dev": true,
"requires": {
"safer-buffer": ">= 2.1.2 < 3"
}
@@ -69972,8 +69885,7 @@
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "dev": true
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
}
}
},
@@ -70740,8 +70652,7 @@
"chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
- "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
- "dev": true
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="
},
"chrome-trace-event": {
"version": "1.0.3",
@@ -71042,8 +70953,7 @@
"cmd-shim": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-6.0.1.tgz",
- "integrity": "sha512-S9iI9y0nKR4hwEQsVWpyxld/6kRfGepGfzff83FcaiEBpmvlbA2nnGe7Cylgrx2f/p1P5S5wpRm9oL8z1PbS3Q==",
- "dev": true
+ "integrity": "sha512-S9iI9y0nKR4hwEQsVWpyxld/6kRfGepGfzff83FcaiEBpmvlbA2nnGe7Cylgrx2f/p1P5S5wpRm9oL8z1PbS3Q=="
},
"co": {
"version": "4.6.0",
@@ -71377,7 +71287,6 @@
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
- "dev": true,
"requires": {
"safe-buffer": "5.2.1"
},
@@ -71385,14 +71294,12 @@
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
- "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "dev": true
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
}
}
},
"content-type": {
- "version": "1.0.4",
- "dev": true
+ "version": "1.0.4"
},
"convert-source-map": {
"version": "1.9.0",
@@ -71402,14 +71309,12 @@
"cookie": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
- "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
- "dev": true
+ "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
},
"cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
- "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
- "dev": true
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"copy-concurrently": {
"version": "1.0.5",
@@ -74869,9 +74774,9 @@
}
},
"expensify-common": {
- "version": "git+ssh://git@github.com/Expensify/expensify-common.git#009c2ab79bf7ddeab0eea7a3a4c0d9cc4277c34b",
- "integrity": "sha512-mD9p6Qj8FfvLdb6JLXvF0UNqLN6ymssUU67Fm37zmK18hd1Abw+vR/pQkNpHR2iv+KRs8HdcyuZ0vaOec4VrFQ==",
- "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#009c2ab79bf7ddeab0eea7a3a4c0d9cc4277c34b",
+ "version": "git+ssh://git@github.com/Expensify/expensify-common.git#bdbdf44825658500ba581d3e86237d7b8996cc2e",
+ "integrity": "sha512-/kXD/18YQJY/iWB5MOSN0ixB1mpxUA+NXEYgKjac1tJd+DoY3K6+bDeu++Qfqg26LCNfvjTkjkDGZVdGsJQ7Hw==",
+ "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#bdbdf44825658500ba581d3e86237d7b8996cc2e",
"requires": {
"classnames": "2.3.1",
"clipboard": "2.0.4",
@@ -74943,7 +74848,6 @@
},
"express": {
"version": "4.18.1",
- "dev": true,
"requires": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -74982,7 +74886,6 @@
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dev": true,
"requires": {
"ms": "2.0.0"
}
@@ -74990,14 +74893,12 @@
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "dev": true
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
- "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "dev": true
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
}
}
},
@@ -75137,7 +75038,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
- "dev": true,
"requires": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
@@ -75196,7 +75096,6 @@
},
"fastq": {
"version": "1.13.0",
- "dev": true,
"requires": {
"reusify": "^1.0.4"
}
@@ -75375,7 +75274,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
"integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==",
- "dev": true,
"requires": {
"debug": "2.6.9",
"encodeurl": "~1.0.2",
@@ -75390,7 +75288,6 @@
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dev": true,
"requires": {
"ms": "2.0.0"
}
@@ -75398,8 +75295,7 @@
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "dev": true
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
}
}
},
@@ -75537,8 +75433,7 @@
"follow-redirects": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz",
- "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==",
- "dev": true
+ "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q=="
},
"for-each": {
"version": "0.3.3",
@@ -75704,13 +75599,12 @@
"forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
- "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
- "dev": true
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="
},
"fraction.js": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz",
- "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==",
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.4.tgz",
+ "integrity": "sha512-pwiTgt0Q7t+GHZA4yaLjObx4vXmmdcS0iSJ19o8d/goUGgItX9UZWKWNnLHehxviD8wU2IWRsnR8cD5+yOJP2Q==",
"dev": true
},
"fragment-cache": {
@@ -75757,7 +75651,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
- "dev": true,
"requires": {
"minipass": "^3.0.0"
}
@@ -75940,7 +75833,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "devOptional": true,
"requires": {
"is-glob": "^4.0.1"
}
@@ -77230,8 +77122,7 @@
"ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
- "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
- "dev": true
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="
},
"is-absolute-url": {
"version": "3.0.3",
@@ -77426,8 +77317,7 @@
"is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
- "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "devOptional": true
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="
},
"is-finalizationregistry": {
"version": "1.0.2",
@@ -77474,7 +77364,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "devOptional": true,
"requires": {
"is-extglob": "^2.1.1"
}
@@ -79885,8 +79774,7 @@
"json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
- "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
- "dev": true
+ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="
},
"json5": {
"version": "2.2.3",
@@ -80554,20 +80442,20 @@
}
},
"mathjs": {
- "version": "11.8.0",
- "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-11.8.0.tgz",
- "integrity": "sha512-I7r8HCoqUGyEiHQdeOCF2m2k9N+tcOHO3cZQ3tyJkMMBQMFqMR7dMQEboBMJAiFW2Um3PEItGPwcOc4P6KRqwg==",
+ "version": "11.11.2",
+ "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-11.11.2.tgz",
+ "integrity": "sha512-SL4/0Fxm9X4sgovUpJTeyVeZ2Ifnk4tzLPTYWDyR3AIx9SabnXYqtCkyJtmoF3vZrDPKGkLvrhbIL4YN2YbXLQ==",
"dev": true,
"requires": {
- "@babel/runtime": "^7.21.0",
+ "@babel/runtime": "^7.23.1",
"complex.js": "^2.1.1",
"decimal.js": "^10.4.3",
"escape-latex": "^1.2.0",
- "fraction.js": "^4.2.0",
+ "fraction.js": "4.3.4",
"javascript-natural-sort": "^0.7.1",
"seedrandom": "^3.0.5",
"tiny-emitter": "^2.1.0",
- "typed-function": "^4.1.0"
+ "typed-function": "^4.1.1"
}
},
"md5.js": {
@@ -81009,8 +80897,7 @@
"media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
- "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
- "dev": true
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="
},
"mem": {
"version": "8.1.1",
@@ -81185,8 +81072,7 @@
"merge-descriptors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
- "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==",
- "dev": true
+ "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w=="
},
"merge-options": {
"version": "3.0.4",
@@ -81212,14 +81098,12 @@
"merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
- "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
- "dev": true
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="
},
"methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
- "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
- "dev": true
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="
},
"metro": {
"version": "0.76.8",
@@ -82582,7 +82466,6 @@
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
- "dev": true,
"requires": {
"yallist": "^4.0.0"
}
@@ -82618,7 +82501,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
- "dev": true,
"requires": {
"minipass": "^3.0.0",
"yallist": "^4.0.0"
@@ -82692,8 +82574,7 @@
"mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
- "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
- "dev": true
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="
},
"mock-fs": {
"version": "4.14.0",
@@ -82865,7 +82746,6 @@
"version": "13.3.3",
"resolved": "https://registry.npmjs.org/nock/-/nock-13.3.3.tgz",
"integrity": "sha512-z+KUlILy9SK/RjpeXDiDUEAq4T94ADPHE3qaRkf66mpEhzc/ytOMm3Bwdrbq6k1tMWkbdujiKim3G2tfQARuJw==",
- "dev": true,
"requires": {
"debug": "^4.1.0",
"json-stringify-safe": "^5.0.1",
@@ -83041,8 +82921,7 @@
"npm-normalize-package-bin": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz",
- "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==",
- "dev": true
+ "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ=="
},
"npm-run-path": {
"version": "4.0.1",
@@ -83888,8 +83767,7 @@
"path-to-regexp": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
- "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==",
- "dev": true
+ "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ=="
},
"path-type": {
"version": "4.0.0",
@@ -84394,8 +84272,7 @@
"propagate": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz",
- "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==",
- "dev": true
+ "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag=="
},
"property-information": {
"version": "5.6.0",
@@ -84415,7 +84292,6 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
- "dev": true,
"requires": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
@@ -84632,7 +84508,6 @@
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz",
"integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==",
- "dev": true,
"requires": {
"side-channel": "^1.0.4"
}
@@ -84673,8 +84548,7 @@
"queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
- "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
- "dev": true
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="
},
"quick-lru": {
"version": "5.1.1",
@@ -84724,7 +84598,6 @@
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
"integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
- "dev": true,
"requires": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
@@ -84735,14 +84608,12 @@
"bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
- "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
- "dev": true
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="
},
"iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
- "dev": true,
"requires": {
"safer-buffer": ">= 2.1.2 < 3"
}
@@ -85993,8 +85864,7 @@
"read-cmd-shim": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-4.0.0.tgz",
- "integrity": "sha512-yILWifhaSEEytfXI76kB9xEEiG1AiozaCJZ83A87ytjRiN+jVibXjedjCRNjoZviinhG+4UkalO3mWTd8u5O0Q==",
- "dev": true
+ "integrity": "sha512-yILWifhaSEEytfXI76kB9xEEiG1AiozaCJZ83A87ytjRiN+jVibXjedjCRNjoZviinhG+4UkalO3mWTd8u5O0Q=="
},
"read-config-file": {
"version": "6.3.2",
@@ -86180,14 +86050,14 @@
"integrity": "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg=="
},
"reassure": {
- "version": "0.9.0",
- "resolved": "https://registry.npmjs.org/reassure/-/reassure-0.9.0.tgz",
- "integrity": "sha512-FIf0GPchyPGItsrW5Wwff/NWVrfOcCUuJJSs4Nur6iRdQt8yvmCpcba4UyemdZ1KaFTIW1gKbAV3u2tuA7zmtQ==",
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/reassure/-/reassure-0.10.1.tgz",
+ "integrity": "sha512-+GANr5ojh32NZu1YGfa6W8vIJm3iOIZJUvXT5Gc9fQyre7okYsCzyBq9WsHbnAQDjNq1g9SsM/4bwcVET9OIqA==",
"dev": true,
"requires": {
- "@callstack/reassure-cli": "0.9.0",
+ "@callstack/reassure-cli": "0.10.0",
"@callstack/reassure-danger": "0.1.1",
- "@callstack/reassure-measure": "0.5.0"
+ "@callstack/reassure-measure": "0.6.0"
}
},
"recast": {
@@ -86849,8 +86719,7 @@
"reusify": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
- "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
- "dev": true
+ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="
},
"right-align": {
"version": "0.1.3",
@@ -86915,7 +86784,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
- "dev": true,
"requires": {
"queue-microtask": "^1.2.2"
}
@@ -87461,7 +87329,6 @@
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.19.0.tgz",
"integrity": "sha512-hyH2p9Ptxjf/xPuL7HfXbpYt9gKhC1yWDh3KYIAYJJePAKV7AEjLN4xhp7lozOdNiaJ9jlVvAbBymVlcS2jRiA==",
- "dev": true,
"requires": {
"@kwsites/file-exists": "^1.1.1",
"@kwsites/promise-deferred": "^1.1.1",
@@ -88612,7 +88479,6 @@
"version": "6.1.15",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz",
"integrity": "sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==",
- "dev": true,
"requires": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
@@ -88625,8 +88491,7 @@
"minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
- "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
- "dev": true
+ "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="
}
}
},
@@ -89223,7 +89088,6 @@
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
- "dev": true,
"requires": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
@@ -89273,9 +89137,9 @@
}
},
"typed-function": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.1.0.tgz",
- "integrity": "sha512-DGwUl6cioBW5gw2L+6SMupGwH/kZOqivy17E4nsh1JI9fKF87orMmlQx3KISQPmg3sfnOUGlwVkroosvgddrlg==",
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.1.1.tgz",
+ "integrity": "sha512-Pq1DVubcvibmm8bYcMowjVnnMwPVMeh0DIdA8ad8NZY2sJgapANJmiigSUwlt+EgXxpfIv8MWrQXTIzkfYZLYQ==",
"dev": true
},
"typedarray": {
@@ -91301,9 +91165,9 @@
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
},
"zod": {
- "version": "3.21.4",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
- "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==",
+ "version": "3.22.4",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
+ "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
"dev": true
},
"zwitch": {
diff --git a/package.json b/package.json
index 8cebfc186e37..993b8d165ed0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.3.84-2",
+ "version": "1.3.86-1",
"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.",
@@ -88,7 +88,6 @@
"@types/node": "^18.14.0",
"@ua/react-native-airship": "^15.2.6",
"awesome-phonenumber": "^5.4.0",
- "babel-plugin-transform-remove-console": "^6.9.4",
"babel-polyfill": "^6.26.0",
"canvas-size": "^1.2.6",
"core-js": "^3.32.0",
@@ -96,7 +95,7 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#009c2ab79bf7ddeab0eea7a3a4c0d9cc4277c34b",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#bdbdf44825658500ba581d3e86237d7b8996cc2e",
"fbjs": "^3.0.2",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
@@ -183,8 +182,6 @@
"@babel/runtime": "^7.20.0",
"@electron/notarize": "^1.2.3",
"@jest/globals": "^29.5.0",
- "@kie/act-js": "^2.0.1",
- "@kie/mock-github": "^1.0.0",
"@octokit/core": "4.0.4",
"@octokit/plugin-paginate-rest": "3.1.0",
"@octokit/plugin-throttling": "4.1.0",
@@ -267,7 +264,7 @@
"react-native-performance-flipper-reporter": "^2.0.0",
"react-native-svg-transformer": "^1.0.0",
"react-test-renderer": "18.2.0",
- "reassure": "^0.9.0",
+ "reassure": "^0.10.1",
"setimmediate": "^1.0.5",
"shellcheck": "^1.1.0",
"style-loader": "^2.0.0",
diff --git a/src/CONFIG.ts b/src/CONFIG.ts
index c02ed8065836..8b1dab5b3d71 100644
--- a/src/CONFIG.ts
+++ b/src/CONFIG.ts
@@ -64,6 +64,7 @@ export default {
CONCIERGE_URL_PATHNAME: 'concierge/',
DEVPORTAL_URL_PATHNAME: '_devportal/',
CONCIERGE_URL: `${expensifyURL}concierge/`,
+ SAML_URL: `${expensifyURL}authentication/saml/login`,
},
IS_IN_PRODUCTION: Platform.OS === 'web' ? process.env.NODE_ENV === 'production' : !__DEV__,
IS_IN_STAGING: ENVIRONMENT === CONST.ENVIRONMENT.STAGING,
diff --git a/src/CONST.ts b/src/CONST.ts
index d1d9bb1dede9..e2f3fea08215 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -131,7 +131,6 @@ const CONST = {
DESKTOP: `${ACTIVE_EXPENSIFY_URL}NewExpensify.dmg`,
},
DATE: {
- MOMENT_FORMAT_STRING: 'YYYY-MM-DD',
SQL_DATE_TIME: 'YYYY-MM-DD HH:mm:ss',
FNS_FORMAT_STRING: 'yyyy-MM-dd',
LOCAL_TIME_FORMAT: 'h:mm a',
@@ -243,6 +242,7 @@ const CONST = {
CUSTOM_STATUS: 'customStatus',
NEW_DOT_CATEGORIES: 'newDotCategories',
NEW_DOT_TAGS: 'newDotTags',
+ NEW_DOT_SAML: 'newDotSAML',
},
BUTTON_STATES: {
DEFAULT: 'default',
@@ -472,6 +472,7 @@ const CONST = {
HAND_ICON_HEIGHT: 152,
HAND_ICON_WIDTH: 200,
SHUTTER_SIZE: 90,
+ MAX_REPORT_PREVIEW_RECEIPTS: 3,
},
REPORT: {
MAXIMUM_PARTICIPANTS: 8,
@@ -1015,8 +1016,10 @@ const CONST = {
ACTIVATE: 'ActivateStep',
},
TIER_NAME: {
+ PLATINUM: 'PLATINUM',
GOLD: 'GOLD',
SILVER: 'SILVER',
+ BRONZE: 'BRONZE',
},
WEB_MESSAGE_TYPE: {
STATEMENT: 'STATEMENT_NAVIGATE',
@@ -1271,6 +1274,8 @@ const CONST = {
CARD_EXPIRATION_DATE: /^(0[1-9]|1[0-2])([^0-9])?([0-9]{4}|([0-9]{2}))$/,
ROOM_NAME: /^#[\p{Ll}0-9-]{1,80}$/u,
+ // eslint-disable-next-line max-len, no-misleading-character-class
+ EMOJI: /[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu,
// eslint-disable-next-line max-len, no-misleading-character-class
EMOJIS: /[\p{Extended_Pictographic}](\u200D[\p{Extended_Pictographic}]|[\u{1F3FB}-\u{1F3FF}]|[\u{E0020}-\u{E007F}]|\uFE0F|\u20E3)*|[\u{1F1E6}-\u{1F1FF}]{2}|[#*0-9]\uFE0F?\u20E3/gu,
@@ -1289,18 +1294,26 @@ const CONST = {
HAS_COLON_ONLY_AT_THE_BEGINNING: /^:[^:]+$/,
HAS_AT_MOST_TWO_AT_SIGNS: /^@[^@]*@?[^@]*$/,
- SPECIAL_CHAR_OR_EMOJI:
- // eslint-disable-next-line no-misleading-character-class
- /[\n\s,/?"{}[\]()&_~^%\\;`$=#<>!*\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu,
+ SPECIAL_CHAR: /[,/?"{}[\]()&^%;`$=#<>!*]/g,
+
+ get SPECIAL_CHAR_OR_EMOJI() {
+ return new RegExp(`[~\\n\\s]|(_\\b(?!$))|${this.SPECIAL_CHAR.source}|${this.EMOJI.source}`, 'gu');
+ },
- SPACE_OR_EMOJI:
- // eslint-disable-next-line no-misleading-character-class
- /(\s+|(?:[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3)+)/gu,
+ get SPACE_OR_EMOJI() {
+ return new RegExp(`(\\s+|(?:${this.EMOJI.source})+)`, 'gu');
+ },
+
+ // Define the regular expression pattern to find a potential end of a mention suggestion:
+ // It might be a space, a newline character, an emoji, or a special character (excluding underscores & tildes, which might be used in usernames)
+ get MENTION_BREAKER() {
+ return new RegExp(`[\\n\\s]|${this.SPECIAL_CHAR.source}|${this.EMOJI.source}`, 'gu');
+ },
// Define the regular expression pattern to match a string starting with an at sign and ending with a space or newline character
- MENTION_REPLACER:
- // eslint-disable-next-line no-misleading-character-class
- /^@[^\n\r]*?(?=$|[\s,/?"{}[\]()&^%\\;`$=#<>!*\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3)/u,
+ get MENTION_REPLACER() {
+ return new RegExp(`^@[^\\n\\r]*?(?=$|\\s|${this.SPECIAL_CHAR.source}|${this.EMOJI.source})`, 'u');
+ },
MERGED_ACCOUNT_PREFIX: /^(MERGED_\d+@)/,
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index d9ea3488f85f..c3003699378c 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -1,4 +1,5 @@
import {ValueOf} from 'type-fest';
+import {OnyxEntry} from 'react-native-onyx/lib/types';
import DeepValueOf from './types/utils/DeepValueOf';
import * as OnyxTypes from './types/onyx';
import CONST from './CONST';
@@ -235,6 +236,8 @@ const ONYXKEYS = {
DOWNLOAD: 'download_',
POLICY: 'policy_',
POLICY_MEMBERS: 'policyMembers_',
+ POLICY_DRAFTS: 'policyDrafts_',
+ POLICY_MEMBERS_DRAFTS: 'policyMembersDrafts_',
POLICY_CATEGORIES: 'policyCategories_',
POLICY_RECENTLY_USED_CATEGORIES: 'policyRecentlyUsedCategories_',
POLICY_TAGS: 'policyTags_',
@@ -429,5 +432,7 @@ type OnyxValues = {
[ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: OnyxTypes.Form;
};
+type OnyxKeyValue = OnyxEntry;
+
export default ONYXKEYS;
-export type {OnyxKey, OnyxCollectionKey, OnyxValues};
+export type {OnyxKey, OnyxCollectionKey, OnyxValues, OnyxKeyValue};
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 398b9ac6ba4f..a677b7192fac 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -36,6 +36,8 @@ export default {
APPLE_SIGN_IN: 'sign-in-with-apple',
GOOGLE_SIGN_IN: 'sign-in-with-google',
DESKTOP_SIGN_IN_REDIRECT: 'desktop-signin-redirect',
+ SAML_SIGN_IN: 'sign-in-with-saml',
+
// This is a special validation URL that will take the user to /workspace/new after validation. This is used
// when linking users from e.com in order to share a session in this app.
ENABLE_PAYMENTS: 'enable-payments',
@@ -71,22 +73,26 @@ export default {
SETTINGS_ABOUT: 'settings/about',
SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links',
SETTINGS_WALLET: 'settings/wallet',
- SETTINGS_WALLET_DOMAINCARDS: {
+ SETTINGS_WALLET_DOMAINCARD: {
route: '/settings/wallet/card/:domain',
getRoute: (domain: string) => `/settings/wallet/card/${domain}`,
},
SETTINGS_REPORT_FRAUD: {
- route: '/settings/wallet/cards/:domain/report-virtual-fraud',
- getRoute: (domain: string) => `/settings/wallet/cards/${domain}/report-virtual-fraud`,
+ route: '/settings/wallet/card/:domain/report-virtual-fraud',
+ getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-virtual-fraud`,
},
SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card',
SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account',
SETTINGS_ENABLE_PAYMENTS: 'settings/wallet/enable-payments',
+ SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS: {
+ route: 'settings/wallet/card/:domain/digital-details/update-address',
+ getRoute: (domain: string) => `settings/wallet/card/${domain}/digital-details/update-address`,
+ },
SETTINGS_WALLET_TRANSFER_BALANCE: 'settings/wallet/transfer-balance',
SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT: 'settings/wallet/choose-transfer-account',
SETTINGS_WALLET_CARD_ACTIVATE: {
- route: 'settings/wallet/cards/:domain/activate',
- getRoute: (domain: string) => `settings/wallet/cards/${domain}/activate`,
+ route: 'settings/wallet/card/:domain/activate',
+ getRoute: (domain: string) => `settings/wallet/card/${domain}/activate`,
},
SETTINGS_PERSONAL_DETAILS: 'settings/profile/personal-details',
SETTINGS_PERSONAL_DETAILS_LEGAL_NAME: 'settings/profile/personal-details/legal-name',
@@ -286,6 +292,11 @@ export default {
I_AM_A_TEACHER: 'teachersunite/i-am-a-teacher',
INTRO_SCHOOL_PRINCIPAL: 'teachersunite/intro-school-principal',
+ ERECEIPT: {
+ route: 'eReceipt/:transactionID',
+ getRoute: (transactionID: string) => `eReceipt/${transactionID}`,
+ },
+
WORKSPACE_NEW: 'workspace/new',
WORKSPACE_NEW_ROOM: 'workspace/new-room',
WORKSPACE_INITIAL: {
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 69f905e4a7a3..8ef787edec2e 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -2,17 +2,15 @@
* This is a file containing constants for all of the screen names. In most cases, we should use the routes for
* navigation. But there are situations where we may need to access screen names directly.
*/
-const PROTECTED_SCREENS = {
- HOME: 'Home',
- CONCIERGE: 'Concierge',
- REPORT_ATTACHMENTS: 'ReportAttachments',
-} as const;
-
export default {
- ...PROTECTED_SCREENS,
+ HOME: 'Home',
LOADING: 'Loading',
REPORT: 'Report',
+ REPORT_ATTACHMENTS: 'ReportAttachments',
NOT_FOUND: 'not-found',
+ TRANSITION_BETWEEN_APPS: 'TransitionBetweenApps',
+ VALIDATE_LOGIN: 'ValidateLogin',
+ CONCIERGE: 'Concierge',
SETTINGS: {
ROOT: 'Settings_Root',
PREFERENCES: 'Settings_Preferences',
@@ -25,11 +23,10 @@ export default {
SAVE_THE_WORLD: {
ROOT: 'SaveTheWorld_Root',
},
- TRANSITION_BETWEEN_APPS: 'TransitionBetweenApps',
SIGN_IN_WITH_APPLE_DESKTOP: 'AppleSignInDesktop',
SIGN_IN_WITH_GOOGLE_DESKTOP: 'GoogleSignInDesktop',
DESKTOP_SIGN_IN_REDIRECT: 'DesktopSignInRedirect',
- VALIDATE_LOGIN: 'ValidateLogin',
+ SAML_SIGN_IN: 'SAMLSignIn',
// Iframe screens from olddot
HOME_OLDDOT: 'Home_OLDDOT',
@@ -44,5 +41,3 @@ export default {
GROUPS_WORKSPACES_OLDDOT: 'GroupWorkspaces_OLDDOT',
CARDS_AND_DOMAINS_OLDDOT: 'CardsAndDomains_OLDDOT',
} as const;
-
-export {PROTECTED_SCREENS};
diff --git a/src/components/AddressSearch/CurrentLocationButton.js b/src/components/AddressSearch/CurrentLocationButton.js
new file mode 100644
index 000000000000..893ec031ab7f
--- /dev/null
+++ b/src/components/AddressSearch/CurrentLocationButton.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import {Text} from 'react-native';
+import colors from '../../styles/colors';
+import styles from '../../styles/styles';
+import Icon from '../Icon';
+import * as Expensicons from '../Icon/Expensicons';
+import PressableWithFeedback from '../Pressable/PressableWithFeedback';
+import getButtonState from '../../libs/getButtonState';
+import * as StyleUtils from '../../styles/StyleUtils';
+import useLocalize from '../../hooks/useLocalize';
+
+const propTypes = {
+ /** Callback that runs when location button is clicked */
+ onPress: PropTypes.func,
+
+ /** Boolean to indicate if the button is clickable */
+ isDisabled: PropTypes.bool,
+};
+
+const defaultProps = {
+ isDisabled: false,
+ onPress: () => {},
+};
+
+function CurrentLocationButton({onPress, isDisabled}) {
+ const {translate} = useLocalize();
+
+ return (
+ e.preventDefault()}
+ onTouchStart={(e) => e.preventDefault()}
+ >
+
+ {translate('location.useCurrent')}
+
+ );
+}
+
+CurrentLocationButton.displayName = 'CurrentLocationButton';
+CurrentLocationButton.propTypes = propTypes;
+CurrentLocationButton.defaultProps = defaultProps;
+
+export default CurrentLocationButton;
diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js
index fe220d442674..3e676b811c16 100644
--- a/src/components/AddressSearch/index.js
+++ b/src/components/AddressSearch/index.js
@@ -1,7 +1,7 @@
import _ from 'underscore';
-import React, {useMemo, useRef, useState} from 'react';
+import React, {useEffect, useMemo, useRef, useState} from 'react';
import PropTypes from 'prop-types';
-import {LogBox, ScrollView, View, Text, ActivityIndicator} from 'react-native';
+import {Keyboard, LogBox, ScrollView, View, Text, ActivityIndicator} from 'react-native';
import {GooglePlacesAutocomplete} from 'react-native-google-places-autocomplete';
import lodashGet from 'lodash/get';
import compose from '../../libs/compose';
@@ -11,12 +11,16 @@ import themeColors from '../../styles/themes/default';
import TextInput from '../TextInput';
import * as ApiUtils from '../../libs/ApiUtils';
import * as GooglePlacesUtils from '../../libs/GooglePlacesUtils';
+import getCurrentPosition from '../../libs/getCurrentPosition';
import CONST from '../../CONST';
import * as StyleUtils from '../../styles/StyleUtils';
-import resetDisplayListViewBorderOnBlur from './resetDisplayListViewBorderOnBlur';
+import isCurrentTargetInsideContainer from './isCurrentTargetInsideContainer';
import variables from '../../styles/variables';
+import FullScreenLoadingIndicator from '../FullscreenLoadingIndicator';
+import LocationErrorMessage from '../LocationErrorMessage';
import {withNetwork} from '../OnyxProvider';
import networkPropTypes from '../networkPropTypes';
+import CurrentLocationButton from './CurrentLocationButton';
// The error that's being thrown below will be ignored until we fork the
// react-native-google-places-autocomplete repo and replace the
@@ -61,6 +65,9 @@ const propTypes = {
/** Should address search be limited to results in the USA */
isLimitedToUSA: PropTypes.bool,
+ /** Shows a current location button in suggestion list */
+ canUseCurrentLocation: PropTypes.bool,
+
/** A list of predefined places that can be shown when the user isn't searching for something */
predefinedPlaces: PropTypes.arrayOf(
PropTypes.shape({
@@ -115,6 +122,7 @@ const defaultProps = {
defaultValue: undefined,
containerStyles: [],
isLimitedToUSA: false,
+ canUseCurrentLocation: false,
renamedInputKeys: {
street: 'addressStreet',
street2: 'addressStreet2',
@@ -135,6 +143,11 @@ const defaultProps = {
function AddressSearch(props) {
const [displayListViewBorder, setDisplayListViewBorder] = useState(false);
const [isTyping, setIsTyping] = useState(false);
+ const [isFocused, setIsFocused] = useState(false);
+ const [searchValue, setSearchValue] = useState(props.value || props.defaultValue || '');
+ const [locationErrorCode, setLocationErrorCode] = useState(null);
+ const [isFetchingCurrentLocation, setIsFetchingCurrentLocation] = useState(false);
+ const shouldTriggerGeolocationCallbacks = useRef(true);
const containerRef = useRef();
const query = useMemo(
() => ({
@@ -144,6 +157,7 @@ function AddressSearch(props) {
}),
[props.preferredLocale, props.resultTypes, props.isLimitedToUSA],
);
+ const shouldShowCurrentLocationButton = props.canUseCurrentLocation && searchValue.trim().length === 0 && isFocused;
const saveLocationDetails = (autocompleteData, details) => {
const addressComponents = details.address_components;
@@ -262,6 +276,72 @@ function AddressSearch(props) {
props.onPress(values);
};
+ /** Gets the user's current location and registers success/error callbacks */
+ const getCurrentLocation = () => {
+ if (isFetchingCurrentLocation) {
+ return;
+ }
+
+ setIsTyping(false);
+ setIsFocused(false);
+ setDisplayListViewBorder(false);
+ setIsFetchingCurrentLocation(true);
+
+ Keyboard.dismiss();
+
+ getCurrentPosition(
+ (successData) => {
+ if (!shouldTriggerGeolocationCallbacks.current) {
+ return;
+ }
+
+ setIsFetchingCurrentLocation(false);
+ setLocationErrorCode(null);
+
+ const location = {
+ lat: successData.coords.latitude,
+ lng: successData.coords.longitude,
+ address: CONST.YOUR_LOCATION_TEXT,
+ };
+ props.onPress(location);
+ },
+ (errorData) => {
+ if (!shouldTriggerGeolocationCallbacks.current) {
+ return;
+ }
+
+ setIsFetchingCurrentLocation(false);
+ setLocationErrorCode(errorData.code);
+ },
+ {
+ maximumAge: 0, // No cache, always get fresh location info
+ timeout: 5000,
+ },
+ );
+ };
+
+ const renderHeaderComponent = () =>
+ props.predefinedPlaces.length > 0 && (
+ <>
+ {/* This will show current location button in list if there are some recent destinations */}
+ {shouldShowCurrentLocationButton && (
+
+ )}
+ {!props.value && {props.translate('common.recentDestinations')}}
+ >
+ );
+
+ // eslint-disable-next-line arrow-body-style
+ useEffect(() => {
+ return () => {
+ // If the component unmounts we don't want any of the callback for geolocation to run.
+ shouldTriggerGeolocationCallbacks.current = false;
+ };
+ }, []);
+
return (
/*
* The GooglePlacesAutocomplete component uses a VirtualizedList internally,
@@ -269,119 +349,149 @@ function AddressSearch(props) {
* To work around this, we wrap the GooglePlacesAutocomplete component with a horizontal ScrollView
* that has scrolling disabled and would otherwise not be needed
*/
-
-
+
- {props.translate('common.noResultsFound')}
- )
- }
- listLoaderComponent={
-
-
-
- }
- renderHeaderComponent={() =>
- !props.value &&
- props.predefinedPlaces && (
- {props.translate('common.recentDestinations')}
- )
- }
- onPress={(data, details) => {
- saveLocationDetails(data, details);
- setIsTyping(false);
-
- // After we select an option, we set displayListViewBorder to false to prevent UI flickering
- setDisplayListViewBorder(false);
- }}
- query={query}
- requestUrl={{
- useOnPlatform: 'all',
- url: props.network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}),
- }}
- textInputProps={{
- InputComp: TextInput,
- ref: (node) => {
- if (!props.innerRef) {
- return;
- }
-
- if (_.isFunction(props.innerRef)) {
- props.innerRef(node);
- return;
- }
-
- // eslint-disable-next-line no-param-reassign
- props.innerRef.current = node;
- },
- label: props.label,
- containerStyles: props.containerStyles,
- errorText: props.errorText,
- hint: displayListViewBorder ? undefined : props.hint,
- value: props.value,
- defaultValue: props.defaultValue,
- inputID: props.inputID,
- shouldSaveDraft: props.shouldSaveDraft,
- onBlur: (event) => {
- resetDisplayListViewBorderOnBlur(setDisplayListViewBorder, event, containerRef);
- props.onBlur();
- },
- autoComplete: 'off',
- onInputChange: (text) => {
- setIsTyping(true);
- if (props.inputID) {
- props.onInputChange(text);
- } else {
- props.onInputChange({street: text});
- }
-
- // If the text is empty and we have no predefined places, we set displayListViewBorder to false to prevent UI flickering
- if (_.isEmpty(text) && _.isEmpty(props.predefinedPlaces)) {
- setDisplayListViewBorder(false);
- }
- },
- maxLength: props.maxInputLength,
- spellCheck: false,
- }}
- styles={{
- textInputContainer: [styles.flexColumn],
- listView: [StyleUtils.getGoogleListViewStyle(displayListViewBorder), styles.overflowAuto, styles.borderLeft, styles.borderRight],
- row: [styles.pv4, styles.ph3, styles.overflowAuto],
- description: [styles.googleSearchText],
- separator: [styles.googleSearchSeparator],
- }}
- numberOfLines={2}
- isRowScrollable={false}
- listHoverColor={themeColors.border}
- listUnderlayColor={themeColors.buttonPressedBG}
- onLayout={(event) => {
- // We use the height of the element to determine if we should hide the border of the listView dropdown
- // to prevent a lingering border when there are no address suggestions.
- setDisplayListViewBorder(event.nativeEvent.layout.height > variables.googleEmptyListViewHeight);
- }}
- />
-
-
+
+ {props.translate('common.noResultsFound')}
+ )
+ }
+ listLoaderComponent={
+
+
+
+ }
+ renderHeaderComponent={renderHeaderComponent}
+ onPress={(data, details) => {
+ saveLocationDetails(data, details);
+ setIsTyping(false);
+
+ // After we select an option, we set displayListViewBorder to false to prevent UI flickering
+ setDisplayListViewBorder(false);
+ setIsFocused(false);
+
+ // Clear location error code after address is selected
+ setLocationErrorCode(null);
+ }}
+ query={query}
+ requestUrl={{
+ useOnPlatform: 'all',
+ url: props.network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}),
+ }}
+ textInputProps={{
+ InputComp: TextInput,
+ ref: (node) => {
+ if (!props.innerRef) {
+ return;
+ }
+
+ if (_.isFunction(props.innerRef)) {
+ props.innerRef(node);
+ return;
+ }
+
+ // eslint-disable-next-line no-param-reassign
+ props.innerRef.current = node;
+ },
+ label: props.label,
+ containerStyles: props.containerStyles,
+ errorText: props.errorText,
+ hint:
+ displayListViewBorder || (props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (props.canUseCurrentLocation && isTyping)
+ ? undefined
+ : props.hint,
+ value: props.value,
+ defaultValue: props.defaultValue,
+ inputID: props.inputID,
+ shouldSaveDraft: props.shouldSaveDraft,
+ onFocus: () => {
+ setIsFocused(true);
+ },
+ onBlur: (event) => {
+ if (!isCurrentTargetInsideContainer(event, containerRef)) {
+ setDisplayListViewBorder(false);
+ setIsFocused(false);
+ setIsTyping(false);
+ }
+ props.onBlur();
+ },
+ autoComplete: 'off',
+ onInputChange: (text) => {
+ setSearchValue(text);
+ setIsTyping(true);
+ if (props.inputID) {
+ props.onInputChange(text);
+ } else {
+ props.onInputChange({street: text});
+ }
+
+ // If the text is empty and we have no predefined places, we set displayListViewBorder to false to prevent UI flickering
+ if (_.isEmpty(text) && _.isEmpty(props.predefinedPlaces)) {
+ setDisplayListViewBorder(false);
+ }
+ },
+ maxLength: props.maxInputLength,
+ spellCheck: false,
+ }}
+ styles={{
+ textInputContainer: [styles.flexColumn],
+ listView: [StyleUtils.getGoogleListViewStyle(displayListViewBorder), styles.overflowAuto, styles.borderLeft, styles.borderRight, !isFocused && {height: 0}],
+ row: [styles.pv4, styles.ph3, styles.overflowAuto],
+ description: [styles.googleSearchText],
+ separator: [styles.googleSearchSeparator],
+ }}
+ numberOfLines={2}
+ isRowScrollable={false}
+ listHoverColor={themeColors.border}
+ listUnderlayColor={themeColors.buttonPressedBG}
+ onLayout={(event) => {
+ // We use the height of the element to determine if we should hide the border of the listView dropdown
+ // to prevent a lingering border when there are no address suggestions.
+ setDisplayListViewBorder(event.nativeEvent.layout.height > variables.googleEmptyListViewHeight);
+ }}
+ inbetweenCompo={
+ // We want to show the current location button even if there are no recent destinations
+ props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? (
+
+
+
+ ) : (
+ <>>
+ )
+ }
+ />
+ setLocationErrorCode(null)}
+ locationErrorCode={locationErrorCode}
+ />
+
+
+ {isFetchingCurrentLocation && }
+ >
);
}
diff --git a/src/components/AddressSearch/isCurrentTargetInsideContainer.js b/src/components/AddressSearch/isCurrentTargetInsideContainer.js
new file mode 100644
index 000000000000..18bfc10a8dcb
--- /dev/null
+++ b/src/components/AddressSearch/isCurrentTargetInsideContainer.js
@@ -0,0 +1,8 @@
+function isCurrentTargetInsideContainer(event, containerRef) {
+ // The related target check is required here
+ // because without it when we select an option, the onBlur will still trigger setting displayListViewBorder to false
+ // it will make the auto complete component re-render before onPress is called making selecting an option not working.
+ return containerRef.current && event.target && containerRef.current.contains(event.relatedTarget);
+}
+
+export default isCurrentTargetInsideContainer;
diff --git a/src/components/AddressSearch/isCurrentTargetInsideContainer.native.js b/src/components/AddressSearch/isCurrentTargetInsideContainer.native.js
new file mode 100644
index 000000000000..dbf0004b08d9
--- /dev/null
+++ b/src/components/AddressSearch/isCurrentTargetInsideContainer.native.js
@@ -0,0 +1,6 @@
+function isCurrentTargetInsideContainer() {
+ // The related target check is not required here because in native there is no race condition rendering like on the web
+ return false;
+}
+
+export default isCurrentTargetInsideContainer;
diff --git a/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.js b/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.js
deleted file mode 100644
index def4da13a9a2..000000000000
--- a/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.js
+++ /dev/null
@@ -1,11 +0,0 @@
-function resetDisplayListViewBorderOnBlur(setDisplayListViewBorder, event, containerRef) {
- // The related target check is required here
- // because without it when we select an option, the onBlur will still trigger setting displayListViewBorder to false
- // it will make the auto complete component re-render before onPress is called making selecting an option not working.
- if (containerRef.current && event.target && containerRef.current.contains(event.relatedTarget)) {
- return;
- }
- setDisplayListViewBorder(false);
-}
-
-export default resetDisplayListViewBorderOnBlur;
diff --git a/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.native.js b/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.native.js
deleted file mode 100644
index 7ae5a44cae71..000000000000
--- a/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.native.js
+++ /dev/null
@@ -1,7 +0,0 @@
-function resetDisplayListViewBorderOnBlur(setDisplayListViewBorder) {
- // The related target check is not required here because in native there is no race condition rendering like on the web
- // onPress still called when cliking the option
- setDisplayListViewBorder(false);
-}
-
-export default resetDisplayListViewBorderOnBlur;
diff --git a/src/components/AnonymousReportFooter.js b/src/components/AnonymousReportFooter.js
index dd1a0864b0cf..43933210dc0b 100644
--- a/src/components/AnonymousReportFooter.js
+++ b/src/components/AnonymousReportFooter.js
@@ -36,6 +36,7 @@ function AnonymousReportFooter(props) {
report={props.report}
personalDetails={props.personalDetails}
isAnonymous
+ shouldEnableDetailPageNavigation
/>
diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
index 8a623a44709f..dae0191b2158 100644
--- a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
+++ b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
@@ -53,7 +53,7 @@ function extractAttachmentsFromReport(report, reportActions) {
const transaction = TransactionUtils.getTransaction(transactionID);
if (TransactionUtils.hasReceipt(transaction)) {
- const {image} = ReceiptUtils.getThumbnailAndImageURIs(transaction.receipt.source, transaction.filename);
+ const {image} = ReceiptUtils.getThumbnailAndImageURIs(transaction);
attachments.unshift({
source: tryResolveUrlFromApiRoot(image),
isAuthTokenRequired: true,
diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js
index bd12020341be..bcea50698b3b 100644
--- a/src/components/Attachments/AttachmentCarousel/index.native.js
+++ b/src/components/Attachments/AttachmentCarousel/index.native.js
@@ -104,10 +104,10 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, onClose,
* @returns {JSX.Element}
*/
const renderItem = useCallback(
- ({item}) => (
+ ({item, isActive}) => (
setShouldShowArrows(!shouldShowArrows)}
/>
),
diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js
index a1b07fb99dd8..34ff45160ce9 100755
--- a/src/components/Attachments/AttachmentView/index.js
+++ b/src/components/Attachments/AttachmentView/index.js
@@ -1,5 +1,5 @@
import React, {memo, useState} from 'react';
-import {View, ActivityIndicator} from 'react-native';
+import {View, ScrollView, ActivityIndicator} from 'react-native';
import _ from 'underscore';
import PropTypes from 'prop-types';
import Str from 'expensify-common/lib/str';
@@ -22,6 +22,7 @@ import * as TransactionUtils from '../../../libs/TransactionUtils';
import DistanceEReceipt from '../../DistanceEReceipt';
import useNetwork from '../../../hooks/useNetwork';
import ONYXKEYS from '../../../ONYXKEYS';
+import EReceipt from '../../EReceipt';
const propTypes = {
...attachmentViewPropTypes,
@@ -101,6 +102,19 @@ function AttachmentView({
);
}
+ if (TransactionUtils.hasEReceipt(transaction)) {
+ return (
+
+
+
+
+
+ );
+ }
+
// Check both source and file.name since PDFs dragged into the text field
// will appear with a source that is a blob
if ((_.isString(source) && Str.isPDF(source)) || (file && Str.isPDF(file.name || translate('attachmentView.unknownFilename')))) {
diff --git a/src/components/Button/index.js b/src/components/Button/index.js
index dc12a4ded5c2..16654ce87d30 100644
--- a/src/components/Button/index.js
+++ b/src/components/Button/index.js
@@ -9,8 +9,6 @@ import Icon from '../Icon';
import CONST from '../../CONST';
import * as StyleUtils from '../../styles/StyleUtils';
import HapticFeedback from '../../libs/HapticFeedback';
-import withNavigationFallback from '../withNavigationFallback';
-import compose from '../../libs/compose';
import * as Expensicons from '../Icon/Expensicons';
import withNavigationFocus from '../withNavigationFocus';
import validateSubmitShortcut from './validateSubmitShortcut';
@@ -328,10 +326,7 @@ class Button extends Component {
Button.propTypes = propTypes;
Button.defaultProps = defaultProps;
-export default compose(
- withNavigationFallback,
- withNavigationFocus,
-)(
+export default withNavigationFocus(
React.forwardRef((props, ref) => (
+
+
+ >
+ );
}
-DatePicker.propTypes = datepickerPropTypes;
+DatePicker.propTypes = propTypes;
DatePicker.defaultProps = defaultProps;
+DatePicker.displayName = 'DatePicker';
/**
* We're applying localization here because we present a modal (with buttons) ourselves
@@ -149,15 +138,10 @@ DatePicker.defaultProps = defaultProps;
* locale. Otherwise the spinner would be present in the system locale and it would be weird if it happens
* that the modal buttons are in one locale (app) while the (spinner) month names are another (system)
*/
-export default compose(
- withLocalize,
- withKeyboardState,
-)(
- React.forwardRef((props, ref) => (
-
- )),
-);
+export default React.forwardRef((props, ref) => (
+
+));
diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js
index d14886fd1c59..e0672f847295 100644
--- a/src/components/DatePicker/index.js
+++ b/src/components/DatePicker/index.js
@@ -1,5 +1,5 @@
import React, {useEffect, useRef} from 'react';
-import moment from 'moment';
+import {format, isValid} from 'date-fns';
import _ from 'underscore';
import TextInput from '../TextInput';
import CONST from '../../CONST';
@@ -13,8 +13,8 @@ function DatePicker({maxDate, minDate, onInputChange, innerRef, label, value, pl
useEffect(() => {
// Adds nice native datepicker on web/desktop. Not possible to set this through props
inputRef.current.setAttribute('type', 'date');
- inputRef.current.setAttribute('max', moment(maxDate).format(CONST.DATE.MOMENT_FORMAT_STRING));
- inputRef.current.setAttribute('min', moment(minDate).format(CONST.DATE.MOMENT_FORMAT_STRING));
+ inputRef.current.setAttribute('max', format(new Date(maxDate), CONST.DATE.FNS_FORMAT_STRING));
+ inputRef.current.setAttribute('min', format(new Date(minDate), CONST.DATE.FNS_FORMAT_STRING));
inputRef.current.classList.add('expensify-datepicker');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -29,9 +29,9 @@ function DatePicker({maxDate, minDate, onInputChange, innerRef, label, value, pl
return;
}
- const asMoment = moment(text, true);
- if (asMoment.isValid()) {
- onInputChange(asMoment.format(CONST.DATE.MOMENT_FORMAT_STRING));
+ const date = new Date(text);
+ if (isValid(date)) {
+ onInputChange(format(date, CONST.DATE.FNS_FORMAT_STRING));
}
};
diff --git a/src/components/DistanceRequest/DistanceRequestFooter.js b/src/components/DistanceRequest/DistanceRequestFooter.js
index c96adfee9ba0..b0f6e0410ad5 100644
--- a/src/components/DistanceRequest/DistanceRequestFooter.js
+++ b/src/components/DistanceRequest/DistanceRequestFooter.js
@@ -115,7 +115,7 @@ function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navig
pitchEnabled={false}
initialState={{
zoom: CONST.MAPBOX.DEFAULT_ZOOM,
- location: CONST.MAPBOX.DEFAULT_COORDINATE,
+ location: lodashGet(waypointMarkers, [0, 'coordinate'], CONST.MAPBOX.DEFAULT_COORDINATE),
}}
directionCoordinates={lodashGet(transaction, 'routes.route0.geometry.coordinates', [])}
style={styles.mapView}
diff --git a/src/components/DistanceRequest/index.js b/src/components/DistanceRequest/index.js
index 416fefc5af89..db0571cdcdaf 100644
--- a/src/components/DistanceRequest/index.js
+++ b/src/components/DistanceRequest/index.js
@@ -2,7 +2,6 @@ import React, {useCallback, useEffect, useMemo, useState, useRef} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import lodashGet from 'lodash/get';
-import lodashIsEmpty from 'lodash/isEmpty';
import PropTypes from 'prop-types';
import _ from 'underscore';
import ROUTES from '../../ROUTES';
@@ -169,8 +168,7 @@ function DistanceRequest({transactionID, report, transaction, route, isEditingRe
const newWaypoints = {};
_.each(data, (waypoint, index) => {
- const newWaypoint = lodashGet(waypoints, waypoint, {});
- newWaypoints[`waypoint${index}`] = lodashIsEmpty(newWaypoint) ? null : newWaypoint;
+ newWaypoints[`waypoint${index}`] = lodashGet(waypoints, waypoint, {});
});
setOptimisticWaypoints(newWaypoints);
diff --git a/src/components/EReceipt.js b/src/components/EReceipt.js
index e6b3a9809c7e..84daabb96c9b 100644
--- a/src/components/EReceipt.js
+++ b/src/components/EReceipt.js
@@ -59,7 +59,7 @@ function EReceipt({transaction, transactionID}) {
-
+ {currency}
diff --git a/src/components/EmojiPicker/EmojiPickerButtonDropdown.js b/src/components/EmojiPicker/EmojiPickerButtonDropdown.js
index 3023a9abf95c..2f84d38ccbc6 100644
--- a/src/components/EmojiPicker/EmojiPickerButtonDropdown.js
+++ b/src/components/EmojiPicker/EmojiPickerButtonDropdown.js
@@ -28,12 +28,18 @@ function EmojiPickerButtonDropdown(props) {
const emojiPopoverAnchor = useRef(null);
useEffect(() => EmojiPickerAction.resetEmojiPopoverAnchor, []);
- const onPress = () =>
+ const onPress = () => {
+ if (EmojiPickerAction.isEmojiPickerVisible()) {
+ EmojiPickerAction.hideEmojiPicker();
+ return;
+ }
+
EmojiPickerAction.showEmojiPicker(props.onModalHide, (emoji) => props.onInputChange(emoji), emojiPopoverAnchor.current, {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
shiftVertical: 4,
});
+ };
return (
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js
index 3dfc5f59bb38..0d7826ff3783 100755
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js
@@ -1,4 +1,4 @@
-import React, {Component} from 'react';
+import React, {useCallback, useEffect, useRef, useState} from 'react';
import {View, FlatList} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import PropTypes from 'prop-types';
@@ -8,7 +8,7 @@ import CONST from '../../../CONST';
import ONYXKEYS from '../../../ONYXKEYS';
import styles from '../../../styles/styles';
import * as StyleUtils from '../../../styles/StyleUtils';
-import emojis from '../../../../assets/emojis';
+import emojiAssets from '../../../../assets/emojis';
import EmojiPickerMenuItem from '../EmojiPickerMenuItem';
import Text from '../../Text';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../withWindowDimensions';
@@ -18,6 +18,7 @@ import getOperatingSystem from '../../../libs/getOperatingSystem';
import * as User from '../../../libs/actions/User';
import EmojiSkinToneList from '../EmojiSkinToneList';
import * as EmojiUtils from '../../../libs/EmojiUtils';
+import * as Browser from '../../../libs/Browser';
import CategoryShortcutBar from '../CategoryShortcutBar';
import TextInput from '../../TextInput';
import isEnterWhileComposition from '../../../libs/KeyboardShortcut/isEnterWhileComposition';
@@ -32,7 +33,6 @@ const propTypes = {
/** Stores user's preferred skin tone */
preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
-
/** Stores user's frequently used emojis */
// eslint-disable-next-line react/forbid-prop-types
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.object),
@@ -49,105 +49,35 @@ const defaultProps = {
frequentlyUsedEmojis: [],
};
-class EmojiPickerMenu extends Component {
- constructor(props) {
- super(props);
-
- // Ref for the emoji search input
- this.searchInput = undefined;
-
- // Ref for emoji FlatList
- this.emojiList = undefined;
-
- // We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will
- // prevent auto focus when open picker for mobile device
- this.shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
-
- this.filterEmojis = _.debounce(this.filterEmojis.bind(this), 300);
- this.highlightAdjacentEmoji = this.highlightAdjacentEmoji.bind(this);
- this.setupEventHandlers = this.setupEventHandlers.bind(this);
- this.cleanupEventHandlers = this.cleanupEventHandlers.bind(this);
- this.renderItem = this.renderItem.bind(this);
- this.isMobileLandscape = this.isMobileLandscape.bind(this);
- this.onSelectionChange = this.onSelectionChange.bind(this);
- this.updatePreferredSkinTone = this.updatePreferredSkinTone.bind(this);
- this.setFirstNonHeaderIndex = this.setFirstNonHeaderIndex.bind(this);
- this.getItemLayout = this.getItemLayout.bind(this);
- this.scrollToHeader = this.scrollToHeader.bind(this);
-
- this.firstNonHeaderIndex = 0;
-
- const {filteredEmojis, headerEmojis, headerRowIndices} = this.getEmojisAndHeaderRowIndices();
- this.emojis = filteredEmojis;
- this.headerEmojis = headerEmojis;
- this.headerRowIndices = headerRowIndices;
-
- this.state = {
- filteredEmojis: this.emojis,
- headerIndices: this.headerRowIndices,
- highlightedIndex: -1,
- arePointerEventsDisabled: false,
- selection: {
- start: 0,
- end: 0,
- },
- isFocused: false,
- isUsingKeyboardMovement: false,
- };
- }
+const throttleTime = Browser.isMobile() ? 200 : 50;
- componentDidMount() {
- // This callback prop is used by the parent component using the constructor to
- // get a ref to the inner textInput element e.g. if we do
- // this.textInput = el} /> this will not
- // return a ref to the component, but rather the HTML element by default
- if (this.shouldFocusInputOnScreenFocus && this.props.forwardedRef && _.isFunction(this.props.forwardedRef)) {
- this.props.forwardedRef(this.searchInput);
- }
- this.setupEventHandlers();
- this.setFirstNonHeaderIndex(this.emojis);
- }
+function EmojiPickerMenu(props) {
+ const {forwardedRef, frequentlyUsedEmojis, preferredSkinTone, onEmojiSelected, preferredLocale, isSmallScreenWidth, windowHeight, translate} = props;
- componentDidUpdate(prevProps) {
- if (prevProps.frequentlyUsedEmojis === this.props.frequentlyUsedEmojis) {
- return;
- }
+ // Ref for the emoji search input
+ const searchInputRef = useRef(null);
- const {filteredEmojis, headerEmojis, headerRowIndices} = this.getEmojisAndHeaderRowIndices();
- this.emojis = filteredEmojis;
- this.headerEmojis = headerEmojis;
- this.headerRowIndices = headerRowIndices;
- this.setState({
- filteredEmojis: this.emojis,
- headerIndices: this.headerRowIndices,
- });
- }
+ // Ref for emoji FlatList
+ const emojiListRef = useRef(null);
- componentWillUnmount() {
- this.cleanupEventHandlers();
- }
+ // We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will
+ // prevent auto focus when open picker for mobile device
+ const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
- /**
- * On text input selection change
- *
- * @param {Event} event
- */
- onSelectionChange(event) {
- this.setState({selection: event.nativeEvent.selection});
- }
+ const firstNonHeaderIndex = useRef(0);
/**
* Calculate the filtered + header emojis and header row indices
* @returns {Object}
*/
- getEmojisAndHeaderRowIndices() {
+ function getEmojisAndHeaderRowIndices() {
// If we're on Windows, don't display the flag emojis (the last category),
// since Windows doesn't support them
- const flagHeaderIndex = _.findIndex(emojis, (emoji) => emoji.header && emoji.code === 'flags');
+ const flagHeaderIndex = _.findIndex(emojiAssets, (emoji) => emoji.header && emoji.code === 'flags');
const filteredEmojis =
getOperatingSystem() === CONST.OS.WINDOWS
- ? EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis.slice(0, flagHeaderIndex))
- : EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis);
+ ? EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojiAssets.slice(0, flagHeaderIndex))
+ : EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojiAssets);
// Get the header emojis along with the code, index and icon.
// index is the actual header index starting at the first emoji and counting each one
@@ -161,76 +91,57 @@ class EmojiPickerMenu extends Component {
return {filteredEmojis, headerEmojis, headerRowIndices};
}
+ const emojis = useRef([]);
+ if (emojis.current.length === 0) {
+ emojis.current = getEmojisAndHeaderRowIndices().filteredEmojis;
+ }
+ const headerRowIndices = useRef([]);
+ if (headerRowIndices.current.length === 0) {
+ headerRowIndices.current = getEmojisAndHeaderRowIndices().headerRowIndices;
+ }
+ const [headerEmojis, setHeaderEmojis] = useState(() => getEmojisAndHeaderRowIndices().headerEmojis);
+
+ const [filteredEmojis, setFilteredEmojis] = useState(emojis.current);
+ const [headerIndices, setHeaderIndices] = useState(headerRowIndices.current);
+ const [highlightedIndex, setHighlightedIndex] = useState(-1);
+ const [arePointerEventsDisabled, setArePointerEventsDisabled] = useState(false);
+ const [selection, setSelection] = useState({start: 0, end: 0});
+ const [isFocused, setIsFocused] = useState(false);
+ const [isUsingKeyboardMovement, setIsUsingKeyboardMovement] = useState(false);
+ const [selectTextOnFocus, setSelectTextOnFocus] = useState(false);
+
+ useEffect(() => {
+ const emojisAndHeaderRowIndices = getEmojisAndHeaderRowIndices();
+ emojis.current = emojisAndHeaderRowIndices.filteredEmojis;
+ headerRowIndices.current = emojisAndHeaderRowIndices.headerRowIndices;
+ setHeaderEmojis(emojisAndHeaderRowIndices.headerEmojis);
+ setFilteredEmojis(emojis.current);
+ setHeaderIndices(headerRowIndices.current);
+ }, [frequentlyUsedEmojis]);
+
/**
- * Find and store index of the first emoji item
- * @param {Array} filteredEmojis
+ * On text input selection change
+ *
+ * @param {Event} event
*/
- setFirstNonHeaderIndex(filteredEmojis) {
- this.firstNonHeaderIndex = _.findIndex(filteredEmojis, (item) => !item.spacer && !item.header);
- }
+ const onSelectionChange = useCallback((event) => {
+ setSelection(event.nativeEvent.selection);
+ }, []);
/**
- * Setup and attach keypress/mouse handlers for highlight navigation.
+ * Find and store index of the first emoji item
+ * @param {Array} filteredEmojisArr
*/
- setupEventHandlers() {
- if (!document) {
+ function updateFirstNonHeaderIndex(filteredEmojisArr) {
+ firstNonHeaderIndex.current = _.findIndex(filteredEmojisArr, (item) => !item.spacer && !item.header);
+ }
+
+ const mouseMoveHandler = useCallback(() => {
+ if (!arePointerEventsDisabled) {
return;
}
-
- this.keyDownHandler = (keyBoardEvent) => {
- if (keyBoardEvent.key.startsWith('Arrow')) {
- if (!this.state.isFocused || keyBoardEvent.key === 'ArrowUp' || keyBoardEvent.key === 'ArrowDown') {
- keyBoardEvent.preventDefault();
- }
-
- // Move the highlight when arrow keys are pressed
- this.highlightAdjacentEmoji(keyBoardEvent.key);
- return;
- }
-
- // Select the currently highlighted emoji if enter is pressed
- if (!isEnterWhileComposition(keyBoardEvent) && keyBoardEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && this.state.highlightedIndex !== -1) {
- const item = this.state.filteredEmojis[this.state.highlightedIndex];
- if (!item) {
- return;
- }
- const emoji = lodashGet(item, ['types', this.props.preferredSkinTone], item.code);
- this.props.onEmojiSelected(emoji, item);
- return;
- }
-
- // Enable keyboard movement if tab or enter is pressed or if shift is pressed while the input
- // is not focused, so that the navigation and tab cycling can be done using the keyboard without
- // interfering with the input behaviour.
- if (keyBoardEvent.key === 'Tab' || keyBoardEvent.key === 'Enter' || (keyBoardEvent.key === 'Shift' && this.searchInput && !this.searchInput.isFocused())) {
- this.setState({isUsingKeyboardMovement: true});
- return;
- }
-
- // We allow typing in the search box if any key is pressed apart from Arrow keys.
- if (this.searchInput && !this.searchInput.isFocused()) {
- this.setState({selectTextOnFocus: false});
- this.searchInput.focus();
-
- // Re-enable selection on the searchInput
- this.setState({selectTextOnFocus: true});
- }
- };
-
- // Keyboard events are not bubbling on TextInput in RN-Web, Bubbling was needed for this event to trigger
- // event handler attached to document root. To fix this, trigger event handler in Capture phase.
- document.addEventListener('keydown', this.keyDownHandler, true);
-
- // Re-enable pointer events and hovering over EmojiPickerItems when the mouse moves
- this.mouseMoveHandler = () => {
- if (!this.state.arePointerEventsDisabled) {
- return;
- }
-
- this.setState({arePointerEventsDisabled: false});
- };
- document.addEventListener('mousemove', this.mouseMoveHandler);
- }
+ setArePointerEventsDisabled(false);
+ }, [arePointerEventsDisabled]);
/**
* This function will be used with FlatList getItemLayout property for optimization purpose that allows skipping
@@ -242,179 +153,254 @@ class EmojiPickerMenu extends Component {
* @param {Number} index row index
* @returns {Object}
*/
- getItemLayout(data, index) {
- return {length: CONST.EMOJI_PICKER_ITEM_HEIGHT, offset: CONST.EMOJI_PICKER_ITEM_HEIGHT * index, index};
- }
+ const getItemLayout = useCallback((data, index) => ({length: CONST.EMOJI_PICKER_ITEM_HEIGHT, offset: CONST.EMOJI_PICKER_ITEM_HEIGHT * index, index}), []);
/**
- * Cleanup all mouse/keydown event listeners that we've set up
+ * Focuses the search Input and has the text selected
*/
- cleanupEventHandlers() {
- if (!document) {
+ function focusInputWithTextSelect() {
+ if (!searchInputRef.current) {
return;
}
- document.removeEventListener('keydown', this.keyDownHandler, true);
- document.removeEventListener('mousemove', this.mouseMoveHandler);
+ setSelectTextOnFocus(true);
+ searchInputRef.current.focus();
}
- /**
- * Focuses the search Input and has the text selected
- */
- focusInputWithTextSelect() {
- if (!this.searchInput) {
+ const filterEmojis = _.throttle((searchTerm) => {
+ const normalizedSearchTerm = searchTerm.toLowerCase().trim().replaceAll(':', '');
+ if (emojiListRef.current) {
+ emojiListRef.current.scrollToOffset({offset: 0, animated: false});
+ }
+ if (normalizedSearchTerm === '') {
+ // There are no headers when searching, so we need to re-make them sticky when there is no search term
+ setFilteredEmojis(emojis.current);
+ setHeaderIndices(headerRowIndices.current);
+ setHighlightedIndex(-1);
+ updateFirstNonHeaderIndex(emojis.current);
return;
}
+ const newFilteredEmojiList = EmojiUtils.suggestEmojis(`:${normalizedSearchTerm}`, preferredLocale, emojis.current.length);
- this.setState({selectTextOnFocus: true});
- this.searchInput.focus();
- }
+ // Remove sticky header indices. There are no headers while searching and we don't want to make emojis sticky
+ setFilteredEmojis(newFilteredEmojiList);
+ setHeaderIndices([]);
+ setHighlightedIndex(0);
+ updateFirstNonHeaderIndex(newFilteredEmojiList);
+ }, throttleTime);
/**
* Highlights emojis adjacent to the currently highlighted emoji depending on the arrowKey
* @param {String} arrowKey
*/
- highlightAdjacentEmoji(arrowKey) {
- if (this.state.filteredEmojis.length === 0) {
- return;
- }
-
- // Arrow Down and Arrow Right enable arrow navigation when search is focused
- if (this.searchInput && this.searchInput.isFocused()) {
- if (arrowKey !== 'ArrowDown' && arrowKey !== 'ArrowRight') {
+ const highlightAdjacentEmoji = useCallback(
+ (arrowKey) => {
+ if (filteredEmojis.length === 0) {
return;
}
- if (arrowKey === 'ArrowRight' && !(this.searchInput.value.length === this.state.selection.start && this.state.selection.start === this.state.selection.end)) {
+ // Arrow Down and Arrow Right enable arrow navigation when search is focused
+ if (searchInputRef.current && searchInputRef.current.isFocused()) {
+ if (arrowKey !== 'ArrowDown' && arrowKey !== 'ArrowRight') {
+ return;
+ }
+
+ if (arrowKey === 'ArrowRight' && !(searchInputRef.current.value.length === selection.start && selection.start === selection.end)) {
+ return;
+ }
+
+ // Blur the input, change the highlight type to keyboard, and disable pointer events
+ searchInputRef.current.blur();
+ setArePointerEventsDisabled(true);
+ setIsUsingKeyboardMovement(true);
+
+ // We only want to hightlight the Emoji if none was highlighted already
+ // If we already have a highlighted Emoji, lets just skip the first navigation
+ if (highlightedIndex !== -1) {
+ return;
+ }
+ }
+
+ // If nothing is highlighted and an arrow key is pressed
+ // select the first emoji, apply keyboard movement styles, and disable pointer events
+ if (highlightedIndex === -1) {
+ setHighlightedIndex(firstNonHeaderIndex.current);
+ setArePointerEventsDisabled(true);
+ setIsUsingKeyboardMovement(true);
return;
}
- // Blur the input, change the highlight type to keyboard, and disable pointer events
- this.searchInput.blur();
- this.setState({isUsingKeyboardMovement: true, arePointerEventsDisabled: true});
+ let newIndex = highlightedIndex;
+ const move = (steps, boundsCheck, onBoundReached = () => {}) => {
+ if (boundsCheck()) {
+ onBoundReached();
+ return;
+ }
- // We only want to hightlight the Emoji if none was highlighted already
- // If we already have a highlighted Emoji, lets just skip the first navigation
- if (this.state.highlightedIndex !== -1) {
- return;
+ // Move in the prescribed direction until we reach an element that isn't a header
+ const isHeader = (e) => e.header || e.spacer;
+ do {
+ newIndex += steps;
+ if (newIndex < 0) {
+ break;
+ }
+ } while (isHeader(filteredEmojis[newIndex]));
+ };
+
+ switch (arrowKey) {
+ case 'ArrowDown':
+ move(CONST.EMOJI_NUM_PER_ROW, () => highlightedIndex + CONST.EMOJI_NUM_PER_ROW > filteredEmojis.length - 1);
+ break;
+ case 'ArrowLeft':
+ move(
+ -1,
+ () => highlightedIndex - 1 < firstNonHeaderIndex.current,
+ () => {
+ // Reaching start of the list, arrow left set the focus to searchInput.
+ focusInputWithTextSelect();
+ newIndex = -1;
+ },
+ );
+ break;
+ case 'ArrowRight':
+ move(1, () => highlightedIndex + 1 > filteredEmojis.length - 1);
+ break;
+ case 'ArrowUp':
+ move(
+ -CONST.EMOJI_NUM_PER_ROW,
+ () => highlightedIndex - CONST.EMOJI_NUM_PER_ROW < firstNonHeaderIndex.current,
+ () => {
+ // Reaching start of the list, arrow up set the focus to searchInput.
+ focusInputWithTextSelect();
+ newIndex = -1;
+ },
+ );
+ break;
+ default:
+ break;
}
- }
- // If nothing is highlighted and an arrow key is pressed
- // select the first emoji, apply keyboard movement styles, and disable pointer events
- if (this.state.highlightedIndex === -1) {
- this.setState({highlightedIndex: this.firstNonHeaderIndex, isUsingKeyboardMovement: true, arePointerEventsDisabled: true});
- return;
- }
+ // Actually highlight the new emoji, apply keyboard movement styles, and disable pointer events
+ if (newIndex !== highlightedIndex) {
+ setHighlightedIndex(newIndex);
+ setArePointerEventsDisabled(true);
+ setIsUsingKeyboardMovement(true);
+ }
+ },
+ [filteredEmojis, highlightedIndex, selection.end, selection.start],
+ );
- let newIndex = this.state.highlightedIndex;
- const move = (steps, boundsCheck, onBoundReached = () => {}) => {
- if (boundsCheck()) {
- onBoundReached();
+ const keyDownHandler = useCallback(
+ (keyBoardEvent) => {
+ if (keyBoardEvent.key.startsWith('Arrow')) {
+ if (!isFocused || keyBoardEvent.key === 'ArrowUp' || keyBoardEvent.key === 'ArrowDown') {
+ keyBoardEvent.preventDefault();
+ }
+
+ // Move the highlight when arrow keys are pressed
+ highlightAdjacentEmoji(keyBoardEvent.key);
return;
}
- // Move in the prescribed direction until we reach an element that isn't a header
- const isHeader = (e) => e.header || e.spacer;
- do {
- newIndex += steps;
- if (newIndex < 0) {
- break;
+ // Select the currently highlighted emoji if enter is pressed
+ if (!isEnterWhileComposition(keyBoardEvent) && keyBoardEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && highlightedIndex !== -1) {
+ const item = filteredEmojis[highlightedIndex];
+ if (!item) {
+ return;
}
- } while (isHeader(this.state.filteredEmojis[newIndex]));
- };
+ const emoji = lodashGet(item, ['types', preferredSkinTone], item.code);
+ onEmojiSelected(emoji, item);
+ return;
+ }
- switch (arrowKey) {
- case 'ArrowDown':
- move(CONST.EMOJI_NUM_PER_ROW, () => this.state.highlightedIndex + CONST.EMOJI_NUM_PER_ROW > this.state.filteredEmojis.length - 1);
- break;
- case 'ArrowLeft':
- move(
- -1,
- () => this.state.highlightedIndex - 1 < this.firstNonHeaderIndex,
- () => {
- // Reaching start of the list, arrow left set the focus to searchInput.
- this.focusInputWithTextSelect();
- newIndex = -1;
- },
- );
- break;
- case 'ArrowRight':
- move(1, () => this.state.highlightedIndex + 1 > this.state.filteredEmojis.length - 1);
- break;
- case 'ArrowUp':
- move(
- -CONST.EMOJI_NUM_PER_ROW,
- () => this.state.highlightedIndex - CONST.EMOJI_NUM_PER_ROW < this.firstNonHeaderIndex,
- () => {
- // Reaching start of the list, arrow up set the focus to searchInput.
- this.focusInputWithTextSelect();
- newIndex = -1;
- },
- );
- break;
- default:
- break;
- }
+ // Enable keyboard movement if tab or enter is pressed or if shift is pressed while the input
+ // is not focused, so that the navigation and tab cycling can be done using the keyboard without
+ // interfering with the input behaviour.
+ if (keyBoardEvent.key === 'Tab' || keyBoardEvent.key === 'Enter' || (keyBoardEvent.key === 'Shift' && searchInputRef.current && !searchInputRef.current.isFocused())) {
+ setIsUsingKeyboardMovement(true);
+ return;
+ }
- // Actually highlight the new emoji, apply keyboard movement styles, and disable pointer events
- if (newIndex !== this.state.highlightedIndex) {
- this.setState({highlightedIndex: newIndex, isUsingKeyboardMovement: true, arePointerEventsDisabled: true});
- }
- }
+ // We allow typing in the search box if any key is pressed apart from Arrow keys.
+ if (searchInputRef.current && !searchInputRef.current.isFocused()) {
+ setSelectTextOnFocus(false);
+ searchInputRef.current.focus();
- scrollToHeader(headerIndex) {
- const calculatedOffset = Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW) * CONST.EMOJI_PICKER_HEADER_HEIGHT;
- this.emojiList.flashScrollIndicators();
- this.emojiList.scrollToOffset({offset: calculatedOffset, animated: true});
- }
+ // Re-enable selection on the searchInput
+ setSelectTextOnFocus(true);
+ }
+ },
+ [filteredEmojis, highlightAdjacentEmoji, highlightedIndex, isFocused, onEmojiSelected, preferredSkinTone],
+ );
/**
- * Filter the entire list of emojis to only emojis that have the search term in their keywords
- *
- * @param {String} searchTerm
+ * Setup and attach keypress/mouse handlers for highlight navigation.
*/
- filterEmojis(searchTerm) {
- const normalizedSearchTerm = searchTerm.toLowerCase().trim().replaceAll(':', '');
- if (this.emojiList) {
- this.emojiList.scrollToOffset({offset: 0, animated: false});
- }
- if (normalizedSearchTerm === '') {
- // There are no headers when searching, so we need to re-make them sticky when there is no search term
- this.setState({
- filteredEmojis: this.emojis,
- headerIndices: this.headerRowIndices,
- highlightedIndex: -1,
- });
- this.setFirstNonHeaderIndex(this.emojis);
+ const setupEventHandlers = useCallback(() => {
+ if (!document) {
return;
}
- const newFilteredEmojiList = EmojiUtils.suggestEmojis(`:${normalizedSearchTerm}`, this.props.preferredLocale, this.emojis.length);
- // Remove sticky header indices. There are no headers while searching and we don't want to make emojis sticky
- this.setState({filteredEmojis: newFilteredEmojiList, headerIndices: [], highlightedIndex: 0});
- this.setFirstNonHeaderIndex(newFilteredEmojiList);
- }
+ // Keyboard events are not bubbling on TextInput in RN-Web, Bubbling was needed for this event to trigger
+ // event handler attached to document root. To fix this, trigger event handler in Capture phase.
+ document.addEventListener('keydown', keyDownHandler, true);
+
+ // Re-enable pointer events and hovering over EmojiPickerItems when the mouse moves
+ document.addEventListener('mousemove', mouseMoveHandler);
+ }, [keyDownHandler, mouseMoveHandler]);
/**
- * Check if its a landscape mode of mobile device
- *
- * @returns {Boolean}
+ * Cleanup all mouse/keydown event listeners that we've set up
*/
- isMobileLandscape() {
- return this.props.isSmallScreenWidth && this.props.windowWidth >= this.props.windowHeight;
- }
+ const cleanupEventHandlers = useCallback(() => {
+ if (!document) {
+ return;
+ }
+
+ document.removeEventListener('keydown', keyDownHandler, true);
+ document.removeEventListener('mousemove', mouseMoveHandler);
+ }, [keyDownHandler, mouseMoveHandler]);
+
+ useEffect(() => {
+ // This callback prop is used by the parent component using the constructor to
+ // get a ref to the inner textInput element e.g. if we do
+ // this.textInput = el} /> this will not
+ // return a ref to the component, but rather the HTML element by default
+ if (shouldFocusInputOnScreenFocus && forwardedRef && _.isFunction(forwardedRef)) {
+ forwardedRef(searchInputRef.current);
+ }
+
+ setupEventHandlers();
+ updateFirstNonHeaderIndex(emojis.current);
+
+ return () => {
+ cleanupEventHandlers();
+ };
+ }, [forwardedRef, shouldFocusInputOnScreenFocus, cleanupEventHandlers, setupEventHandlers]);
+
+ const scrollToHeader = useCallback((headerIndex) => {
+ if (!emojiListRef.current) {
+ return;
+ }
+
+ const calculatedOffset = Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW) * CONST.EMOJI_PICKER_HEADER_HEIGHT;
+ emojiListRef.current.flashScrollIndicators();
+ emojiListRef.current.scrollToOffset({offset: calculatedOffset, animated: true});
+ }, []);
/**
* @param {Number} skinTone
*/
- updatePreferredSkinTone(skinTone) {
- if (this.props.preferredSkinTone === skinTone) {
- return;
- }
+ const updatePreferredSkinTone = useCallback(
+ (skinTone) => {
+ if (Number(preferredSkinTone) === Number(skinTone)) {
+ return;
+ }
- User.updatePreferredSkinTone(skinTone);
- }
+ User.updatePreferredSkinTone(skinTone);
+ },
+ [preferredSkinTone],
+ );
/**
* Return a unique key for each emoji item
@@ -423,9 +409,7 @@ class EmojiPickerMenu extends Component {
* @param {Number} index
* @returns {String}
*/
- keyExtractor(item, index) {
- return `emoji_picker_${item.code}_${index}`;
- }
+ const keyExtractor = useCallback((item, index) => `emoji_picker_${item.code}_${index}`, []);
/**
* Given an emoji item object, render a component based on its type.
@@ -436,112 +420,112 @@ class EmojiPickerMenu extends Component {
* @param {Number} index
* @returns {*}
*/
- renderItem({item, index}) {
- const {code, header, types} = item;
- if (item.spacer) {
- return null;
- }
+ const renderItem = useCallback(
+ ({item, index}) => {
+ const {code, header, types} = item;
+ if (item.spacer) {
+ return null;
+ }
- if (header) {
- return (
-
- {this.props.translate(`emojiPicker.headers.${code}`)}
-
- );
- }
+ if (header) {
+ return (
+
+ {translate(`emojiPicker.headers.${code}`)}
+
+ );
+ }
- const emojiCode = types && types[this.props.preferredSkinTone] ? types[this.props.preferredSkinTone] : code;
+ const emojiCode = types && types[preferredSkinTone] ? types[preferredSkinTone] : code;
- const isEmojiFocused = index === this.state.highlightedIndex && this.state.isUsingKeyboardMovement;
+ const isEmojiFocused = index === highlightedIndex && isUsingKeyboardMovement;
- return (
- this.props.onEmojiSelected(emoji, item)}
- onHoverIn={() => this.setState({highlightedIndex: index, isUsingKeyboardMovement: false})}
- onHoverOut={() => {
- if (this.state.arePointerEventsDisabled) {
- return;
- }
- this.setState({highlightedIndex: -1});
- }}
- emoji={emojiCode}
- onFocus={() => this.setState({highlightedIndex: index})}
- onBlur={() =>
- this.setState((prevState) => ({
+ return (
+ onEmojiSelected(emoji, item)}
+ onHoverIn={() => {
+ if (!isUsingKeyboardMovement) {
+ return;
+ }
+ setIsUsingKeyboardMovement(false);
+ }}
+ emoji={emojiCode}
+ onFocus={() => setHighlightedIndex(index)}
+ onBlur={() =>
// Only clear the highlighted index if the highlighted index is the same,
// meaning that the focus changed to an element that is not an emoji item.
- highlightedIndex: prevState.highlightedIndex === index ? -1 : prevState.highlightedIndex,
- }))
- }
- isFocused={isEmojiFocused}
- isHighlighted={index === this.state.highlightedIndex}
- isUsingKeyboardMovement={this.state.isUsingKeyboardMovement}
- />
- );
- }
-
- render() {
- const isFiltered = this.emojis.length !== this.state.filteredEmojis.length;
- const listStyle = StyleUtils.getEmojiPickerListHeight(isFiltered, this.props.windowHeight);
- const height = !listStyle.maxHeight || listStyle.height < listStyle.maxHeight ? listStyle.height : listStyle.maxHeight;
- const overflowLimit = Math.floor(height / CONST.EMOJI_PICKER_ITEM_HEIGHT) * 8;
- return (
-
-
- (this.searchInput = el)}
- autoFocus={this.shouldFocusInputOnScreenFocus}
- selectTextOnFocus={this.state.selectTextOnFocus}
- onSelectionChange={this.onSelectionChange}
- onFocus={() => this.setState({isFocused: true, highlightedIndex: -1, isUsingKeyboardMovement: false})}
- onBlur={() => this.setState({isFocused: false})}
- autoCorrect={false}
- blurOnSubmit={this.state.filteredEmojis.length > 0}
- />
-
- {!isFiltered && (
-
- )}
- (this.emojiList = el)}
- data={this.state.filteredEmojis}
- renderItem={this.renderItem}
- keyExtractor={this.keyExtractor}
- numColumns={CONST.EMOJI_NUM_PER_ROW}
- style={[
- listStyle,
- // This prevents elastic scrolling when scroll reaches the start or end
- {overscrollBehaviorY: 'contain'},
- // Set overflow to hidden to prevent elastic scrolling when there are not enough contents to scroll in FlatList
- {overflowY: this.state.filteredEmojis.length > overflowLimit ? 'auto' : 'hidden'},
- // Set scrollPaddingTop to consider sticky headers while scrolling
- {scrollPaddingTop: isFiltered ? 0 : CONST.EMOJI_PICKER_ITEM_HEIGHT},
- ]}
- extraData={[this.state.filteredEmojis, this.state.highlightedIndex, this.props.preferredSkinTone]}
- stickyHeaderIndices={this.state.headerIndices}
- getItemLayout={this.getItemLayout}
- contentContainerStyle={styles.flexGrow1}
- ListEmptyComponent={{this.props.translate('common.noResultsFound')}}
+ setHighlightedIndex((prevState) => (prevState === index ? -1 : prevState))
+ }
+ isFocused={isEmojiFocused}
/>
-
+
+ {
+ setHighlightedIndex(-1);
+ setIsFocused(true);
+ setIsUsingKeyboardMovement(false);
+ }}
+ onBlur={() => setIsFocused(false)}
+ autoCorrect={false}
+ blurOnSubmit={filteredEmojis.length > 0}
/>
- );
- }
+ {!isFiltered && (
+
+ )}
+ overflowLimit ? 'auto' : 'hidden'},
+ // Set scrollPaddingTop to consider sticky headers while scrolling
+ {scrollPaddingTop: isFiltered ? 0 : CONST.EMOJI_PICKER_ITEM_HEIGHT},
+ ]}
+ extraData={[filteredEmojis, highlightedIndex, preferredSkinTone]}
+ stickyHeaderIndices={headerIndices}
+ getItemLayout={getItemLayout}
+ contentContainerStyle={styles.flexGrow1}
+ ListEmptyComponent={{translate('common.noResultsFound')}}
+ />
+
+
+ );
}
EmojiPickerMenu.propTypes = propTypes;
diff --git a/src/components/EmojiPicker/EmojiPickerMenuItem/index.js b/src/components/EmojiPicker/EmojiPickerMenuItem/index.js
index b51a8b07537c..c5ca5463aec4 100644
--- a/src/components/EmojiPicker/EmojiPickerMenuItem/index.js
+++ b/src/components/EmojiPicker/EmojiPickerMenuItem/index.js
@@ -27,14 +27,8 @@ const propTypes = {
/** Handles what to do when the pressable is blurred */
onBlur: PropTypes.func,
- /** Whether this menu item is currently highlighted or not */
- isHighlighted: PropTypes.bool,
-
/** Whether this menu item is currently focused or not */
isFocused: PropTypes.bool,
-
- /** Whether the emoji is highlighted by the keyboard/mouse */
- isUsingKeyboardMovement: PropTypes.bool,
};
class EmojiPickerMenuItem extends PureComponent {
@@ -43,6 +37,9 @@ class EmojiPickerMenuItem extends PureComponent {
this.ref = null;
this.focusAndScroll = this.focusAndScroll.bind(this);
+ this.state = {
+ isHovered: false,
+ };
}
componentDidMount() {
@@ -72,15 +69,29 @@ class EmojiPickerMenuItem extends PureComponent {
this.props.onPress(this.props.emoji)}
+ // In order to prevent haptic feedback, pass empty callback as onLongPress props. Please refer https://github.com/necolas/react-native-web/issues/2349#issuecomment-1195564240
+ onLongPress={Browser.isMobileChrome() ? () => {} : undefined}
onPressOut={Browser.isMobile() ? this.props.onHoverOut : undefined}
- onHoverIn={this.props.onHoverIn}
- onHoverOut={this.props.onHoverOut}
+ onHoverIn={() => {
+ if (this.props.onHoverIn) {
+ this.props.onHoverIn();
+ }
+
+ this.setState({isHovered: true});
+ }}
+ onHoverOut={() => {
+ if (this.props.onHoverOut) {
+ this.props.onHoverOut();
+ }
+
+ this.setState({isHovered: false});
+ }}
onFocus={this.props.onFocus}
onBlur={this.props.onBlur}
ref={(ref) => (this.ref = ref)}
style={({pressed}) => [
- this.props.isHighlighted && this.props.isUsingKeyboardMovement ? styles.emojiItemKeyboardHighlighted : {},
- this.props.isHighlighted && !this.props.isUsingKeyboardMovement ? styles.emojiItemHighlighted : {},
+ this.props.isFocused ? styles.emojiItemKeyboardHighlighted : {},
+ this.state.isHovered ? styles.emojiItemHighlighted : {},
Browser.isMobile() && StyleUtils.getButtonBackgroundColorStyle(getButtonState(false, pressed)),
styles.emojiItem,
]}
@@ -95,9 +106,7 @@ class EmojiPickerMenuItem extends PureComponent {
EmojiPickerMenuItem.propTypes = propTypes;
EmojiPickerMenuItem.defaultProps = {
- isHighlighted: false,
isFocused: false,
- isUsingKeyboardMovement: false,
onHoverIn: () => {},
onHoverOut: () => {},
onFocus: () => {},
@@ -106,8 +115,4 @@ EmojiPickerMenuItem.defaultProps = {
// Significantly speeds up re-renders of the EmojiPickerMenu's FlatList
// by only re-rendering at most two EmojiPickerMenuItems that are highlighted/un-highlighted per user action.
-export default React.memo(
- EmojiPickerMenuItem,
- (prevProps, nextProps) =>
- prevProps.isHighlighted === nextProps.isHighlighted && prevProps.emoji === nextProps.emoji && prevProps.isUsingKeyboardMovement === nextProps.isUsingKeyboardMovement,
-);
+export default React.memo(EmojiPickerMenuItem, (prevProps, nextProps) => prevProps.isFocused === nextProps.isFocused && prevProps.emoji === nextProps.emoji);
diff --git a/src/components/Hoverable/index.js b/src/components/Hoverable/index.js
index 5cba52db5a7b..2ded0e52e94d 100644
--- a/src/components/Hoverable/index.js
+++ b/src/components/Hoverable/index.js
@@ -1,197 +1,216 @@
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';
+/**
+ * Maps the children of a Hoverable component to
+ * - a function that is called with the parameter
+ * - the child itself if it is the only child
+ * @param {Array|Function|ReactNode} children - The children to map.
+ * @param {Object} callbackParam - The parameter to pass to the children function.
+ * @returns {ReactNode} The mapped children.
+ */
+function mapChildren(children, callbackParam) {
+ if (_.isArray(children) && children.length === 1) {
+ return children[0];
+ }
+
+ if (_.isFunction(children)) {
+ return children(callbackParam);
+ }
+
+ return children;
+}
+
+/**
+ * Assigns a ref to an element, either by setting the current property of the ref object or by calling the ref function
+ * @param {Object|Function} ref - The ref object or function.
+ * @param {HTMLElement} el - The element to assign the ref to.
+ */
+function assignRef(ref, el) {
+ if (!ref) {
+ return;
+ }
+
+ if (_.has(ref, 'current')) {
+ // eslint-disable-next-line no-param-reassign
+ ref.current = el;
+ }
+
+ if (_.isFunction(ref)) {
+ ref(el);
+ }
+}
+
/**
* 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);
+const Hoverable = React.forwardRef(({disabled, onHoverIn, onHoverOut, onMouseEnter, onMouseLeave, 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);
+ isHoveredRef.current = hovered;
- /**
- * 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;
- });
- }
- }
+ if (shouldHandleScroll && isScrolling.current) {
+ return;
+ }
+ setIsHovered(hovered);
+ },
+ [disabled, shouldHandleScroll],
+ );
+
+ useEffect(() => {
+ const unsetHoveredWhenDocumentIsHidden = () => document.visibilityState === 'hidden' && setIsHovered(false);
+
+ document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
- componentDidUpdate(prevProps) {
- if (prevProps.disabled === this.props.disabled) {
+ return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
+ }, []);
+
+ useEffect(() => {
+ if (!shouldHandleScroll) {
return;
}
- if (this.props.disabled && this.state.isHovered) {
- this.setState({isHovered: false});
- }
- }
+ const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => {
+ isScrolling.current = scrolling;
+ if (!scrolling) {
+ setIsHovered(isHoveredRef.current);
+ }
+ });
- componentWillUnmount() {
- document.removeEventListener('visibilitychange', this.handleVisibilityChange);
- document.removeEventListener('mouseover', this.checkHover);
- if (this.scrollingListener) {
- this.scrollingListener.remove();
- }
- }
+ return () => scrollingListener.remove();
+ }, [shouldHandleScroll]);
- /**
- * 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) {
+ useEffect(() => {
+ if (!DeviceCapabilities.hasHoverSupport()) {
return;
}
/**
- * 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.
+ * Checks the hover state of a component and updates it based on the event target.
+ * This is necessary to handle cases where the hover state might get stuck due to an unreliable mouseleave trigger,
+ * such as when an element is removed before the mouseleave event is triggered.
+ * @param {Event} e - The hover event object.
*/
- this.isHoveredRef = isHovered;
+ const unsetHoveredIfOutside = (e) => {
+ if (!ref.current || !isHovered) {
+ return;
+ }
- /**
- * 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) {
- return;
- }
+ if (ref.current.contains(e.target)) {
+ return;
+ }
- if (isHovered !== this.state.isHovered) {
- this.setState({isHovered}, isHovered ? this.props.onHoverIn : this.props.onHoverOut);
- }
- }
+ setIsHovered(false);
+ };
- /**
- * Checks the hover state of a component and updates it based on the event target.
- * This is necessary to handle cases where the hover state might get stuck due to an unreliable mouseleave trigger,
- * 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) {
- return;
- }
+ document.addEventListener('mouseover', unsetHoveredIfOutside);
- if (this.wrapperView.contains(e.target)) {
- return;
- }
-
- this.setIsHovered(false);
- }
+ return () => document.removeEventListener('mouseover', unsetHoveredIfOutside);
+ }, [isHovered]);
- 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) => {
- if (_.isFunction(this.props.onMouseEnter)) {
- this.props.onMouseEnter(el);
- }
-
- this.setIsHovered(true);
-
- if (_.isFunction(child.props.onMouseEnter)) {
- child.props.onMouseEnter(el);
- }
- },
- onMouseLeave: (el) => {
- if (_.isFunction(this.props.onMouseLeave)) {
- this.props.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 enableHoveredOnMouseEnter = useCallback(
+ (el) => {
+ updateIsHoveredOnScrolling(true);
+
+ if (_.isFunction(onMouseEnter)) {
+ onMouseEnter(el);
+ }
+
+ if (_.isFunction(child.props.onMouseEnter)) {
+ child.props.onMouseEnter(el);
+ }
+ },
+ [child.props, onMouseEnter, updateIsHoveredOnScrolling],
+ );
+
+ const disableHoveredOnMouseLeave = useCallback(
+ (el) => {
+ updateIsHoveredOnScrolling(false);
+
+ if (_.isFunction(onMouseLeave)) {
+ onMouseLeave(el);
+ }
+
+ if (_.isFunction(child.props.onMouseLeave)) {
+ child.props.onMouseLeave(el);
+ }
+ },
+ [child.props, onMouseLeave, updateIsHoveredOnScrolling],
+ );
+
+ const disableHoveredOnBlur = 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: (el) => {
+ ref.current = el;
+ assignRef(child.ref, el);
+ },
+ onMouseEnter: enableHoveredOnMouseEnter,
+ onMouseLeave: disableHoveredOnMouseLeave,
+ onBlur: disableHoveredOnBlur,
+ });
+});
Hoverable.propTypes = propTypes;
Hoverable.defaultProps = defaultProps;
+Hoverable.displayName = 'Hoverable';
export default Hoverable;
diff --git a/src/components/KYCWall/BaseKYCWall.js b/src/components/KYCWall/BaseKYCWall.js
index 1c1552d55844..db3c85ef818c 100644
--- a/src/components/KYCWall/BaseKYCWall.js
+++ b/src/components/KYCWall/BaseKYCWall.js
@@ -1,3 +1,4 @@
+import _ from 'underscore';
import React from 'react';
import {withOnyx} from 'react-native-onyx';
import {Dimensions} from 'react-native';
@@ -123,9 +124,9 @@ class KYCWall extends React.Component {
}
if (!isExpenseReport) {
// Ask the user to upgrade to a gold wallet as this means they have not yet gone through our Know Your Customer (KYC) checks
- const hasGoldWallet = this.props.userWallet.tierName && this.props.userWallet.tierName === CONST.WALLET.TIER_NAME.GOLD;
- if (!hasGoldWallet) {
- Log.info('[KYC Wallet] User does not have gold wallet');
+ const hasActivatedWallet = this.props.userWallet.tierName && _.contains([CONST.WALLET.TIER_NAME.GOLD, CONST.WALLET.TIER_NAME.PLATINUM], this.props.userWallet.tierName);
+ if (!hasActivatedWallet) {
+ Log.info('[KYC Wallet] User does not have active wallet');
Navigation.navigate(this.props.enablePaymentsRoute);
return;
}
diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js
index 17c2ef0c1998..ba035c8b3baf 100644
--- a/src/components/LHNOptionsList/OptionRowLHN.js
+++ b/src/components/LHNOptionsList/OptionRowLHN.js
@@ -186,7 +186,9 @@ function OptionRowLHN(props) {
onSecondaryInteraction={(e) => {
showPopover(e);
// Ensure that we blur the composer when opening context menu, so that only one component is focused at a time
- DomUtils.getActiveElement().blur();
+ if (DomUtils.getActiveElement()) {
+ DomUtils.getActiveElement().blur();
+ }
}}
withoutFocusOnSecondaryInteraction
activeOpacity={0.8}
diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js
index dcaa0273f96a..3a9cc6845194 100644
--- a/src/components/MagicCodeInput.js
+++ b/src/components/MagicCodeInput.js
@@ -103,6 +103,7 @@ function MagicCodeInput(props) {
const [input, setInput] = useState('');
const [focusedIndex, setFocusedIndex] = useState(0);
const [editIndex, setEditIndex] = useState(0);
+ const [wasSubmitted, setWasSubmitted] = useState(false);
const blurMagicCodeInput = () => {
inputRefs.current[editIndex].blur();
@@ -124,9 +125,12 @@ function MagicCodeInput(props) {
const validateAndSubmit = () => {
const numbers = decomposeString(props.value, props.maxLength);
- if (!props.shouldSubmitOnComplete || _.filter(numbers, (n) => ValidationUtils.isNumeric(n)).length !== props.maxLength || props.network.isOffline) {
+ if (wasSubmitted || !props.shouldSubmitOnComplete || _.filter(numbers, (n) => ValidationUtils.isNumeric(n)).length !== props.maxLength || props.network.isOffline) {
return;
}
+ if (!wasSubmitted) {
+ setWasSubmitted(true);
+ }
// Blurs the input and removes focus from the last input and, if it should submit
// on complete, it will call the onFulfill callback.
blurMagicCodeInput();
diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js
index 6c41290f1d17..16181ee00abb 100644
--- a/src/components/MenuItem.js
+++ b/src/components/MenuItem.js
@@ -79,6 +79,7 @@ const defaultProps = {
shouldRenderAsHTML: false,
rightComponent: undefined,
shouldShowRightComponent: false,
+ shouldCheckActionAllowedOnPress: true,
};
const MenuItem = React.forwardRef((props, ref) => {
@@ -117,39 +118,42 @@ const MenuItem = React.forwardRef((props, ref) => {
return;
}
const parser = new ExpensiMark();
- setHtml(parser.replace(convertToLTR(props.title)));
+ setHtml(parser.replace(props.title));
titleRef.current = props.title;
}, [props.title, props.shouldParseTitle]);
const getProcessedTitle = useMemo(() => {
+ let title = '';
if (props.shouldRenderAsHTML) {
- return convertToLTR(props.title);
+ title = convertToLTR(props.title);
}
if (props.shouldParseTitle) {
- return html;
+ title = html;
}
- return '';
+ return title ? `${title}` : '';
}, [props.title, props.shouldRenderAsHTML, props.shouldParseTitle, html]);
const hasPressableRightComponent = props.iconRight || (props.rightComponent && props.shouldShowRightComponent);
+ const onPressAction = (e) => {
+ if (props.disabled || !props.interactive) {
+ return;
+ }
+
+ if (e && e.type === 'click') {
+ e.currentTarget.blur();
+ }
+
+ props.onPress(e);
+ };
+
return (
{(isHovered) => (
{
- if (props.disabled || !props.interactive) {
- return;
- }
-
- if (e && e.type === 'click') {
- e.currentTarget.blur();
- }
-
- props.onPress(e);
- }, props.isAnonymousAction)}
+ onPress={props.shouldCheckActionAllowedOnPress ? Session.checkIfActionIsAllowed(onPressAction, props.isAnonymousAction) : onPressAction}
onPressIn={() => props.shouldBlockSelection && isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()}
onPressOut={ControlSelection.unblock}
onSecondaryInteraction={props.onSecondaryInteraction}
diff --git a/src/components/MoneyReportHeader.js b/src/components/MoneyReportHeader.js
index 6b2b4e16db65..49681f396181 100644
--- a/src/components/MoneyReportHeader.js
+++ b/src/components/MoneyReportHeader.js
@@ -62,7 +62,7 @@ const defaultProps = {
function MoneyReportHeader({session, personalDetails, policy, chatReport, report: moneyRequestReport, isSmallScreenWidth}) {
const {translate} = useLocalize();
- const reportTotal = ReportUtils.getMoneyRequestTotal(moneyRequestReport);
+ const reimbursableTotal = ReportUtils.getMoneyRequestReimbursableTotal(moneyRequestReport);
const isApproved = ReportUtils.isReportApproved(moneyRequestReport);
const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID);
const policyType = lodashGet(policy, 'type');
@@ -71,8 +71,8 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, report
const isPayer = policyType === CONST.POLICY.TYPE.CORPORATE ? isPolicyAdmin && isApproved : isPolicyAdmin || (ReportUtils.isMoneyRequestReport(moneyRequestReport) && isManager);
const isDraft = ReportUtils.isReportDraft(moneyRequestReport);
const shouldShowSettlementButton = useMemo(
- () => isPayer && !isDraft && !isSettled && !moneyRequestReport.isWaitingOnBankAccount && reportTotal !== 0 && !ReportUtils.isArchivedRoom(chatReport),
- [isPayer, isDraft, isSettled, moneyRequestReport, reportTotal, chatReport],
+ () => isPayer && !isDraft && !isSettled && !moneyRequestReport.isWaitingOnBankAccount && reimbursableTotal !== 0 && !ReportUtils.isArchivedRoom(chatReport),
+ [isPayer, isDraft, isSettled, moneyRequestReport, reimbursableTotal, chatReport],
);
const shouldShowApproveButton = useMemo(() => {
if (policyType !== CONST.POLICY.TYPE.CORPORATE) {
@@ -80,10 +80,10 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, report
}
return isManager && !isDraft && !isApproved && !isSettled;
}, [policyType, isManager, isDraft, isApproved, isSettled]);
- const shouldShowSubmitButton = isDraft && reportTotal !== 0;
+ const shouldShowSubmitButton = isDraft && reimbursableTotal !== 0;
const shouldShowAnyButton = shouldShowSettlementButton || shouldShowApproveButton || shouldShowSubmitButton;
const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport);
- const formattedAmount = CurrencyUtils.convertToDisplayString(reportTotal, moneyRequestReport.currency);
+ const formattedAmount = CurrencyUtils.convertToDisplayString(reimbursableTotal, moneyRequestReport.currency);
return (
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index 42fa1db48220..fefacc385116 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -536,8 +536,7 @@ function MoneyRequestConfirmationList(props) {
);
}, [confirm, props.bankAccountRoute, props.iouCurrencyCode, props.iouType, props.isReadOnly, props.policyID, selectedParticipants, splitOrRequestOptions, translate, formError]);
- const {image: receiptImage, thumbnail: receiptThumbnail} =
- props.receiptPath && props.receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(props.receiptPath, props.receiptFilename) : {};
+ const {image: receiptImage, thumbnail: receiptThumbnail} = props.receiptPath && props.receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction) : {};
return (
{},
};
@@ -46,16 +47,15 @@ class CalendarPicker extends React.PureComponent {
if (props.minDate >= props.maxDate) {
throw new Error('Minimum date cannot be greater than the maximum date.');
}
-
- let currentDateView = moment(props.value, CONST.DATE.MOMENT_FORMAT_STRING).toDate();
+ let currentDateView = new Date(props.value);
if (props.maxDate < currentDateView) {
currentDateView = props.maxDate;
} else if (props.minDate > currentDateView) {
currentDateView = props.minDate;
}
- const minYear = moment(this.props.minDate).year();
- const maxYear = moment(this.props.maxDate).year();
+ const minYear = getYear(new Date(this.props.minDate));
+ const maxYear = getYear(new Date(this.props.maxDate));
this.state = {
currentDateView,
@@ -79,7 +79,7 @@ class CalendarPicker extends React.PureComponent {
onYearSelected(year) {
this.setState((prev) => {
- const newCurrentDateView = moment(prev.currentDateView).set('year', year).toDate();
+ const newCurrentDateView = setYear(new Date(prev.currentDateView), year);
return {
currentDateView: newCurrentDateView,
@@ -99,9 +99,9 @@ class CalendarPicker extends React.PureComponent {
onDayPressed(day) {
this.setState(
(prev) => ({
- currentDateView: moment(prev.currentDateView).set('date', day).toDate(),
+ currentDateView: setDate(new Date(prev.currentDateView), day),
}),
- () => this.props.onSelected(moment(this.state.currentDateView).format('YYYY-MM-DD')),
+ () => this.props.onSelected(format(new Date(this.state.currentDateView), CONST.DATE.FNS_FORMAT_STRING)),
);
}
@@ -109,24 +109,24 @@ class CalendarPicker extends React.PureComponent {
* Handles the user pressing the previous month arrow of the calendar picker.
*/
moveToPrevMonth() {
- this.setState((prev) => ({currentDateView: moment(prev.currentDateView).subtract(1, 'months').toDate()}));
+ this.setState((prev) => ({currentDateView: subMonths(new Date(prev.currentDateView), 1)}));
}
/**
* Handles the user pressing the next month arrow of the calendar picker.
*/
moveToNextMonth() {
- this.setState((prev) => ({currentDateView: moment(prev.currentDateView).add(1, 'months').toDate()}));
+ this.setState((prev) => ({currentDateView: addMonths(new Date(prev.currentDateView), 1)}));
}
render() {
- const monthNames = _.map(moment.localeData(this.props.preferredLocale).months(), Str.recapitalize);
- const daysOfWeek = _.map(moment.localeData(this.props.preferredLocale).weekdays(), (day) => day.toUpperCase());
+ const monthNames = _.map(DateUtils.getMonthNames(this.props.preferredLocale), Str.recapitalize);
+ const daysOfWeek = _.map(DateUtils.getDaysOfWeek(this.props.preferredLocale), (day) => day.toUpperCase());
const currentMonthView = this.state.currentDateView.getMonth();
const currentYearView = this.state.currentDateView.getFullYear();
const calendarDaysMatrix = generateMonthMatrix(currentYearView, currentMonthView);
- const hasAvailableDatesNextMonth = moment(this.props.maxDate).endOf('month').endOf('day') >= moment(this.state.currentDateView).add(1, 'months');
- const hasAvailableDatesPrevMonth = moment(this.props.minDate).startOf('month').startOf('day') <= moment(this.state.currentDateView).subtract(1, 'months');
+ const hasAvailableDatesNextMonth = startOfDay(endOfMonth(new Date(this.props.maxDate))) > addMonths(new Date(this.state.currentDateView), 1);
+ const hasAvailableDatesPrevMonth = startOfDay(new Date(this.props.minDate)) < endOfMonth(subMonths(new Date(this.state.currentDateView), 1));
return (
@@ -201,11 +201,11 @@ class CalendarPicker extends React.PureComponent {
style={styles.flexRow}
>
{_.map(week, (day, index) => {
- const currentDate = moment([currentYearView, currentMonthView, day]);
- const isBeforeMinDate = currentDate < moment(this.props.minDate).startOf('day');
- const isAfterMaxDate = currentDate > moment(this.props.maxDate).startOf('day');
+ const currentDate = new Date(currentYearView, currentMonthView, day);
+ const isBeforeMinDate = currentDate < startOfDay(new Date(this.props.minDate));
+ const isAfterMaxDate = currentDate > startOfDay(new Date(this.props.maxDate));
const isDisabled = !day || isBeforeMinDate || isAfterMaxDate;
- const isSelected = moment(this.props.value).isSame(moment([currentYearView, currentMonthView, day]), 'day');
+ const isSelected = isSameDay(new Date(this.props.value), new Date(currentYearView, currentMonthView, day));
return (
-
-
-
-
-
+ {props.canDismissError && (
+
+
+
+
+
+ )}
)}
diff --git a/src/components/OnyxProvider.js b/src/components/OnyxProvider.tsx
similarity index 91%
rename from src/components/OnyxProvider.js
rename to src/components/OnyxProvider.tsx
index 380328cf8137..3bd4ca52c3be 100644
--- a/src/components/OnyxProvider.js
+++ b/src/components/OnyxProvider.tsx
@@ -1,12 +1,11 @@
import React from 'react';
-import PropTypes from 'prop-types';
import ONYXKEYS from '../ONYXKEYS';
import createOnyxContext from './createOnyxContext';
import ComposeProviders from './ComposeProviders';
// Set up any providers for individual keys. This should only be used in cases where many components will subscribe to
// the same key (e.g. FlatList renderItem components)
-const [withNetwork, NetworkProvider, NetworkContext] = createOnyxContext(ONYXKEYS.NETWORK, {});
+const [withNetwork, NetworkProvider, NetworkContext] = createOnyxContext(ONYXKEYS.NETWORK);
const [withPersonalDetails, PersonalDetailsProvider] = createOnyxContext(ONYXKEYS.PERSONAL_DETAILS_LIST);
const [withCurrentDate, CurrentDateProvider] = createOnyxContext(ONYXKEYS.CURRENT_DATE);
const [withReportActionsDrafts, ReportActionsDraftsProvider] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS);
@@ -15,12 +14,12 @@ const [withBetas, BetasProvider, BetasContext] = createOnyxContext(ONYXKEYS.BETA
const [withReportCommentDrafts, ReportCommentDraftsProvider] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT);
const [withPreferredTheme, PreferredThemeProvider, PreferredThemeContext] = createOnyxContext(ONYXKEYS.PREFERRED_THEME);
-const propTypes = {
+type OnyxProviderProps = {
/** Rendered child component */
- children: PropTypes.node.isRequired,
+ children: React.ReactNode;
};
-function OnyxProvider(props) {
+function OnyxProvider(props: OnyxProviderProps) {
return (
{
if (isPasswordInvalid) {
return translate('attachmentView.passwordIncorrect');
@@ -67,7 +69,19 @@ function PDFPasswordForm({isFocused, isPasswordInvalid, shouldShowLoadingIndicat
if (!textInputRef.current) {
return;
}
- textInputRef.current.focus();
+ /**
+ * We recommend using setTimeout to wait for the animation to finish and then focus on the input
+ * Relevant thread: https://expensify.slack.com/archives/C01GTK53T8Q/p1694660990479979
+ */
+ focusTimeoutRef.current = setTimeout(() => {
+ textInputRef.current.focus();
+ }, CONST.ANIMATED_TRANSITION);
+ return () => {
+ if (!focusTimeoutRef.current) {
+ return;
+ }
+ clearTimeout(focusTimeoutRef.current);
+ };
}, [isFocused]);
const updatePassword = (newPassword) => {
diff --git a/src/components/PopoverMenu/index.js b/src/components/PopoverMenu/index.js
index 4cdc7a5a4f47..f0b9b7448e3f 100644
--- a/src/components/PopoverMenu/index.js
+++ b/src/components/PopoverMenu/index.js
@@ -100,6 +100,7 @@ function PopoverMenu(props) {
iconHeight={item.iconHeight}
iconFill={item.iconFill}
title={item.text}
+ shouldCheckActionAllowedOnPress={false}
description={item.description}
onPress={() => selectItem(menuIndex)}
focused={focusedIndex === menuIndex}
diff --git a/src/components/PopoverWithoutOverlay/index.js b/src/components/PopoverWithoutOverlay/index.js
index 3b194ad4b9cf..d35637958f1d 100644
--- a/src/components/PopoverWithoutOverlay/index.js
+++ b/src/components/PopoverWithoutOverlay/index.js
@@ -8,6 +8,7 @@ import styles from '../../styles/styles';
import * as StyleUtils from '../../styles/StyleUtils';
import getModalStyles from '../../styles/getModalStyles';
import withWindowDimensions from '../withWindowDimensions';
+import usePrevious from '../../hooks/usePrevious';
function Popover(props) {
const {onOpen, close} = React.useContext(PopoverContext);
@@ -24,6 +25,8 @@ function Popover(props) {
props.outerStyle,
);
+ const prevIsVisible = usePrevious(props.isVisible);
+
React.useEffect(() => {
if (props.isVisible) {
props.onModalShow();
@@ -40,7 +43,7 @@ function Popover(props) {
Modal.willAlertModalBecomeVisible(props.isVisible);
// We prevent setting closeModal function to null when the component is invisible the first time it is rendered
- if (!firstRenderRef.current || !props.isVisible) {
+ if (prevIsVisible === props.isVisible && (!firstRenderRef.current || !props.isVisible)) {
firstRenderRef.current = false;
return;
}
@@ -49,7 +52,7 @@ function Popover(props) {
// We want this effect to run strictly ONLY when isVisible prop changes
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [props.isVisible]);
+ }, [props.isVisible, prevIsVisible]);
if (!props.isVisible) {
return null;
diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.js b/src/components/Pressable/GenericPressable/BaseGenericPressable.js
index 79ce5629c9e9..24d81f59f4f8 100644
--- a/src/components/Pressable/GenericPressable/BaseGenericPressable.js
+++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.js
@@ -5,7 +5,6 @@ import _ from 'underscore';
import Accessibility from '../../../libs/Accessibility';
import HapticFeedback from '../../../libs/HapticFeedback';
import KeyboardShortcut from '../../../libs/KeyboardShortcut';
-import * as Browser from '../../../libs/Browser';
import styles from '../../../styles/styles';
import genericPressablePropTypes from './PropTypes';
import CONST from '../../../CONST';
@@ -129,15 +128,13 @@ const GenericPressable = forwardRef((props, ref) => {
return KeyboardShortcut.subscribe(shortcutKey, onPressHandler, descriptionKey, modifiers, true, false, 0, false);
}, [keyboardShortcut, onPressHandler]);
- const defaultLongPressHandler = Browser.isMobileChrome() ? () => {} : undefined;
return (
-
+
{translate('common.total')}
@@ -59,10 +67,50 @@ function MoneyReportView(props) {
numberOfLines={1}
style={[styles.taskTitleMenuItem, styles.alignSelfCenter]}
>
- {formattedAmount}
+ {formattedTotalAmount}
+ {shouldShowBreakdown ? (
+ <>
+
+
+
+ {translate('cardTransactions.outOfPocket')}
+
+
+
+
+ {formattedOutOfPocketAmount}
+
+
+
+
+
+
+ {translate('cardTransactions.companySpend')}
+
+
+
+
+ {formattedCompanySpendAmount}
+
+
+
+ >
+ ) : undefined}
{
if (isExpensifyCardTransaction) {
diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js
index 289cd70c3332..707ef419d8b3 100644
--- a/src/components/ReportActionItem/MoneyRequestView.js
+++ b/src/components/ReportActionItem/MoneyRequestView.js
@@ -151,7 +151,7 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should
let receiptURIs;
let hasErrors = false;
if (hasReceipt) {
- receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(transaction.receipt.source, transaction.filename);
+ receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(transaction);
hasErrors = canEdit && TransactionUtils.hasMissingSmartscanFields(transaction);
}
@@ -170,6 +170,7 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should
diff --git a/src/components/ReportActionItem/ReportActionItemImage.js b/src/components/ReportActionItem/ReportActionItemImage.js
index 98bdede0fe26..f17a1f1929fe 100644
--- a/src/components/ReportActionItem/ReportActionItemImage.js
+++ b/src/components/ReportActionItem/ReportActionItemImage.js
@@ -1,5 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
+import {View} from 'react-native';
+import _ from 'underscore';
import styles from '../../styles/styles';
import Image from '../Image';
import ThumbnailImage from '../ThumbnailImage';
@@ -10,6 +12,9 @@ import {ShowContextMenuContext} from '../ShowContextMenuContext';
import Navigation from '../../libs/Navigation/Navigation';
import PressableWithoutFocus from '../Pressable/PressableWithoutFocus';
import useLocalize from '../../hooks/useLocalize';
+import EReceiptThumbnail from '../EReceiptThumbnail';
+import transactionPropTypes from '../transactionPropTypes';
+import * as TransactionUtils from '../../libs/TransactionUtils';
const propTypes = {
/** thumbnail URI for the image */
@@ -20,10 +25,14 @@ const propTypes = {
/** whether or not to enable the image preview modal */
enablePreviewModal: PropTypes.bool,
+
+ /* The transaction associated with this image, if any. Passed for handling eReceipts. */
+ transaction: transactionPropTypes,
};
const defaultProps = {
thumbnail: null,
+ transaction: {},
enablePreviewModal: false,
};
@@ -33,24 +42,37 @@ const defaultProps = {
* and optional preview modal as well.
*/
-function ReportActionItemImage({thumbnail, image, enablePreviewModal}) {
+function ReportActionItemImage({thumbnail, image, enablePreviewModal, transaction}) {
const {translate} = useLocalize();
const imageSource = tryResolveUrlFromApiRoot(image || '');
const thumbnailSource = tryResolveUrlFromApiRoot(thumbnail || '');
+ const isEReceipt = !_.isEmpty(transaction) && TransactionUtils.hasEReceipt(transaction);
+
+ let receiptImageComponent;
- const receiptImageComponent = thumbnail ? (
-
- ) : (
-
- );
+ if (isEReceipt) {
+ receiptImageComponent = (
+
+
+
+ );
+ } else if (thumbnail) {
+ receiptImageComponent = (
+
+ );
+ } else {
+ receiptImageComponent = (
+
+ );
+ }
if (enablePreviewModal) {
return (
diff --git a/src/components/ReportActionItem/ReportActionItemImages.js b/src/components/ReportActionItem/ReportActionItemImages.js
index ce49f519df66..bd1ee6d45a07 100644
--- a/src/components/ReportActionItem/ReportActionItemImages.js
+++ b/src/components/ReportActionItem/ReportActionItemImages.js
@@ -7,6 +7,7 @@ import Text from '../Text';
import ReportActionItemImage from './ReportActionItemImage';
import * as StyleUtils from '../../styles/StyleUtils';
import variables from '../../styles/variables';
+import transactionPropTypes from '../transactionPropTypes';
const propTypes = {
/** array of image and thumbnail URIs */
@@ -14,6 +15,7 @@ const propTypes = {
PropTypes.shape({
thumbnail: PropTypes.string,
image: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ transaction: transactionPropTypes,
}),
).isRequired,
@@ -47,8 +49,10 @@ const defaultProps = {
*/
function ReportActionItemImages({images, size, total, isHovered}) {
- const numberOfShownImages = size || images.length;
- const shownImages = images.slice(0, size);
+ // Calculate the number of images to be shown, limited by the value of 'size' (if defined)
+ // or the total number of images.
+ const numberOfShownImages = Math.min(size || images.length, images.length);
+ const shownImages = images.slice(0, numberOfShownImages);
const remaining = (total || images.length) - size;
const MAX_REMAINING = 9;
@@ -66,7 +70,7 @@ function ReportActionItemImages({images, size, total, isHovered}) {
return (
- {_.map(shownImages, ({thumbnail, image}, index) => {
+ {_.map(shownImages, ({thumbnail, image, transaction}, index) => {
const isLastImage = index === numberOfShownImages - 1;
// Show a border to separate multiple images. Shown to the right for each except the last.
@@ -80,6 +84,7 @@ function ReportActionItemImages({images, size, total, isHovered}) {
{isLastImage && remaining > 0 && (
diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js
index 0ddd8098f460..2147f0a4362e 100644
--- a/src/components/ReportActionItem/ReportPreview.js
+++ b/src/components/ReportActionItem/ReportPreview.js
@@ -111,7 +111,7 @@ function ReportPreview(props) {
const managerID = props.iouReport.managerID || 0;
const isCurrentUserManager = managerID === lodashGet(props.session, 'accountID');
- const reportTotal = ReportUtils.getMoneyRequestTotal(props.iouReport);
+ const {totalDisplaySpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(props.iouReport);
const iouSettled = ReportUtils.isSettled(props.iouReportID);
const iouCanceled = ReportUtils.isArchivedRoom(props.chatReport);
@@ -125,8 +125,8 @@ function ReportPreview(props) {
const hasReceipts = transactionsWithReceipts.length > 0;
const isScanning = hasReceipts && ReportUtils.areAllRequestsBeingSmartScanned(props.iouReportID, props.action);
const hasErrors = hasReceipts && ReportUtils.hasMissingSmartscanFields(props.iouReportID);
- const lastThreeTransactionsWithReceipts = ReportUtils.getReportPreviewDisplayTransactions(props.action);
- const lastThreeReceipts = _.map(lastThreeTransactionsWithReceipts, ({receipt, filename}) => ReceiptUtils.getThumbnailAndImageURIs(receipt.source, filename || ''));
+ const lastThreeTransactionsWithReceipts = transactionsWithReceipts.slice(-3);
+ const lastThreeReceipts = _.map(lastThreeTransactionsWithReceipts, (transaction) => ReceiptUtils.getThumbnailAndImageURIs(transaction));
const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(props.iouReportID);
const hasOnlyOneReceiptRequest = numberOfRequests === 1 && hasReceipts;
const previewSubtitle = hasOnlyOneReceiptRequest
@@ -136,11 +136,11 @@ function ReportPreview(props) {
scanningReceipts: numberOfScanningReceipts,
});
- const shouldShowSubmitButton = isReportDraft && reportTotal !== 0;
+ const shouldShowSubmitButton = isReportDraft && reimbursableSpend !== 0;
const getDisplayAmount = () => {
- if (reportTotal) {
- return CurrencyUtils.convertToDisplayString(reportTotal, props.iouReport.currency);
+ if (totalDisplaySpend) {
+ return CurrencyUtils.convertToDisplayString(totalDisplaySpend, props.iouReport.currency);
}
if (isScanning) {
return props.translate('iou.receiptScanning');
@@ -176,7 +176,7 @@ function ReportPreview(props) {
const bankAccountRoute = ReportUtils.getBankAccountRoute(props.chatReport);
const shouldShowSettlementButton = ReportUtils.isControlPolicyExpenseChat(props.chatReport)
? props.policy.role === CONST.POLICY.ROLE.ADMIN && ReportUtils.isReportApproved(props.iouReport) && !iouSettled && !iouCanceled
- : !_.isEmpty(props.iouReport) && isCurrentUserManager && !isReportDraft && !iouSettled && !iouCanceled && !props.iouReport.isWaitingOnBankAccount && reportTotal !== 0;
+ : !_.isEmpty(props.iouReport) && isCurrentUserManager && !isReportDraft && !iouSettled && !iouCanceled && !props.iouReport.isWaitingOnBankAccount && reimbursableSpend !== 0;
return (
@@ -195,9 +195,9 @@ function ReportPreview(props) {
{hasReceipts && (
)}
@@ -241,7 +241,7 @@ function ReportPreview(props) {
onPress={(paymentType) => IOU.payMoneyRequest(paymentType, props.chatReport, props.iouReport)}
enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS}
addBankAccountRoute={bankAccountRoute}
- style={[styles.requestPreviewBox]}
+ style={[styles.mt3]}
anchorAlignment={{
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
@@ -253,7 +253,7 @@ function ReportPreview(props) {
medium
success={props.chatReport.isOwnPolicyExpenseChat}
text={translate('common.submit')}
- style={styles.requestPreviewBox}
+ style={styles.mt3}
onPress={() => IOU.submitReport(props.iouReport)}
/>
)}
diff --git a/src/components/TagPicker/index.js b/src/components/TagPicker/index.js
index 8e7cf11f7e5a..05eca664bd0f 100644
--- a/src/components/TagPicker/index.js
+++ b/src/components/TagPicker/index.js
@@ -53,7 +53,7 @@ function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubm
[searchValue, selectedOptions, policyTagList, policyRecentlyUsedTagsList],
);
- const headerMessage = OptionsListUtils.getHeaderMessage(lodashGet(sections, '[0].data.length', 0) > 0, false, '');
+ const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(lodashGet(sections, '[0].data.length', 0) > 0, '');
return (
{
- if (!isRendered) {
- setIsRendered(true);
- }
-
+ setIsRendered(true);
setIsVisible(true);
animation.current.stopAnimation();
@@ -109,7 +106,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent,
});
}
TooltipSense.activate();
- }, [isRendered]);
+ }, []);
// eslint-disable-next-line rulesdir/prefer-early-return
useEffect(() => {
@@ -130,11 +127,17 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent,
if (bounds.width === 0) {
setIsRendered(false);
}
+ if (!target.current) {
+ return;
+ }
// Choose a bounding box for the tooltip to target.
// In the case when the target is a link that has wrapped onto
// multiple lines, we want to show the tooltip over the part
// of the link that the user is hovering over.
const betterBounds = chooseBoundingBox(target.current, initialMousePosition.current.x, initialMousePosition.current.y);
+ if (!betterBounds) {
+ return;
+ }
setWrapperWidth(betterBounds.width);
setWrapperHeight(betterBounds.height);
setXOffset(betterBounds.x);
@@ -144,7 +147,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent,
/**
* Hide the tooltip in an animation.
*/
- const hideTooltip = () => {
+ const hideTooltip = useCallback(() => {
animation.current.stopAnimation();
if (TooltipSense.isActive() && !isTooltipSenseInitiator.current) {
@@ -162,7 +165,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent,
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
diff --git a/src/components/UserCurrentLocationButton.js b/src/components/UserCurrentLocationButton.js
deleted file mode 100644
index fa22eb602886..000000000000
--- a/src/components/UserCurrentLocationButton.js
+++ /dev/null
@@ -1,114 +0,0 @@
-import PropTypes from 'prop-types';
-import React, {useEffect, useRef, useState} from 'react';
-import {Text} from 'react-native';
-import getCurrentPosition from '../libs/getCurrentPosition';
-import styles from '../styles/styles';
-import Icon from './Icon';
-import * as Expensicons from './Icon/Expensicons';
-import LocationErrorMessage from './LocationErrorMessage';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-import colors from '../styles/colors';
-import PressableWithFeedback from './Pressable/PressableWithFeedback';
-
-const propTypes = {
- /** Callback that runs when location data is fetched */
- onLocationFetched: PropTypes.func.isRequired,
-
- /** Callback that runs when fetching location has errors */
- onLocationError: PropTypes.func,
-
- /** Callback that runs when location button is clicked */
- onClick: PropTypes.func,
-
- /** Boolean to indicate if the button is clickable */
- isDisabled: PropTypes.bool,
-
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- isDisabled: false,
- onLocationError: () => {},
- onClick: () => {},
-};
-
-function UserCurrentLocationButton({onLocationFetched, onLocationError, onClick, isDisabled, translate}) {
- const isFetchingLocation = useRef(false);
- const shouldTriggerCallbacks = useRef(true);
- const [locationErrorCode, setLocationErrorCode] = useState(null);
-
- /** Gets the user's current location and registers success/error callbacks */
- const getUserLocation = () => {
- if (isFetchingLocation.current) {
- return;
- }
-
- isFetchingLocation.current = true;
-
- onClick();
-
- getCurrentPosition(
- (successData) => {
- isFetchingLocation.current = false;
- if (!shouldTriggerCallbacks.current) {
- return;
- }
-
- setLocationErrorCode(null);
- onLocationFetched(successData);
- },
- (errorData) => {
- isFetchingLocation.current = false;
- if (!shouldTriggerCallbacks.current) {
- return;
- }
-
- setLocationErrorCode(errorData.code);
- onLocationError(errorData);
- },
- {
- maximumAge: 0, // No cache, always get fresh location info
- timeout: 5000,
- },
- );
- };
-
- // eslint-disable-next-line arrow-body-style
- useEffect(() => {
- return () => {
- // If the component unmounts we don't want any of the callback for geolocation to run.
- shouldTriggerCallbacks.current = false;
- };
- }, []);
-
- return (
- <>
- e.preventDefault()}
- onTouchStart={(e) => e.preventDefault()}
- >
-
- {translate('location.useCurrent')}
-
- setLocationErrorCode(null)}
- locationErrorCode={locationErrorCode}
- />
- >
- );
-}
-
-UserCurrentLocationButton.displayName = 'UserCurrentLocationButton';
-UserCurrentLocationButton.propTypes = propTypes;
-UserCurrentLocationButton.defaultProps = defaultProps;
-
-// This components gets used inside , we are using an HOC (withLocalize) as function components with
-// hooks give hook errors when nested inside .
-export default withLocalize(UserCurrentLocationButton);
diff --git a/src/components/createOnyxContext.js b/src/components/createOnyxContext.js
deleted file mode 100644
index 3dbc07a7032e..000000000000
--- a/src/components/createOnyxContext.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import React, {createContext, forwardRef} from 'react';
-import PropTypes from 'prop-types';
-import {withOnyx} from 'react-native-onyx';
-import Str from 'expensify-common/lib/str';
-import getComponentDisplayName from '../libs/getComponentDisplayName';
-
-const propTypes = {
- /** Rendered child component */
- children: PropTypes.node.isRequired,
-};
-
-export default (onyxKeyName, defaultValue) => {
- const Context = createContext();
- function Provider(props) {
- return {props.children};
- }
-
- Provider.propTypes = propTypes;
- Provider.displayName = `${Str.UCFirst(onyxKeyName)}Provider`;
-
- // eslint-disable-next-line rulesdir/onyx-props-must-have-default
- const ProviderWithOnyx = withOnyx({
- [onyxKeyName]: {
- key: onyxKeyName,
- },
- })(Provider);
-
- const withOnyxKey =
- ({propName = onyxKeyName, transformValue} = {}) =>
- (WrappedComponent) => {
- const Consumer = forwardRef((props, ref) => (
-
- {(value) => {
- const propsToPass = {
- ...props,
- [propName]: transformValue ? transformValue(value, props) : value,
- };
-
- if (propsToPass[propName] === undefined && defaultValue) {
- propsToPass[propName] = defaultValue;
- }
- return (
-
- );
- }}
-
- ));
-
- Consumer.displayName = `with${Str.UCFirst(onyxKeyName)}(${getComponentDisplayName(WrappedComponent)})`;
- return Consumer;
- };
-
- return [withOnyxKey, ProviderWithOnyx, Context];
-};
diff --git a/src/components/createOnyxContext.tsx b/src/components/createOnyxContext.tsx
new file mode 100644
index 000000000000..d142e551012f
--- /dev/null
+++ b/src/components/createOnyxContext.tsx
@@ -0,0 +1,81 @@
+import React, {ComponentType, ForwardRefExoticComponent, ForwardedRef, PropsWithoutRef, ReactNode, RefAttributes, createContext, forwardRef} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import Str from 'expensify-common/lib/str';
+import getComponentDisplayName from '../libs/getComponentDisplayName';
+import {OnyxCollectionKey, OnyxKey, OnyxKeyValue, OnyxValues} from '../ONYXKEYS';
+import ChildrenProps from '../types/utils/ChildrenProps';
+
+type OnyxKeys = (OnyxKey | OnyxCollectionKey) & keyof OnyxValues;
+
+// Provider types
+type ProviderOnyxProps = Record>;
+
+type ProviderPropsWithOnyx = ChildrenProps & ProviderOnyxProps;
+
+// withOnyxKey types
+type WithOnyxKeyProps = {
+ propName?: TOnyxKey | TNewOnyxKey;
+ // It's not possible to infer the type of props of the wrapped component, so we have to use `any` here
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ transformValue?: (value: OnyxKeyValue, props: any) => TTransformedValue;
+};
+
+type WrapComponentWithConsumer = , TRef>(
+ WrappedComponent: ComponentType>,
+) => ForwardRefExoticComponent> & RefAttributes>;
+
+type WithOnyxKey = >(
+ props?: WithOnyxKeyProps,
+) => WrapComponentWithConsumer;
+
+// createOnyxContext return type
+type CreateOnyxContext = [WithOnyxKey, ComponentType, TOnyxKey>>, React.Context>];
+
+export default (onyxKeyName: TOnyxKey): CreateOnyxContext => {
+ const Context = createContext>(null);
+ function Provider(props: ProviderPropsWithOnyx): ReactNode {
+ return {props.children};
+ }
+
+ Provider.displayName = `${Str.UCFirst(onyxKeyName)}Provider`;
+
+ const ProviderWithOnyx = withOnyx, ProviderOnyxProps>({
+ [onyxKeyName]: {
+ key: onyxKeyName,
+ },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as Record)(Provider);
+
+ function withOnyxKey>({
+ propName,
+ transformValue,
+ }: WithOnyxKeyProps = {}) {
+ return , TRef>(WrappedComponent: ComponentType>) => {
+ function Consumer(props: Omit, ref: ForwardedRef): ReactNode {
+ return (
+
+ {(value) => {
+ const propsToPass = {
+ ...props,
+ [propName ?? onyxKeyName]: transformValue ? transformValue(value, props) : value,
+ } as TProps;
+
+ return (
+
+ );
+ }}
+
+ );
+ }
+
+ Consumer.displayName = `with${Str.UCFirst(onyxKeyName)}(${getComponentDisplayName(WrappedComponent)})`;
+ return forwardRef(Consumer);
+ };
+ }
+
+ return [withOnyxKey, ProviderWithOnyx, Context];
+};
diff --git a/src/components/menuItemPropTypes.js b/src/components/menuItemPropTypes.js
index e33170ac67f4..a5b5b3a8eba8 100644
--- a/src/components/menuItemPropTypes.js
+++ b/src/components/menuItemPropTypes.js
@@ -153,6 +153,9 @@ const propTypes = {
/** Should render component on the right */
shouldShowRightComponent: PropTypes.bool,
+
+ /** Should check anonymous user in onPress function */
+ shouldCheckActionAllowedOnPress: PropTypes.bool,
};
export default propTypes;
diff --git a/src/components/withNavigationFallback.js b/src/components/withNavigationFallback.js
deleted file mode 100644
index e82946c9e049..000000000000
--- a/src/components/withNavigationFallback.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import React, {forwardRef, useContext, useMemo} from 'react';
-import {NavigationContext} from '@react-navigation/core';
-import getComponentDisplayName from '../libs/getComponentDisplayName';
-import refPropTypes from './refPropTypes';
-
-export default function (WrappedComponent) {
- function WithNavigationFallback(props) {
- const context = useContext(NavigationContext);
-
- const navigationContextValue = useMemo(() => ({isFocused: () => true, addListener: () => () => {}, removeListener: () => () => {}}), []);
-
- return context ? (
-
- ) : (
-
-
-
- );
- }
- WithNavigationFallback.displayName = `WithNavigationFocusWithFallback(${getComponentDisplayName(WrappedComponent)})`;
- WithNavigationFallback.propTypes = {
- forwardedRef: refPropTypes,
- };
- WithNavigationFallback.defaultProps = {
- forwardedRef: undefined,
- };
-
- return forwardRef((props, ref) => (
-
- ));
-}
diff --git a/src/hooks/useNetwork.js b/src/hooks/useNetwork.ts
similarity index 74%
rename from src/hooks/useNetwork.js
rename to src/hooks/useNetwork.ts
index a4e973d0194d..4405dd7126a5 100644
--- a/src/hooks/useNetwork.js
+++ b/src/hooks/useNetwork.ts
@@ -1,16 +1,17 @@
import {useRef, useContext, useEffect} from 'react';
import {NetworkContext} from '../components/OnyxProvider';
-/**
- * @param {Object} [options]
- * @param {Function} [options.onReconnect]
- * @returns {Object}
- */
-export default function useNetwork({onReconnect = () => {}} = {}) {
+type UseNetworkProps = {
+ onReconnect?: () => void;
+};
+
+type UseNetwork = {isOffline?: boolean};
+
+export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = {}): UseNetwork {
const callback = useRef(onReconnect);
callback.current = onReconnect;
- const {isOffline} = useContext(NetworkContext);
+ const {isOffline} = useContext(NetworkContext) ?? {};
const prevOfflineStatusRef = useRef(isOffline);
useEffect(() => {
// If we were offline before and now we are not offline then we just reconnected
diff --git a/src/hooks/useWindowDimensions/index.native.js b/src/hooks/useWindowDimensions/index.native.ts
similarity index 89%
rename from src/hooks/useWindowDimensions/index.native.js
rename to src/hooks/useWindowDimensions/index.native.ts
index 358e43f1b75d..5b0ec2002201 100644
--- a/src/hooks/useWindowDimensions/index.native.js
+++ b/src/hooks/useWindowDimensions/index.native.ts
@@ -1,17 +1,18 @@
// eslint-disable-next-line no-restricted-imports
import {useWindowDimensions} from 'react-native';
import variables from '../../styles/variables';
+import WindowDimensions from './types';
/**
* A convenience wrapper around React Native's useWindowDimensions hook that also provides booleans for our breakpoints.
- * @returns {Object}
*/
-export default function () {
+export default function (): WindowDimensions {
const {width: windowWidth, height: windowHeight} = useWindowDimensions();
const isExtraSmallScreenHeight = windowHeight <= variables.extraSmallMobileResponsiveHeightBreakpoint;
const isSmallScreenWidth = true;
const isMediumScreenWidth = false;
const isLargeScreenWidth = false;
+
return {
windowWidth,
windowHeight,
diff --git a/src/hooks/useWindowDimensions/index.js b/src/hooks/useWindowDimensions/index.ts
similarity index 93%
rename from src/hooks/useWindowDimensions/index.js
rename to src/hooks/useWindowDimensions/index.ts
index 1a1f7eed5a67..f9fee6301d06 100644
--- a/src/hooks/useWindowDimensions/index.js
+++ b/src/hooks/useWindowDimensions/index.ts
@@ -1,12 +1,12 @@
// eslint-disable-next-line no-restricted-imports
import {Dimensions, useWindowDimensions} from 'react-native';
import variables from '../../styles/variables';
+import WindowDimensions from './types';
/**
* A convenience wrapper around React Native's useWindowDimensions hook that also provides booleans for our breakpoints.
- * @returns {Object}
*/
-export default function () {
+export default function (): WindowDimensions {
const {width: windowWidth, height: windowHeight} = useWindowDimensions();
// When the soft keyboard opens on mWeb, the window height changes. Use static screen height instead to get real screenHeight.
const screenHeight = Dimensions.get('screen').height;
@@ -14,6 +14,7 @@ export default function () {
const isSmallScreenWidth = windowWidth <= variables.mobileResponsiveWidthBreakpoint;
const isMediumScreenWidth = windowWidth > variables.mobileResponsiveWidthBreakpoint && windowWidth <= variables.tabletResponsiveWidthBreakpoint;
const isLargeScreenWidth = windowWidth > variables.tabletResponsiveWidthBreakpoint;
+
return {
windowWidth,
windowHeight,
diff --git a/src/hooks/useWindowDimensions/types.ts b/src/hooks/useWindowDimensions/types.ts
new file mode 100644
index 000000000000..9b59d4968935
--- /dev/null
+++ b/src/hooks/useWindowDimensions/types.ts
@@ -0,0 +1,10 @@
+type WindowDimensions = {
+ windowWidth: number;
+ windowHeight: number;
+ isExtraSmallScreenHeight: boolean;
+ isSmallScreenWidth: boolean;
+ isMediumScreenWidth: boolean;
+ isLargeScreenWidth: boolean;
+};
+
+export default WindowDimensions;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index b321903a9781..e7f71e755dd8 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -381,6 +381,14 @@ export default {
termsOfService: 'Terms of Service',
privacy: 'Privacy',
},
+ samlSignIn: {
+ welcomeSAMLEnabled: 'Continue logging in with single sign-on:',
+ orContinueWithMagicCode: 'Or optionally, your company allows signing in with a magic code',
+ useSingleSignOn: 'Use single sign-on',
+ useMagicCode: 'Use magic code',
+ launching: 'Launching...',
+ oneMoment: "One moment while we redirect you to your company's single sign-on portal.",
+ },
reportActionCompose: {
addAction: 'Actions',
dropToUpload: 'Drop to upload',
@@ -869,6 +877,7 @@ export default {
address: 'Address',
revealDetails: 'Reveal details',
copyCardNumber: 'Copy card number',
+ updateAddress: 'Update address',
},
},
reportFraudPage: {
@@ -1035,7 +1044,7 @@ export default {
legalName: 'Legal name',
legalFirstName: 'Legal first name',
legalLastName: 'Legal last name',
- homeAddress: 'Home address',
+ address: 'Address',
error: {
dateShouldBeBefore: ({dateString}: DateShouldBeBeforeParams) => `Date should be before ${dateString}.`,
dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `Date should be after ${dateString}.`,
@@ -1843,7 +1852,7 @@ export default {
},
cardTransactions: {
notActivated: 'Not activated',
- outOfPocketSpend: 'Out-of-pocket spend',
+ outOfPocket: 'Out of pocket',
companySpend: 'Company spend',
},
distance: {
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 51d9923a570b..6020ded30b92 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -372,6 +372,14 @@ export default {
termsOfService: 'Términos de servicio',
privacy: 'Privacidad',
},
+ samlSignIn: {
+ welcomeSAMLEnabled: 'Continua iniciando sesión con el inicio de sesión único:',
+ orContinueWithMagicCode: 'O, opcionalmente, tu empresa te permite iniciar sesión con un código mágico',
+ useSingleSignOn: 'Usar el inicio de sesión único',
+ useMagicCode: 'Usar código mágico',
+ launching: 'Cargando...',
+ oneMoment: 'Un momento mientras te redirigimos al portal de inicio de sesión único de tu empresa.',
+ },
reportActionCompose: {
addAction: 'Acción',
dropToUpload: 'Suelta el archivo aquí para compartirlo',
@@ -865,6 +873,7 @@ export default {
address: 'Dirección',
revealDetails: 'Revelar detalles',
copyCardNumber: 'Copiar número de la tarjeta',
+ updateAddress: 'Actualizar dirección',
},
},
reportFraudPage: {
@@ -1033,7 +1042,7 @@ export default {
legalName: 'Nombre completo',
legalFirstName: 'Nombre legal',
legalLastName: 'Apellidos legales',
- homeAddress: 'Domicilio',
+ address: 'Dirección',
error: {
dateShouldBeBefore: ({dateString}: DateShouldBeBeforeParams) => `La fecha debe ser anterior a ${dateString}.`,
dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `La fecha debe ser posterior a ${dateString}.`,
@@ -2328,7 +2337,7 @@ export default {
},
cardTransactions: {
notActivated: 'No activado',
- outOfPocketSpend: 'Gastos por cuenta propia',
+ outOfPocket: 'Por cuenta propia',
companySpend: 'Gastos de empresa',
},
distance: {
@@ -2355,6 +2364,6 @@ export default {
transactionDate: 'Fecha de transacción',
},
globalNavigationOptions: {
- chats: 'Chats',
+ chats: 'Chats', // "Chats" is the accepted term colloqially in Spanish, this is not a bug!!
},
} satisfies EnglishTranslation;
diff --git a/src/libs/API.js b/src/libs/API.ts
similarity index 64%
rename from src/libs/API.js
rename to src/libs/API.ts
index 2ad1f32347d9..ce3d6bab19bc 100644
--- a/src/libs/API.js
+++ b/src/libs/API.ts
@@ -1,5 +1,5 @@
-import _ from 'underscore';
-import Onyx from 'react-native-onyx';
+import Onyx, {OnyxUpdate} from 'react-native-onyx';
+import {ValueOf} from 'type-fest';
import Log from './Log';
import * as Request from './Request';
import * as Middleware from './Middleware';
@@ -7,6 +7,8 @@ import * as SequentialQueue from './Network/SequentialQueue';
import pkg from '../../package.json';
import CONST from '../CONST';
import * as Pusher from './Pusher/pusher';
+import OnyxRequest from '../types/onyx/Request';
+import Response from '../types/onyx/Response';
// Setup API middlewares. Each request made will pass through a series of middleware functions that will get called in sequence (each one passing the result of the previous to the next).
// Note: The ordering here is intentional as we want to Log, Recheck Connection, Reauthenticate, and Save the Response in Onyx. Errors thrown in one middleware will bubble to the next.
@@ -28,25 +30,34 @@ Request.use(Middleware.HandleUnusedOptimisticID);
// middlewares after this, because the SequentialQueue depends on the result of this middleware to pause the queue (if needed) to bring the app to an up-to-date state.
Request.use(Middleware.SaveResponseInOnyx);
+type OnyxData = {
+ optimisticData?: OnyxUpdate[];
+ successData?: OnyxUpdate[];
+ failureData?: OnyxUpdate[];
+};
+
+type ApiRequestType = ValueOf;
+
/**
* All calls to API.write() will be persisted to disk as JSON with the params, successData, and failureData.
* This is so that if the network is unavailable or the app is closed, we can send the WRITE request later.
*
- * @param {String} command - Name of API command to call.
- * @param {Object} apiCommandParameters - Parameters to send to the API.
- * @param {Object} onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
+ * @param command - Name of API command to call.
+ * @param apiCommandParameters - Parameters to send to the API.
+ * @param onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
* into Onyx before and after a request is made. Each nested object will be formatted in
* the same way as an API response.
- * @param {Object} [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
- * @param {Object} [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
- * @param {Object} [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
+ * @param [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
+ * @param [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
+ * @param [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
*/
-function write(command, apiCommandParameters = {}, onyxData = {}) {
+function write(command: string, apiCommandParameters: Record = {}, onyxData: OnyxData = {}) {
Log.info('Called API write', false, {command, ...apiCommandParameters});
+ const {optimisticData, ...onyxDataWithoutOptimisticData} = onyxData;
// Optimistically update Onyx
- if (onyxData.optimisticData) {
- Onyx.update(onyxData.optimisticData);
+ if (optimisticData) {
+ Onyx.update(optimisticData);
}
// Assemble the data we'll send to the API
@@ -61,7 +72,7 @@ function write(command, apiCommandParameters = {}, onyxData = {}) {
};
// Assemble all the request data we'll be storing in the queue
- const request = {
+ const request: OnyxRequest = {
command,
data: {
...data,
@@ -70,7 +81,7 @@ function write(command, apiCommandParameters = {}, onyxData = {}) {
shouldRetry: true,
canCancel: true,
},
- ..._.omit(onyxData, 'optimisticData'),
+ ...onyxDataWithoutOptimisticData,
};
// Write commands can be saved and retried, so push it to the SequentialQueue
@@ -85,24 +96,30 @@ function write(command, apiCommandParameters = {}, onyxData = {}) {
* Using this method is discouraged and will throw an ESLint error. Use it sparingly and only when all other alternatives have been exhausted.
* It is best to discuss it in Slack anytime you are tempted to use this method.
*
- * @param {String} command - Name of API command to call.
- * @param {Object} apiCommandParameters - Parameters to send to the API.
- * @param {Object} onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
+ * @param command - Name of API command to call.
+ * @param apiCommandParameters - Parameters to send to the API.
+ * @param onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
* into Onyx before and after a request is made. Each nested object will be formatted in
* the same way as an API response.
- * @param {Object} [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
- * @param {Object} [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
- * @param {Object} [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
- * @param {String} [apiRequestType] - Can be either 'read', 'write', or 'makeRequestWithSideEffects'. We use this to either return the chained
+ * @param [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
+ * @param [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
+ * @param [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
+ * @param [apiRequestType] - Can be either 'read', 'write', or 'makeRequestWithSideEffects'. We use this to either return the chained
* response back to the caller or to trigger reconnection callbacks when re-authentication is required.
- * @returns {Promise}
+ * @returns
*/
-function makeRequestWithSideEffects(command, apiCommandParameters = {}, onyxData = {}, apiRequestType = CONST.API_REQUEST_TYPE.MAKE_REQUEST_WITH_SIDE_EFFECTS) {
+function makeRequestWithSideEffects(
+ command: string,
+ apiCommandParameters = {},
+ onyxData: OnyxData = {},
+ apiRequestType: ApiRequestType = CONST.API_REQUEST_TYPE.MAKE_REQUEST_WITH_SIDE_EFFECTS,
+): Promise {
Log.info('Called API makeRequestWithSideEffects', false, {command, ...apiCommandParameters});
+ const {optimisticData, ...onyxDataWithoutOptimisticData} = onyxData;
// Optimistically update Onyx
- if (onyxData.optimisticData) {
- Onyx.update(onyxData.optimisticData);
+ if (optimisticData) {
+ Onyx.update(optimisticData);
}
// Assemble the data we'll send to the API
@@ -113,10 +130,10 @@ function makeRequestWithSideEffects(command, apiCommandParameters = {}, onyxData
};
// Assemble all the request data we'll be storing
- const request = {
+ const request: OnyxRequest = {
command,
data,
- ..._.omit(onyxData, 'optimisticData'),
+ ...onyxDataWithoutOptimisticData,
};
// Return a promise containing the response from HTTPS
@@ -126,16 +143,16 @@ function makeRequestWithSideEffects(command, apiCommandParameters = {}, onyxData
/**
* Requests made with this method are not be persisted to disk. If there is no network connectivity, the request is ignored and discarded.
*
- * @param {String} command - Name of API command to call.
- * @param {Object} apiCommandParameters - Parameters to send to the API.
- * @param {Object} onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
+ * @param command - Name of API command to call.
+ * @param apiCommandParameters - Parameters to send to the API.
+ * @param onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
* into Onyx before and after a request is made. Each nested object will be formatted in
* the same way as an API response.
- * @param {Object} [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
- * @param {Object} [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
- * @param {Object} [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
+ * @param [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
+ * @param [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
+ * @param [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
*/
-function read(command, apiCommandParameters, onyxData) {
+function read(command: string, apiCommandParameters: Record, onyxData: OnyxData = {}) {
// Ensure all write requests on the sequential queue have finished responding before running read requests.
// Responses from read requests can overwrite the optimistic data inserted by
// write requests that use the same Onyx keys and haven't responded yet.
diff --git a/src/libs/Authentication.js b/src/libs/Authentication.ts
similarity index 83%
rename from src/libs/Authentication.js
rename to src/libs/Authentication.ts
index 9f1967ecf0d8..cec20504dd04 100644
--- a/src/libs/Authentication.js
+++ b/src/libs/Authentication.ts
@@ -7,20 +7,20 @@ import redirectToSignIn from './actions/SignInRedirect';
import CONST from '../CONST';
import Log from './Log';
import * as ErrorUtils from './ErrorUtils';
+import Response from '../types/onyx/Response';
-/**
- * @param {Object} parameters
- * @param {Boolean} [parameters.useExpensifyLogin]
- * @param {String} parameters.partnerName
- * @param {String} parameters.partnerPassword
- * @param {String} parameters.partnerUserID
- * @param {String} parameters.partnerUserSecret
- * @param {String} [parameters.twoFactorAuthCode]
- * @param {String} [parameters.email]
- * @param {String} [parameters.authToken]
- * @returns {Promise}
- */
-function Authenticate(parameters) {
+type Parameters = {
+ useExpensifyLogin?: boolean;
+ partnerName: string;
+ partnerPassword: string;
+ partnerUserID?: string;
+ partnerUserSecret?: string;
+ twoFactorAuthCode?: string;
+ email?: string;
+ authToken?: string;
+};
+
+function Authenticate(parameters: Parameters): Promise {
const commandName = 'Authenticate';
requireParameters(['partnerName', 'partnerPassword', 'partnerUserID', 'partnerUserSecret'], parameters, commandName);
@@ -48,11 +48,9 @@ function Authenticate(parameters) {
/**
* Reauthenticate using the stored credentials and redirect to the sign in page if unable to do so.
- *
- * @param {String} [command] command name for logging purposes
- * @returns {Promise}
+ * @param [command] command name for logging purposes
*/
-function reauthenticate(command = '') {
+function reauthenticate(command = ''): Promise {
// Prevent any more requests from being processed while authentication happens
NetworkStore.setIsAuthenticating(true);
@@ -61,8 +59,8 @@ function reauthenticate(command = '') {
useExpensifyLogin: false,
partnerName: CONFIG.EXPENSIFY.PARTNER_NAME,
partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD,
- partnerUserID: credentials.autoGeneratedLogin,
- partnerUserSecret: credentials.autoGeneratedPassword,
+ partnerUserID: credentials?.autoGeneratedLogin,
+ partnerUserSecret: credentials?.autoGeneratedPassword,
}).then((response) => {
if (response.jsonCode === CONST.JSON_CODE.UNABLE_TO_RETRY) {
// If authentication fails, then the network can be unpaused
@@ -92,7 +90,7 @@ function reauthenticate(command = '') {
// Note: It is important to manually set the authToken that is in the store here since any requests that are hooked into
// reauthenticate .then() will immediate post and use the local authToken. Onyx updates subscribers lately so it is not
// enough to do the updateSessionAuthTokens() call above.
- NetworkStore.setAuthToken(response.authToken);
+ NetworkStore.setAuthToken(response.authToken ?? null);
// The authentication process is finished so the network can be unpaused to continue processing requests
NetworkStore.setIsAuthenticating(false);
diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts
index 8df554dd4dbf..c8ea03cc86c0 100644
--- a/src/libs/CardUtils.ts
+++ b/src/libs/CardUtils.ts
@@ -1,6 +1,5 @@
import lodash from 'lodash';
import Onyx from 'react-native-onyx';
-import {Card} from '../types/onyx';
import CONST from '../CONST';
import * as Localize from './Localize';
import * as OnyxTypes from '../types/onyx';
@@ -47,7 +46,7 @@ function getCardDescription(cardID: number) {
return '';
}
const cardDescriptor = card.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED ? Localize.translateLocal('cardTransactions.notActivated') : card.lastFourPAN;
- return `${card.bank} - ${cardDescriptor}`;
+ return cardDescriptor ? `${card.bank} - ${cardDescriptor}` : `${card.bank}`;
}
/**
@@ -60,13 +59,6 @@ function getYearFromExpirationDateString(expirationDateString: string) {
return cardYear.length === 2 ? `20${cardYear}` : cardYear;
}
-function getCompanyCards(cardList: {string: Card}) {
- if (!cardList) {
- return [];
- }
- return Object.values(cardList).filter((card) => card.bank !== CONST.EXPENSIFY_CARD.BANK);
-}
-
/**
* @param cardList - collection of assigned cards
* @returns collection of assigned cards grouped by domain
@@ -96,4 +88,4 @@ function maskCard(lastFour = ''): string {
return maskedString.replace(/(.{4})/g, '$1 ').trim();
}
-export {isExpensifyCard, getDomainCards, getCompanyCards, getMonthFromExpirationDateString, getYearFromExpirationDateString, maskCard, getCardDescription};
+export {isExpensifyCard, getDomainCards, getMonthFromExpirationDateString, getYearFromExpirationDateString, maskCard, getCardDescription};
diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts
index a6f2860310c2..76cf8f15e522 100644
--- a/src/libs/DateUtils.ts
+++ b/src/libs/DateUtils.ts
@@ -15,6 +15,8 @@ import {
isSameDay,
isAfter,
isSameYear,
+ eachMonthOfInterval,
+ eachDayOfInterval,
} from 'date-fns';
import Onyx from 'react-native-onyx';
@@ -255,6 +257,38 @@ function getCurrentTimezone(): Required {
return timezone;
}
+/**
+ * @returns [January, Fabruary, March, April, May, June, July, August, ...]
+ */
+function getMonthNames(preferredLocale: string): string[] {
+ if (preferredLocale) {
+ setLocale(preferredLocale);
+ }
+ const fullYear = new Date().getFullYear();
+ const monthsArray = eachMonthOfInterval({
+ start: new Date(fullYear, 0, 1), // January 1st of the current year
+ end: new Date(fullYear, 11, 31), // December 31st of the current year
+ });
+
+ // eslint-disable-next-line rulesdir/prefer-underscore-method
+ return monthsArray.map((monthDate) => format(monthDate, CONST.DATE.MONTH_FORMAT));
+}
+
+/**
+ * @returns [Monday, Thuesday, Wednesday, ...]
+ */
+function getDaysOfWeek(preferredLocale: string): string[] {
+ if (preferredLocale) {
+ setLocale(preferredLocale);
+ }
+ const startOfCurrentWeek = startOfWeek(new Date(), {weekStartsOn: 1}); // Assuming Monday is the start of the week
+ const endOfCurrentWeek = endOfWeek(new Date(), {weekStartsOn: 1}); // Assuming Monday is the start of the week
+ const daysOfWeek = eachDayOfInterval({start: startOfCurrentWeek, end: endOfCurrentWeek});
+
+ // eslint-disable-next-line rulesdir/prefer-underscore-method
+ return daysOfWeek.map((date) => format(date, 'eeee'));
+}
+
// Used to throttle updates to the timezone when necessary
let lastUpdatedTimezoneTime = new Date();
@@ -357,6 +391,8 @@ const DateUtils = {
isToday,
isTomorrow,
isYesterday,
+ getMonthNames,
+ getDaysOfWeek,
};
export default DateUtils;
diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js
index a44a69f087ab..344d0c3bd397 100644
--- a/src/libs/EmojiUtils.js
+++ b/src/libs/EmojiUtils.js
@@ -110,6 +110,23 @@ function trimEmojiUnicode(emojiCode) {
return emojiCode.replace(/(fe0f|1f3fb|1f3fc|1f3fd|1f3fe|1f3ff)$/, '').trim();
}
+/**
+ * Validates first character is emoji in text string
+ *
+ * @param {String} message
+ * @returns {Boolean}
+ */
+function isFirstLetterEmoji(message) {
+ const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', '');
+ const match = trimmedMessage.match(CONST.REGEX.EMOJIS);
+
+ if (!match) {
+ return false;
+ }
+
+ return trimmedMessage.indexOf(match[0]) === 0;
+}
+
/**
* Validates that this message contains only emojis
*
@@ -497,4 +514,5 @@ export {
replaceAndExtractEmojis,
extractEmojis,
getAddedEmojis,
+ isFirstLetterEmoji,
};
diff --git a/src/libs/Middleware/Logging.js b/src/libs/Middleware/Logging.ts
similarity index 82%
rename from src/libs/Middleware/Logging.js
rename to src/libs/Middleware/Logging.ts
index fdc9f0083abb..171cb4b9ab4c 100644
--- a/src/libs/Middleware/Logging.js
+++ b/src/libs/Middleware/Logging.ts
@@ -1,30 +1,26 @@
-import _ from 'underscore';
-import lodashGet from 'lodash/get';
import Log from '../Log';
import CONST from '../../CONST';
+import Request from '../../types/onyx/Request';
+import Response from '../../types/onyx/Response';
+import Middleware from './types';
-/**
- * @param {String} message
- * @param {Object} request
- * @param {Object} [response]
- */
-function logRequestDetails(message, request, response = {}) {
+function logRequestDetails(message: string, request: Request, response?: Response | void) {
// Don't log about log or else we'd cause an infinite loop
if (request.command === 'Log') {
return;
}
- const logParams = {
+ const logParams: Record = {
command: request.command,
shouldUseSecure: request.shouldUseSecure,
};
- const returnValueList = lodashGet(request, 'data.returnValueList');
+ const returnValueList = request?.data?.returnValueList;
if (returnValueList) {
logParams.returnValueList = returnValueList;
}
- const nvpNames = lodashGet(request, 'data.nvpNames');
+ const nvpNames = request?.data?.nvpNames;
if (nvpNames) {
logParams.nvpNames = nvpNames;
}
@@ -37,14 +33,7 @@ function logRequestDetails(message, request, response = {}) {
Log.info(message, false, logParams);
}
-/**
- * Logging middleware
- *
- * @param {Promise} response
- * @param {Object} request
- * @returns {Promise}
- */
-function Logging(response, request) {
+const Logging: Middleware = (response, request) => {
logRequestDetails('Making API request', request);
return response
.then((data) => {
@@ -52,7 +41,7 @@ function Logging(response, request) {
return data;
})
.catch((error) => {
- const logParams = {
+ const logParams: Record = {
message: error.message,
status: error.status,
title: error.title,
@@ -73,21 +62,18 @@ function Logging(response, request) {
// incorrect url, bad cors headers returned by the server, DNS lookup failure etc.
Log.hmmm('[Network] API request error: Failed to fetch', logParams);
} else if (
- _.contains(
- [
- CONST.ERROR.IOS_NETWORK_CONNECTION_LOST,
- CONST.ERROR.NETWORK_REQUEST_FAILED,
- CONST.ERROR.IOS_NETWORK_CONNECTION_LOST_RUSSIAN,
- CONST.ERROR.IOS_NETWORK_CONNECTION_LOST_SWEDISH,
- CONST.ERROR.IOS_NETWORK_CONNECTION_LOST_SPANISH,
- ],
- error.message,
- )
+ [
+ CONST.ERROR.IOS_NETWORK_CONNECTION_LOST,
+ CONST.ERROR.NETWORK_REQUEST_FAILED,
+ CONST.ERROR.IOS_NETWORK_CONNECTION_LOST_RUSSIAN,
+ CONST.ERROR.IOS_NETWORK_CONNECTION_LOST_SWEDISH,
+ CONST.ERROR.IOS_NETWORK_CONNECTION_LOST_SPANISH,
+ ].includes(error.message)
) {
// These errors seem to happen for native devices with interrupted connections. Often we will see logs about Pusher disconnecting together with these.
// This type of error may also indicate a problem with SSL certs.
Log.hmmm('[Network] API request error: Connection interruption likely', logParams);
- } else if (_.contains([CONST.ERROR.FIREFOX_DOCUMENT_LOAD_ABORTED, CONST.ERROR.SAFARI_DOCUMENT_LOAD_ABORTED], error.message)) {
+ } else if ([CONST.ERROR.FIREFOX_DOCUMENT_LOAD_ABORTED, CONST.ERROR.SAFARI_DOCUMENT_LOAD_ABORTED].includes(error.message)) {
// This message can be observed page load is interrupted (closed or navigated away).
Log.hmmm('[Network] API request error: User likely navigated away from or closed browser', logParams);
} else if (error.message === CONST.ERROR.IOS_LOAD_FAILED) {
@@ -123,6 +109,6 @@ function Logging(response, request) {
// Re-throw this error so the next handler can manage it
throw error;
});
-}
+};
export default Logging;
diff --git a/src/libs/Middleware/Reauthentication.js b/src/libs/Middleware/Reauthentication.ts
similarity index 86%
rename from src/libs/Middleware/Reauthentication.js
rename to src/libs/Middleware/Reauthentication.ts
index dfe4e1b7fda8..aec09227e441 100644
--- a/src/libs/Middleware/Reauthentication.js
+++ b/src/libs/Middleware/Reauthentication.ts
@@ -1,4 +1,3 @@
-import lodashGet from 'lodash/get';
import CONST from '../../CONST';
import * as NetworkStore from '../Network/NetworkStore';
import * as MainQueue from '../Network/MainQueue';
@@ -6,15 +5,12 @@ import * as Authentication from '../Authentication';
import * as Request from '../Request';
import Log from '../Log';
import NetworkConnection from '../NetworkConnection';
+import Middleware from './types';
// We store a reference to the active authentication request so that we are only ever making one request to authenticate at a time.
-let isAuthenticating = null;
+let isAuthenticating: Promise | null = null;
-/**
- * @param {String} commandName
- * @returns {Promise}
- */
-function reauthenticate(commandName) {
+function reauthenticate(commandName?: string): Promise {
if (isAuthenticating) {
return isAuthenticating;
}
@@ -32,16 +28,8 @@ function reauthenticate(commandName) {
return isAuthenticating;
}
-/**
- * Reauthentication middleware
- *
- * @param {Promise} response
- * @param {Object} request
- * @param {Boolean} isFromSequentialQueue
- * @returns {Promise}
- */
-function Reauthentication(response, request, isFromSequentialQueue) {
- return response
+const Reauthentication: Middleware = (response, request, isFromSequentialQueue) =>
+ response
.then((data) => {
// If there is no data for some reason then we cannot reauthenticate
if (!data) {
@@ -58,13 +46,13 @@ function Reauthentication(response, request, isFromSequentialQueue) {
// There are some API requests that should not be retried when there is an auth failure like
// creating and deleting logins. In those cases, they should handle the original response instead
// of the new response created by handleExpiredAuthToken.
- const shouldRetry = lodashGet(request, 'data.shouldRetry');
- const apiRequestType = lodashGet(request, 'data.apiRequestType');
+ const shouldRetry = request?.data?.shouldRetry;
+ const apiRequestType = request?.data?.apiRequestType;
// For the SignInWithShortLivedAuthToken command, if the short token expires, the server returns a 407 error,
// and credentials are still empty at this time, which causes reauthenticate to throw an error (requireParameters),
// and the subsequent SaveResponseInOnyx also cannot be executed, so we need this parameter to skip the reauthentication logic.
- const skipReauthentication = lodashGet(request, 'data.skipReauthentication');
+ const skipReauthentication = request?.data?.skipReauthentication;
if ((!shouldRetry && !apiRequestType) || skipReauthentication) {
if (isFromSequentialQueue) {
return data;
@@ -82,7 +70,7 @@ function Reauthentication(response, request, isFromSequentialQueue) {
return data;
}
- return reauthenticate(request.commandName)
+ return reauthenticate(request?.commandName)
.then((authenticateResponse) => {
if (isFromSequentialQueue || apiRequestType === CONST.API_REQUEST_TYPE.MAKE_REQUEST_WITH_SIDE_EFFECTS) {
return Request.processWithMiddleware(request, isFromSequentialQueue);
@@ -128,6 +116,5 @@ function Reauthentication(response, request, isFromSequentialQueue) {
request.resolve({jsonCode: CONST.JSON_CODE.UNABLE_TO_RETRY});
}
});
-}
export default Reauthentication;
diff --git a/src/libs/Middleware/RecheckConnection.js b/src/libs/Middleware/RecheckConnection.ts
similarity index 83%
rename from src/libs/Middleware/RecheckConnection.js
rename to src/libs/Middleware/RecheckConnection.ts
index 58f5cfa601c8..5a685d66fd02 100644
--- a/src/libs/Middleware/RecheckConnection.js
+++ b/src/libs/Middleware/RecheckConnection.ts
@@ -1,20 +1,17 @@
import CONST from '../../CONST';
import NetworkConnection from '../NetworkConnection';
+import Middleware from './types';
/**
- * @returns {Function} cancel timer
+ * @returns cancel timer
*/
-function startRecheckTimeoutTimer() {
+function startRecheckTimeoutTimer(): () => void {
// If request is still in processing after this time, we might be offline
const timerID = setTimeout(NetworkConnection.recheckNetworkConnection, CONST.NETWORK.MAX_PENDING_TIME_MS);
return () => clearTimeout(timerID);
}
-/**
- * @param {Promise} response
- * @returns {Promise}
- */
-function RecheckConnection(response) {
+const RecheckConnection: Middleware = (response) => {
// When the request goes past a certain amount of time we trigger a re-check of the connection
const cancelRequestTimeoutTimer = startRecheckTimeoutTimer();
return response
@@ -27,6 +24,6 @@ function RecheckConnection(response) {
throw error;
})
.finally(cancelRequestTimeoutTimer);
-}
+};
export default RecheckConnection;
diff --git a/src/libs/Middleware/SaveResponseInOnyx.js b/src/libs/Middleware/SaveResponseInOnyx.ts
similarity index 74%
rename from src/libs/Middleware/SaveResponseInOnyx.js
rename to src/libs/Middleware/SaveResponseInOnyx.ts
index d8c47d4c01dd..0a279a7425b4 100644
--- a/src/libs/Middleware/SaveResponseInOnyx.js
+++ b/src/libs/Middleware/SaveResponseInOnyx.ts
@@ -1,21 +1,16 @@
-import _ from 'underscore';
import CONST from '../../CONST';
import ONYXKEYS from '../../ONYXKEYS';
import * as MemoryOnlyKeys from '../actions/MemoryOnlyKeys/MemoryOnlyKeys';
import * as OnyxUpdates from '../actions/OnyxUpdates';
+import Middleware from './types';
// If we're executing any of these requests, we don't need to trigger our OnyxUpdates flow to update the current data even if our current value is out of
// date because all these requests are updating the app to the most current state.
const requestsToIgnoreLastUpdateID = ['OpenApp', 'ReconnectApp', 'GetMissingOnyxMessages'];
-/**
- * @param {Promise} requestResponse
- * @param {Object} request
- * @returns {Promise}
- */
-function SaveResponseInOnyx(requestResponse, request) {
- return requestResponse.then((response = {}) => {
- const onyxUpdates = response.onyxData;
+const SaveResponseInOnyx: Middleware = (requestResponse, request) =>
+ requestResponse.then((response = {}) => {
+ const onyxUpdates = response?.onyxData ?? [];
// Sometimes we call requests that are successfull but they don't have any response or any success/failure data to set. Let's return early since
// we don't need to store anything here.
@@ -24,7 +19,7 @@ function SaveResponseInOnyx(requestResponse, request) {
}
// If there is an OnyxUpdate for using memory only keys, enable them
- _.find(onyxUpdates, ({key, value}) => {
+ onyxUpdates?.find(({key, value}) => {
if (key !== ONYXKEYS.IS_USING_MEMORY_ONLY_KEYS || !value) {
return false;
}
@@ -35,13 +30,13 @@ function SaveResponseInOnyx(requestResponse, request) {
const responseToApply = {
type: CONST.ONYX_UPDATE_TYPES.HTTPS,
- lastUpdateID: Number(response.lastUpdateID || 0),
- previousUpdateID: Number(response.previousUpdateID || 0),
+ lastUpdateID: Number(response?.lastUpdateID ?? 0),
+ previousUpdateID: Number(response?.previousUpdateID ?? 0),
request,
- response,
+ response: response ?? {},
};
- if (_.includes(requestsToIgnoreLastUpdateID, request.command) || !OnyxUpdates.doesClientNeedToBeUpdated(Number(response.previousUpdateID || 0))) {
+ if (requestsToIgnoreLastUpdateID.includes(request.command) || !OnyxUpdates.doesClientNeedToBeUpdated(Number(response?.previousUpdateID ?? 0))) {
return OnyxUpdates.apply(responseToApply);
}
@@ -54,6 +49,5 @@ function SaveResponseInOnyx(requestResponse, request) {
shouldPauseQueue: true,
});
});
-}
export default SaveResponseInOnyx;
diff --git a/src/libs/Middleware/index.js b/src/libs/Middleware/index.ts
similarity index 100%
rename from src/libs/Middleware/index.js
rename to src/libs/Middleware/index.ts
diff --git a/src/libs/Middleware/types.ts b/src/libs/Middleware/types.ts
new file mode 100644
index 000000000000..ece210ffe2af
--- /dev/null
+++ b/src/libs/Middleware/types.ts
@@ -0,0 +1,6 @@
+import Request from '../../types/onyx/Request';
+import Response from '../../types/onyx/Response';
+
+type Middleware = (response: Promise, request: Request, isFromSequentialQueue: boolean) => Promise;
+
+export default Middleware;
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js
index a4d934faec43..dd7175dbc6f6 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.js
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.js
@@ -35,6 +35,7 @@ import * as SessionUtils from '../../SessionUtils';
import NotFoundPage from '../../../pages/ErrorPage/NotFoundPage';
import getRootNavigatorScreenOptions from './getRootNavigatorScreenOptions';
import DemoSetupPage from '../../../pages/DemoSetupPage';
+import getCurrentUrl from '../currentUrl';
let timezone;
let currentAccountID;
@@ -145,6 +146,15 @@ class AuthScreens extends React.Component {
}
componentDidMount() {
+ const currentUrl = getCurrentUrl();
+ const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(currentUrl, this.props.session.email);
+ // Sign out the current user if we're transitioning with a different user
+ const isTransitioning = currentUrl.includes(ROUTES.TRANSITION_BETWEEN_APPS);
+ if (isLoggingInAsNewUser && isTransitioning) {
+ Session.signOutAndRedirectToSignIn();
+ return;
+ }
+
NetworkConnection.listenForReconnect();
NetworkConnection.onReconnect(() => {
if (isLoadingApp) {
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
index 2d0fdd281422..54c7b9b8396e 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
@@ -143,6 +143,7 @@ const SettingsModalStackNavigator = createModalStackNavigator({
Settings_App_Download_Links: () => require('../../../pages/settings/AppDownloadLinks').default,
Settings_Lounge_Access: () => require('../../../pages/settings/Profile/LoungeAccessPage').default,
Settings_Wallet: () => require('../../../pages/settings/Wallet/WalletPage').default,
+ Settings_Wallet_Cards_Digital_Details_Update_Address: () => require('../../../pages/settings/Profile/PersonalDetails/AddressPage').default,
Settings_Wallet_DomainCards: () => require('../../../pages/settings/Wallet/ExpensifyCardPage').default,
Settings_Wallet_ReportVirtualCardFraud: () => require('../../../pages/settings/Wallet/ReportVirtualCardFraudPage').default,
Settings_Wallet_Card_Activate: () => require('../../../pages/settings/Wallet/ActivatePhysicalCardPage').default,
diff --git a/src/libs/Navigation/AppNavigator/PublicScreens.js b/src/libs/Navigation/AppNavigator/PublicScreens.js
index 7a87530a2d9e..7b0afb787278 100644
--- a/src/libs/Navigation/AppNavigator/PublicScreens.js
+++ b/src/libs/Navigation/AppNavigator/PublicScreens.js
@@ -8,6 +8,7 @@ import defaultScreenOptions from './defaultScreenOptions';
import UnlinkLoginPage from '../../../pages/UnlinkLoginPage';
import AppleSignInDesktopPage from '../../../pages/signin/AppleSignInDesktopPage';
import GoogleSignInDesktopPage from '../../../pages/signin/GoogleSignInDesktopPage';
+import SAMLSignInPage from '../../../pages/signin/SAMLSignInPage';
const RootStack = createStackNavigator();
@@ -44,6 +45,11 @@ function PublicScreens() {
options={defaultScreenOptions}
component={GoogleSignInDesktopPage}
/>
+
);
}
diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.js b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.js
index a3d8398a22b0..890db2b45ad4 100644
--- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.js
+++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.js
@@ -1,6 +1,8 @@
import _ from 'underscore';
import {StackRouter} from '@react-navigation/native';
+import lodashFindLast from 'lodash/findLast';
import NAVIGATORS from '../../../../NAVIGATORS';
+import SCREENS from '../../../../SCREENS';
/**
* @param {Object} state - react-navigation state
@@ -8,6 +10,30 @@ import NAVIGATORS from '../../../../NAVIGATORS';
*/
const isAtLeastOneCentralPaneNavigatorInState = (state) => _.find(state.routes, (r) => r.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR);
+/**
+ * @param {Object} state - react-navigation state
+ * @returns {String|undefined}
+ */
+const getTopMostReportIDFromRHP = (state) => {
+ if (!state) {
+ return;
+ }
+ const topmostRightPane = lodashFindLast(state.routes, (route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR);
+
+ if (topmostRightPane) {
+ return getTopMostReportIDFromRHP(topmostRightPane.state);
+ }
+
+ const topmostRoute = lodashFindLast(state.routes);
+
+ if (topmostRoute.state) {
+ return getTopMostReportIDFromRHP(topmostRoute.state);
+ }
+
+ if (topmostRoute.params && topmostRoute.params.reportID) {
+ return topmostRoute.params.reportID;
+ }
+};
/**
* Adds report route without any specific reportID to the state.
* The report screen will self set proper reportID param based on the helper function findLastAccessedReport (look at ReportScreenWrapper for more info)
@@ -15,7 +41,21 @@ const isAtLeastOneCentralPaneNavigatorInState = (state) => _.find(state.routes,
* @param {Object} state - react-navigation state
*/
const addCentralPaneNavigatorRoute = (state) => {
- state.routes.splice(1, 0, {name: NAVIGATORS.CENTRAL_PANE_NAVIGATOR});
+ const reportID = getTopMostReportIDFromRHP(state);
+ const centralPaneNavigatorRoute = {
+ name: NAVIGATORS.CENTRAL_PANE_NAVIGATOR,
+ state: {
+ routes: [
+ {
+ name: SCREENS.REPORT,
+ params: {
+ reportID,
+ },
+ },
+ ],
+ },
+ };
+ state.routes.splice(1, 0, centralPaneNavigatorRoute);
// eslint-disable-next-line no-param-reassign
state.index = state.routes.length - 1;
};
diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js
index 07b12486b8b2..6bbf53ffa6ea 100644
--- a/src/libs/Navigation/Navigation.js
+++ b/src/libs/Navigation/Navigation.js
@@ -12,7 +12,7 @@ import originalGetTopmostReportId from './getTopmostReportId';
import originalGetTopMostCentralPaneRouteName from './getTopMostCentralPaneRouteName';
import originalGetTopmostReportActionId from './getTopmostReportActionID';
import getStateFromPath from './getStateFromPath';
-import SCREENS, {PROTECTED_SCREENS} from '../../SCREENS';
+import SCREENS from '../../SCREENS';
import CONST from '../../CONST';
let resolveNavigationIsReadyPromise;
@@ -80,7 +80,7 @@ const getActiveRouteIndex = function (route, index) {
/**
* Main navigation method for redirecting to a route.
* @param {String} route
- * @param {String} type - Type of action to perform. Currently UP is supported.
+ * @param {String} [type] - Type of action to perform. Currently UP is supported.
*/
function navigate(route = ROUTES.HOME, type) {
if (!canNavigate('navigate', {route})) {
@@ -262,61 +262,6 @@ function setIsNavigationReady() {
resolveNavigationIsReadyPromise();
}
-/**
- * Checks if the navigation state contains routes that are protected (over the auth wall).
- *
- * @function
- * @param {Object} state - react-navigation state object
- *
- * @returns {Boolean}
- */
-function navContainsProtectedRoutes(state) {
- if (!state || !state.routeNames || !_.isArray(state.routeNames)) {
- return false;
- }
- const protectedScreensNames = _.values(PROTECTED_SCREENS);
- const difference = _.difference(protectedScreensNames, state.routeNames);
-
- return !difference.length;
-}
-
-/**
- * Waits for the navigation state to contain protected routes specified in PROTECTED_SCREENS constant
- * If the navigation is in a state, where protected routes are available, the promise will resolve immediately.
- *
- * @function
- * @returns {Promise} A promise that resolves to `true` when the Concierge route is present.
- * Rejects with an error if the navigation is not ready.
- *
- * @example
- * waitForProtectedRoutes()
- * .then(() => console.log('Protected routes are present!'))
- * .catch(error => console.error(error.message));
- */
-function waitForProtectedRoutes() {
- return new Promise((resolve, reject) => {
- const isReady = navigationRef.current && navigationRef.current.isReady();
- if (!isReady) {
- reject(new Error('[Navigation] is not ready yet!'));
- return;
- }
- const currentState = navigationRef.current.getState();
- if (navContainsProtectedRoutes(currentState)) {
- resolve();
- return;
- }
- let unsubscribe;
- const handleStateChange = ({data}) => {
- const state = lodashGet(data, 'state');
- if (navContainsProtectedRoutes(state)) {
- unsubscribe();
- resolve();
- }
- };
- unsubscribe = navigationRef.current.addListener('state', handleStateChange);
- });
-}
-
export default {
setShouldPopAllStateOnUP,
canNavigate,
@@ -330,7 +275,6 @@ export default {
setIsNavigationReady,
getTopmostReportId,
getRouteNameFromStateEvent,
- waitForProtectedRoutes,
getTopMostCentralPaneRouteName,
getTopmostReportActionId,
};
diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js
index 2311f4faa193..8a68ec9c0d07 100644
--- a/src/libs/Navigation/linkingConfig.js
+++ b/src/libs/Navigation/linkingConfig.js
@@ -15,6 +15,7 @@ export default {
[SCREENS.CONCIERGE]: ROUTES.CONCIERGE,
AppleSignInDesktop: ROUTES.APPLE_SIGN_IN,
GoogleSignInDesktop: ROUTES.GOOGLE_SIGN_IN,
+ SAMLSignIn: ROUTES.SAML_SIGN_IN,
[SCREENS.DESKTOP_SIGN_IN_REDIRECT]: ROUTES.DESKTOP_SIGN_IN_REDIRECT,
[SCREENS.REPORT_ATTACHMENTS]: ROUTES.REPORT_ATTACHMENTS.route,
@@ -73,7 +74,7 @@ export default {
exact: true,
},
Settings_Wallet_DomainCards: {
- path: ROUTES.SETTINGS_WALLET_DOMAINCARDS.route,
+ path: ROUTES.SETTINGS_WALLET_DOMAINCARD.route,
exact: true,
},
Settings_Wallet_ReportVirtualCardFraud: {
@@ -96,6 +97,10 @@ export default {
path: ROUTES.SETTINGS_WALLET_CARD_ACTIVATE.route,
exact: true,
},
+ Settings_Wallet_Cards_Digital_Details_Update_Address: {
+ path: ROUTES.SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS.route,
+ exact: true,
+ },
Settings_Add_Debit_Card: {
path: ROUTES.SETTINGS_ADD_DEBIT_CARD,
exact: true,
diff --git a/src/libs/Network/MainQueue.js b/src/libs/Network/MainQueue.ts
similarity index 71%
rename from src/libs/Network/MainQueue.js
rename to src/libs/Network/MainQueue.ts
index 5b5b928d3284..5f069e2d0ed4 100644
--- a/src/libs/Network/MainQueue.js
+++ b/src/libs/Network/MainQueue.ts
@@ -1,42 +1,28 @@
-import _ from 'underscore';
-import lodashGet from 'lodash/get';
import * as NetworkStore from './NetworkStore';
import * as SequentialQueue from './SequentialQueue';
import * as Request from '../Request';
+import OnyxRequest from '../../types/onyx/Request';
// Queue for network requests so we don't lose actions done by the user while offline
-let networkRequestQueue = [];
+let networkRequestQueue: OnyxRequest[] = [];
/**
* Checks to see if a request can be made.
- *
- * @param {Object} request
- * @param {String} request.type
- * @param {String} request.command
- * @param {Object} [request.data]
- * @param {Boolean} request.data.forceNetworkRequest
- * @return {Boolean}
*/
-function canMakeRequest(request) {
+function canMakeRequest(request: OnyxRequest): boolean {
// Some requests are always made even when we are in the process of authenticating (typically because they require no authToken e.g. Log, BeginSignIn)
// However, if we are in the process of authenticating we always want to queue requests until we are no longer authenticating.
- return request.data.forceNetworkRequest === true || (!NetworkStore.isAuthenticating() && !SequentialQueue.isRunning());
+ return request.data?.forceNetworkRequest === true || (!NetworkStore.isAuthenticating() && !SequentialQueue.isRunning());
}
-/**
- * @param {Object} request
- */
-function push(request) {
+function push(request: OnyxRequest) {
networkRequestQueue.push(request);
}
-/**
- * @param {Object} request
- */
-function replay(request) {
+function replay(request: OnyxRequest) {
push(request);
- // eslint-disable-next-line no-use-before-define
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
process();
}
@@ -57,12 +43,12 @@ function process() {
// - we are in the process of authenticating and the request is retryable (most are)
// - the request does not have forceNetworkRequest === true (this will trigger it to process immediately)
// - the request does not have shouldRetry === false (specified when we do not want to retry, defaults to true)
- const requestsToProcessOnNextRun = [];
+ const requestsToProcessOnNextRun: OnyxRequest[] = [];
- _.each(networkRequestQueue, (queuedRequest) => {
+ networkRequestQueue.forEach((queuedRequest) => {
// Check if we can make this request at all and if we can't see if we should save it for the next run or chuck it into the ether
if (!canMakeRequest(queuedRequest)) {
- const shouldRetry = lodashGet(queuedRequest, 'data.shouldRetry');
+ const shouldRetry = queuedRequest?.data?.shouldRetry;
if (shouldRetry) {
requestsToProcessOnNextRun.push(queuedRequest);
} else {
@@ -84,13 +70,10 @@ function process() {
* Non-cancellable requests like Log would not be cleared
*/
function clear() {
- networkRequestQueue = _.filter(networkRequestQueue, (request) => !request.data.canCancel);
+ networkRequestQueue = networkRequestQueue.filter((request) => !request.data?.canCancel);
}
-/**
- * @returns {Array}
- */
-function getAll() {
+function getAll(): OnyxRequest[] {
return networkRequestQueue;
}
diff --git a/src/libs/Network/NetworkStore.js b/src/libs/Network/NetworkStore.ts
similarity index 61%
rename from src/libs/Network/NetworkStore.js
rename to src/libs/Network/NetworkStore.ts
index 5ab46a4d65fa..0910031bdb63 100644
--- a/src/libs/Network/NetworkStore.js
+++ b/src/libs/Network/NetworkStore.ts
@@ -1,32 +1,28 @@
-import lodashGet from 'lodash/get';
import Onyx from 'react-native-onyx';
-import _ from 'underscore';
import ONYXKEYS from '../../ONYXKEYS';
+import Credentials from '../../types/onyx/Credentials';
-let credentials;
-let authToken;
-let supportAuthToken;
-let currentUserEmail;
+let credentials: Credentials | null = null;
+let authToken: string | null = null;
+let supportAuthToken: string | null = null;
+let currentUserEmail: string | null = null;
let offline = false;
let authenticating = false;
// Allow code that is outside of the network listen for when a reconnection happens so that it can execute any side-effects (like flushing the sequential network queue)
-let reconnectCallback;
+let reconnectCallback: () => void;
function triggerReconnectCallback() {
- if (!_.isFunction(reconnectCallback)) {
+ if (typeof reconnectCallback !== 'function') {
return;
}
return reconnectCallback();
}
-/**
- * @param {Function} callbackFunction
- */
-function onReconnection(callbackFunction) {
+function onReconnection(callbackFunction: () => void) {
reconnectCallback = callbackFunction;
}
-let resolveIsReadyPromise;
+let resolveIsReadyPromise: (args?: unknown[]) => void;
let isReadyPromise = new Promise((resolve) => {
resolveIsReadyPromise = resolve;
});
@@ -36,7 +32,7 @@ let isReadyPromise = new Promise((resolve) => {
* If the values are undefined we haven't read them yet. If they are null or have a value then we have and the network is "ready".
*/
function checkRequiredData() {
- if (_.isUndefined(authToken) || _.isUndefined(credentials)) {
+ if (authToken === undefined || credentials === undefined) {
return;
}
@@ -53,9 +49,9 @@ function resetHasReadRequiredDataFromStorage() {
Onyx.connect({
key: ONYXKEYS.SESSION,
callback: (val) => {
- authToken = lodashGet(val, 'authToken', null);
- supportAuthToken = lodashGet(val, 'supportAuthToken', null);
- currentUserEmail = lodashGet(val, 'email', null);
+ authToken = val?.authToken ?? null;
+ supportAuthToken = val?.supportAuthToken ?? null;
+ currentUserEmail = val?.email ?? null;
checkRequiredData();
},
});
@@ -63,7 +59,7 @@ Onyx.connect({
Onyx.connect({
key: ONYXKEYS.CREDENTIALS,
callback: (val) => {
- credentials = val || {};
+ credentials = val;
checkRequiredData();
},
});
@@ -82,85 +78,51 @@ Onyx.connect({
triggerReconnectCallback();
}
- offline = Boolean(network.shouldForceOffline) || network.isOffline;
+ offline = Boolean(network.shouldForceOffline) || !!network.isOffline;
},
});
-/**
- * @returns {Object}
- */
-function getCredentials() {
+function getCredentials(): Credentials | null {
return credentials;
}
-/**
- * @returns {Boolean}
- */
-function isOffline() {
+function isOffline(): boolean {
return offline;
}
-/**
- * @returns {String}
- */
-function getAuthToken() {
+function getAuthToken(): string | null {
return authToken;
}
-/**
- * @param {String} command
- * @returns {[String]}
- */
-function isSupportRequest(command) {
- return _.contains(['OpenApp', 'ReconnectApp', 'OpenReport'], command);
+function isSupportRequest(command: string): boolean {
+ return ['OpenApp', 'ReconnectApp', 'OpenReport'].includes(command);
}
-/**
- * @returns {String}
- */
-function getSupportAuthToken() {
+function getSupportAuthToken(): string | null {
return supportAuthToken;
}
-/**
- * @param {String} newSupportAuthToken
- */
-function setSupportAuthToken(newSupportAuthToken) {
+function setSupportAuthToken(newSupportAuthToken: string) {
supportAuthToken = newSupportAuthToken;
}
-/**
- * @param {String} newAuthToken
- */
-function setAuthToken(newAuthToken) {
+function setAuthToken(newAuthToken: string | null) {
authToken = newAuthToken;
}
-/**
- * @returns {String}
- */
-function getCurrentUserEmail() {
+function getCurrentUserEmail(): string | null {
return currentUserEmail;
}
-/**
- * @returns {Promise}
- */
-function hasReadRequiredDataFromStorage() {
+function hasReadRequiredDataFromStorage(): Promise {
return isReadyPromise;
}
-/**
- * @returns {Boolean}
- */
-function isAuthenticating() {
+function isAuthenticating(): boolean {
return authenticating;
}
-/**
- * @param {Boolean} val
- */
-function setIsAuthenticating(val) {
+function setIsAuthenticating(val: boolean) {
authenticating = val;
}
diff --git a/src/libs/Network/SequentialQueue.js b/src/libs/Network/SequentialQueue.ts
similarity index 90%
rename from src/libs/Network/SequentialQueue.js
rename to src/libs/Network/SequentialQueue.ts
index 5c74f791e073..980bbc0efc44 100644
--- a/src/libs/Network/SequentialQueue.js
+++ b/src/libs/Network/SequentialQueue.ts
@@ -1,4 +1,3 @@
-import _ from 'underscore';
import Onyx from 'react-native-onyx';
import * as PersistedRequests from '../actions/PersistedRequests';
import * as NetworkStore from './NetworkStore';
@@ -8,17 +7,18 @@ import * as Request from '../Request';
import * as RequestThrottle from '../RequestThrottle';
import CONST from '../../CONST';
import * as QueuedOnyxUpdates from '../actions/QueuedOnyxUpdates';
+import OnyxRequest from '../../types/onyx/Request';
-let resolveIsReadyPromise;
+let resolveIsReadyPromise: ((args?: unknown[]) => void) | undefined;
let isReadyPromise = new Promise((resolve) => {
resolveIsReadyPromise = resolve;
});
// Resolve the isReadyPromise immediately so that the queue starts working as soon as the page loads
-resolveIsReadyPromise();
+resolveIsReadyPromise?.();
let isSequentialQueueRunning = false;
-let currentRequest = null;
+let currentRequest: Promise | null = null;
let isQueuePaused = false;
/**
@@ -52,16 +52,15 @@ function flushOnyxUpdatesQueue() {
* is successfully returned. The first time a request fails we set a random, small, initial wait time. After waiting, we retry the request. If there are subsequent failures the request wait
* time is doubled creating an exponential back off in the frequency of requests hitting the server. Since the initial wait time is random and it increases exponentially, the load of
* requests to our backend is evenly distributed and it gradually decreases with time, which helps the servers catch up.
- * @returns {Promise}
*/
-function process() {
+function process(): Promise {
// When the queue is paused, return early. This prevents any new requests from happening. The queue will be flushed again when the queue is unpaused.
if (isQueuePaused) {
return Promise.resolve();
}
const persistedRequests = PersistedRequests.getAll();
- if (_.isEmpty(persistedRequests) || NetworkStore.isOffline()) {
+ if (persistedRequests.length === 0 || NetworkStore.isOffline()) {
return Promise.resolve();
}
const requestToProcess = persistedRequests[0];
@@ -71,7 +70,7 @@ function process() {
.then((response) => {
// A response might indicate that the queue should be paused. This happens when a gap in onyx updates is detected between the client and the server and
// that gap needs resolved before the queue can continue.
- if (response.shouldPauseQueue) {
+ if (response?.shouldPauseQueue) {
pause();
}
PersistedRequests.remove(requestToProcess);
@@ -89,12 +88,13 @@ function process() {
return RequestThrottle.sleep()
.then(process)
.catch(() => {
- Onyx.update(requestToProcess.failureData);
+ Onyx.update(requestToProcess.failureData ?? []);
PersistedRequests.remove(requestToProcess);
RequestThrottle.clear();
return process();
});
});
+
return currentRequest;
}
@@ -104,7 +104,7 @@ function flush() {
return;
}
- if (isSequentialQueueRunning || _.isEmpty(PersistedRequests.getAll())) {
+ if (isSequentialQueueRunning || PersistedRequests.getAll().length === 0) {
return;
}
@@ -128,7 +128,7 @@ function flush() {
Onyx.disconnect(connectionID);
process().finally(() => {
isSequentialQueueRunning = false;
- resolveIsReadyPromise();
+ resolveIsReadyPromise?.();
currentRequest = null;
flushOnyxUpdatesQueue();
});
@@ -151,20 +151,14 @@ function unpause() {
flush();
}
-/**
- * @returns {Boolean}
- */
-function isRunning() {
+function isRunning(): boolean {
return isSequentialQueueRunning;
}
// Flush the queue when the connection resumes
NetworkStore.onReconnection(flush);
-/**
- * @param {Object} request
- */
-function push(request) {
+function push(request: OnyxRequest) {
// Add request to Persisted Requests so that it can be retried if it fails
PersistedRequests.save([request]);
@@ -182,10 +176,7 @@ function push(request) {
flush();
}
-/**
- * @returns {Promise}
- */
-function getCurrentRequest() {
+function getCurrentRequest(): OnyxRequest | Promise {
if (currentRequest === null) {
return Promise.resolve();
}
@@ -194,9 +185,8 @@ function getCurrentRequest() {
/**
* Returns a promise that resolves when the sequential queue is done processing all persisted write requests.
- * @returns {Promise}
*/
-function waitForIdle() {
+function waitForIdle(): Promise {
return isReadyPromise;
}
diff --git a/src/libs/Network/enhanceParameters.js b/src/libs/Network/enhanceParameters.ts
similarity index 72%
rename from src/libs/Network/enhanceParameters.js
rename to src/libs/Network/enhanceParameters.ts
index 778be881cb98..54d72a7c6c99 100644
--- a/src/libs/Network/enhanceParameters.js
+++ b/src/libs/Network/enhanceParameters.ts
@@ -1,27 +1,18 @@
-import lodashGet from 'lodash/get';
-import _ from 'underscore';
import CONFIG from '../../CONFIG';
import getPlatform from '../getPlatform';
import * as NetworkStore from './NetworkStore';
/**
* Does this command require an authToken?
- *
- * @param {String} command
- * @return {Boolean}
*/
-function isAuthTokenRequired(command) {
- return !_.contains(['Log', 'Authenticate', 'BeginSignIn', 'SetPassword'], command);
+function isAuthTokenRequired(command: string): boolean {
+ return !['Log', 'Authenticate', 'BeginSignIn', 'SetPassword'].includes(command);
}
/**
* Adds default values to our request data
- *
- * @param {String} command
- * @param {Object} parameters
- * @returns {Object}
*/
-export default function enhanceParameters(command, parameters) {
+export default function enhanceParameters(command: string, parameters: Record): Record {
const finalParameters = {...parameters};
if (isAuthTokenRequired(command)) {
@@ -44,7 +35,7 @@ export default function enhanceParameters(command, parameters) {
finalParameters.api_setCookie = false;
// Include current user's email in every request and the server logs
- finalParameters.email = lodashGet(parameters, 'email', NetworkStore.getCurrentUserEmail());
+ finalParameters.email = parameters.email ?? NetworkStore.getCurrentUserEmail();
return finalParameters;
}
diff --git a/src/libs/Network/index.js b/src/libs/Network/index.ts
similarity index 77%
rename from src/libs/Network/index.js
rename to src/libs/Network/index.ts
index 2f5dc9460e60..bf38bc33e95a 100644
--- a/src/libs/Network/index.js
+++ b/src/libs/Network/index.ts
@@ -1,9 +1,10 @@
-import lodashGet from 'lodash/get';
import * as ActiveClientManager from '../ActiveClientManager';
import CONST from '../../CONST';
import * as MainQueue from './MainQueue';
import * as SequentialQueue from './SequentialQueue';
import pkg from '../../../package.json';
+import {Request} from '../../types/onyx';
+import Response from '../../types/onyx/Response';
// We must wait until the ActiveClientManager is ready so that we ensure only the "leader" tab processes any persisted requests
ActiveClientManager.isReady().then(() => {
@@ -15,16 +16,10 @@ ActiveClientManager.isReady().then(() => {
/**
* Perform a queued post request
- *
- * @param {String} command
- * @param {*} [data]
- * @param {String} [type]
- * @param {Boolean} [shouldUseSecure] - Whether we should use the secure API
- * @returns {Promise}
*/
-function post(command, data = {}, type = CONST.NETWORK.METHOD.POST, shouldUseSecure = false) {
+function post(command: string, data: Record = {}, type = CONST.NETWORK.METHOD.POST, shouldUseSecure = false): Promise {
return new Promise((resolve, reject) => {
- const request = {
+ const request: Request = {
command,
data,
type,
@@ -35,8 +30,8 @@ function post(command, data = {}, type = CONST.NETWORK.METHOD.POST, shouldUseSec
// (e.g. any requests currently happening when the user logs out are cancelled)
request.data = {
...data,
- shouldRetry: lodashGet(data, 'shouldRetry', true),
- canCancel: lodashGet(data, 'canCancel', true),
+ shouldRetry: data?.shouldRetry ?? true,
+ canCancel: data?.canCancel ?? true,
appversion: pkg.version,
};
@@ -50,7 +45,7 @@ function post(command, data = {}, type = CONST.NETWORK.METHOD.POST, shouldUseSec
// This check is mainly used to prevent API commands from triggering calls to MainQueue.process() from inside the context of a previous
// call to MainQueue.process() e.g. calling a Log command without this would cause the requests in mainQueue to double process
// since we call Log inside MainQueue.process().
- const shouldProcessImmediately = lodashGet(request, 'data.shouldProcessImmediately', true);
+ const shouldProcessImmediately = request?.data?.shouldProcessImmediately ?? true;
if (!shouldProcessImmediately) {
return;
}
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index 051c19312f09..82714dbcbe11 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -364,7 +364,8 @@ function getLastMessageTextForReport(report) {
if (ReportUtils.isReportMessageAttachment({text: report.lastMessageText, html: report.lastMessageHtml, translationKey: report.lastMessageTranslationKey})) {
lastMessageTextFromReport = `[${Localize.translateLocal(report.lastMessageTranslationKey || 'common.attachment')}]`;
} else if (ReportActionUtils.isMoneyRequestAction(lastReportAction)) {
- lastMessageTextFromReport = ReportUtils.getReportPreviewMessage(report, lastReportAction, true);
+ const properSchemaForMoneyRequestMessage = ReportUtils.getReportPreviewMessage(report, lastReportAction, true);
+ lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForMoneyRequestMessage);
} else if (ReportActionUtils.isReportPreviewAction(lastReportAction)) {
const iouReport = ReportUtils.getReport(ReportActionUtils.getIOUReportIDFromReportActionPreview(lastReportAction));
const lastIOUMoneyReport = _.find(
@@ -375,6 +376,8 @@ function getLastMessageTextForReport(report) {
ReportActionUtils.isMoneyRequestAction(reportAction),
);
lastMessageTextFromReport = ReportUtils.getReportPreviewMessage(iouReport, lastIOUMoneyReport, true);
+ } else if (ReportActionUtils.isDeletedParentAction(lastReportAction) && ReportUtils.isChatReport(report)) {
+ lastMessageTextFromReport = ReportUtils.getDeletedParentActionMessageForChatReport(lastReportAction);
} else if (ReportActionUtils.isModifiedExpenseAction(lastReportAction)) {
const properSchemaForModifiedExpenseMessage = ReportUtils.getModifiedExpenseMessage(lastReportAction);
lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForModifiedExpenseMessage, true);
@@ -521,7 +524,7 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, {
}
result.isIOUReportOwner = ReportUtils.isIOUOwnedByCurrentUser(result);
- result.iouReportAmount = ReportUtils.getMoneyRequestTotal(result);
+ result.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(result);
if (!hasMultipleParticipants) {
result.login = personalDetail.login;
@@ -1525,6 +1528,20 @@ function getHeaderMessage(hasSelectableOptions, hasUserToInvite, searchValue, ma
return '';
}
+/**
+ * Helper method for non-user lists (eg. categories and tags) that returns the text to be used for the header's message and title (if any)
+ *
+ * @param {Boolean} hasSelectableOptions
+ * @param {String} searchValue
+ * @return {String}
+ */
+function getHeaderMessageForNonUserList(hasSelectableOptions, searchValue) {
+ if (searchValue && !hasSelectableOptions) {
+ return Localize.translate(preferredLocale, 'common.noResultsFound');
+ }
+ return '';
+}
+
/**
* Helper method to check whether an option can show tooltip or not
* @param {Object} option
@@ -1544,6 +1561,7 @@ export {
getShareDestinationOptions,
getMemberInviteOptions,
getHeaderMessage,
+ getHeaderMessageForNonUserList,
getPersonalDetailsForAccountIDs,
getIOUConfirmationOptionsFromPayeePersonalDetail,
getIOUConfirmationOptionsFromParticipants,
diff --git a/src/libs/PaymentUtils.ts b/src/libs/PaymentUtils.ts
index 64260569639e..7ea935577fb1 100644
--- a/src/libs/PaymentUtils.ts
+++ b/src/libs/PaymentUtils.ts
@@ -1,19 +1,13 @@
-import {SvgProps} from 'react-native-svg';
import BankAccountModel from './models/BankAccount';
import getBankIcon from '../components/Icon/BankIcons';
import CONST from '../CONST';
import * as Localize from './Localize';
import Fund from '../types/onyx/Fund';
import BankAccount from '../types/onyx/BankAccount';
+import PaymentMethod from '../types/onyx/PaymentMethod';
type AccountType = BankAccount['accountType'] | Fund['accountType'];
-type PaymentMethod = (BankAccount | Fund) & {
- description: string;
- icon: React.FC;
- iconSize?: number;
-};
-
/**
* Check to see if user has either a debit card or personal bank account added
*/
diff --git a/src/libs/PolicyUtils.js b/src/libs/PolicyUtils.js
index 347a825f59cc..6bbae72f1d80 100644
--- a/src/libs/PolicyUtils.js
+++ b/src/libs/PolicyUtils.js
@@ -174,7 +174,7 @@ function getMemberAccountIDsForWorkspace(policyMembers, personalDetails) {
if (!personalDetail || !personalDetail.login) {
return;
}
- memberEmailsToAccountIDs[personalDetail.login] = accountID;
+ memberEmailsToAccountIDs[personalDetail.login] = Number(accountID);
});
return memberEmailsToAccountIDs;
}
diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts
index cdc45cb119d5..9fa7ebdc6559 100644
--- a/src/libs/ReceiptUtils.ts
+++ b/src/libs/ReceiptUtils.ts
@@ -6,10 +6,13 @@ import ReceiptHTML from '../../assets/images/receipt-html.png';
import ReceiptDoc from '../../assets/images/receipt-doc.png';
import ReceiptGeneric from '../../assets/images/receipt-generic.png';
import ReceiptSVG from '../../assets/images/receipt-svg.png';
+import {Transaction} from '../types/onyx';
+import ROUTES from '../ROUTES';
type ThumbnailAndImageURI = {
image: ImageSourcePropType | string;
thumbnail: string | null;
+ transaction?: Transaction;
};
type FileNameAndExtension = {
@@ -20,12 +23,21 @@ type FileNameAndExtension = {
/**
* Grab the appropriate receipt image and thumbnail URIs based on file type
*
- * @param path URI to image, i.e. blob:new.expensify.com/9ef3a018-4067-47c6-b29f-5f1bd35f213d or expensify.com/receipts/w_e616108497ef940b7210ec6beb5a462d01a878f4.jpg
- * @param filename of uploaded image or last part of remote URI
+ * @param transaction
*/
-function getThumbnailAndImageURIs(path: string, filename: string): ThumbnailAndImageURI {
+function getThumbnailAndImageURIs(transaction: Transaction): ThumbnailAndImageURI {
+ // URI to image, i.e. blob:new.expensify.com/9ef3a018-4067-47c6-b29f-5f1bd35f213d or expensify.com/receipts/w_e616108497ef940b7210ec6beb5a462d01a878f4.jpg
+ const path = transaction?.receipt?.source ?? '';
+ // filename of uploaded image or last part of remote URI
+ const filename = transaction?.filename ?? '';
const isReceiptImage = Str.isImage(filename);
+ const hasEReceipt = transaction?.hasEReceipt;
+
+ if (hasEReceipt) {
+ return {thumbnail: null, image: ROUTES.ERECEIPT.getRoute(transaction.transactionID), transaction};
+ }
+
// For local files, we won't have a thumbnail yet
if (isReceiptImage && (path.startsWith('blob:') || path.startsWith('file:'))) {
return {thumbnail: null, image: path};
diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js
index fa1883dd9b98..d0f0b35d5f9a 100644
--- a/src/libs/ReportActionsUtils.js
+++ b/src/libs/ReportActionsUtils.js
@@ -380,10 +380,11 @@ function shouldReportActionBeVisibleAsLastAction(reportAction) {
}
// If a whisper action is the REPORTPREVIEW action, we are displaying it.
+ // If the action's message text is empty and it is not a deleted parent with visible child actions, hide it. Else, consider the action to be displayable.
return (
shouldReportActionBeVisible(reportAction, reportAction.reportActionID) &&
!(isWhisperAction(reportAction) && !isReportPreviewAction(reportAction) && !isMoneyRequestAction(reportAction)) &&
- !isDeletedAction(reportAction)
+ !(isDeletedAction(reportAction) && !isDeletedParentAction(reportAction))
);
}
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index 26f0955b04a8..80c932b8ef61 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -1212,6 +1212,46 @@ function getDisplayNamesWithTooltips(personalDetailsList, isMultipleParticipantR
});
}
+/**
+ * For a deleted parent report action within a chat report,
+ * let us return the appropriate display message
+ *
+ * @param {Object} reportAction - The deleted report action of a chat report for which we need to return message.
+ * @return {String}
+ */
+function getDeletedParentActionMessageForChatReport(reportAction) {
+ // By default, let us display [Deleted message]
+ let deletedMessageText = Localize.translateLocal('parentReportAction.deletedMessage');
+ if (ReportActionsUtils.isCreatedTaskReportAction(reportAction)) {
+ // For canceled task report, let us display [Deleted task]
+ deletedMessageText = Localize.translateLocal('parentReportAction.deletedTask');
+ }
+ return deletedMessageText;
+}
+
+/**
+ * Returns the last visible message for a given report after considering the given optimistic actions
+ *
+ * @param {String} reportID - the report for which last visible message has to be fetched
+ * @param {Object} [actionsToMerge] - the optimistic merge actions that needs to be considered while fetching last visible message
+ * @return {Object}
+ */
+function getLastVisibleMessage(reportID, actionsToMerge = {}) {
+ const report = getReport(reportID);
+ const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID, actionsToMerge);
+
+ // For Chat Report with deleted parent actions, let us fetch the correct message
+ if (ReportActionsUtils.isDeletedParentAction(lastVisibleAction) && isChatReport(report)) {
+ const lastMessageText = getDeletedParentActionMessageForChatReport(lastVisibleAction);
+ return {
+ lastMessageText,
+ };
+ }
+
+ // Fetch the last visible message for report represented by reportID and based on actions to merge.
+ return ReportActionsUtils.getLastVisibleMessage(reportID, actionsToMerge);
+}
+
/**
* Determines if a report has an IOU that is waiting for an action from the current user (either Pay or Add a credit bank account)
*
@@ -1283,7 +1323,7 @@ function hasNonReimbursableTransactions(iouReportID) {
* @param {Object} allReportsDict
* @returns {Number}
*/
-function getMoneyRequestTotal(report, allReportsDict = null) {
+function getMoneyRequestReimbursableTotal(report, allReportsDict = null) {
const allAvailableReports = allReportsDict || allReports;
let moneyRequestReport;
if (isMoneyRequestReport(report)) {
@@ -1294,7 +1334,6 @@ function getMoneyRequestTotal(report, allReportsDict = null) {
}
if (moneyRequestReport) {
const total = lodashGet(moneyRequestReport, 'total', 0);
-
if (total !== 0) {
// There is a possibility that if the Expense report has a negative total.
// This is because there are instances where you can get a credit back on your card,
@@ -1305,6 +1344,45 @@ function getMoneyRequestTotal(report, allReportsDict = null) {
return 0;
}
+/**
+ * @param {Object} report
+ * @param {Object} allReportsDict
+ * @returns {Object}
+ */
+function getMoneyRequestSpendBreakdown(report, allReportsDict = null) {
+ const allAvailableReports = allReportsDict || allReports;
+ let moneyRequestReport;
+ if (isMoneyRequestReport(report)) {
+ moneyRequestReport = report;
+ }
+ if (allAvailableReports && report.hasOutstandingIOU && report.iouReportID) {
+ moneyRequestReport = allAvailableReports[`${ONYXKEYS.COLLECTION.REPORT}${report.iouReportID}`];
+ }
+ if (moneyRequestReport) {
+ let nonReimbursableSpend = lodashGet(moneyRequestReport, 'nonReimbursableTotal', 0);
+ let reimbursableSpend = lodashGet(moneyRequestReport, 'total', 0);
+
+ if (nonReimbursableSpend + reimbursableSpend !== 0) {
+ // There is a possibility that if the Expense report has a negative total.
+ // This is because there are instances where you can get a credit back on your card,
+ // or you enter a negative expense to “offset” future expenses
+ nonReimbursableSpend = isExpenseReport(moneyRequestReport) ? nonReimbursableSpend * -1 : Math.abs(nonReimbursableSpend);
+ reimbursableSpend = isExpenseReport(moneyRequestReport) ? reimbursableSpend * -1 : Math.abs(reimbursableSpend);
+ const totalDisplaySpend = nonReimbursableSpend + reimbursableSpend;
+ return {
+ nonReimbursableSpend,
+ reimbursableSpend,
+ totalDisplaySpend,
+ };
+ }
+ }
+ return {
+ nonReimbursableSpend: 0,
+ reimbursableSpend: 0,
+ totalDisplaySpend: 0,
+ };
+}
+
/**
* Get the title for a policy expense chat which depends on the role of the policy member seeing this report
*
@@ -1344,7 +1422,7 @@ function getPolicyExpenseChatName(report, policy = undefined) {
* @returns {String}
*/
function getMoneyRequestReportName(report, policy = undefined) {
- const moneyRequestTotal = getMoneyRequestTotal(report);
+ const moneyRequestTotal = getMoneyRequestReimbursableTotal(report);
const formattedAmount = CurrencyUtils.convertToDisplayString(moneyRequestTotal, report.currency);
const payerName = isExpenseReport(report) ? getPolicyName(report, false, policy) : getDisplayNameForParticipant(report.managerID);
const payerPaidAmountMesssage = Localize.translateLocal('iou.payerPaidAmount', {
@@ -1551,7 +1629,7 @@ function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceip
return Localize.translateLocal('iou.didSplitAmount', {formattedAmount, comment});
}
- const totalAmount = getMoneyRequestTotal(report);
+ const totalAmount = getMoneyRequestReimbursableTotal(report);
const payerName = isExpenseReport(report) ? getPolicyName(report) : getDisplayNameForParticipant(report.managerID, true);
const formattedAmount = CurrencyUtils.convertToDisplayString(totalAmount, report.currency);
@@ -2220,7 +2298,7 @@ function buildOptimisticExpenseReport(chatReportID, policyID, payeeAccountID, to
function getIOUReportActionMessage(iouReportID, type, total, comment, currency, paymentType = '', isSettlingUp = false) {
const amount =
type === CONST.IOU.REPORT_ACTION_TYPE.PAY
- ? CurrencyUtils.convertToDisplayString(getMoneyRequestTotal(getReport(iouReportID)), currency)
+ ? CurrencyUtils.convertToDisplayString(getMoneyRequestReimbursableTotal(getReport(iouReportID)), currency)
: CurrencyUtils.convertToDisplayString(total, currency);
let paymentMethodMessage;
@@ -3074,6 +3152,7 @@ function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, betas,
if (
!report ||
!report.reportID ||
+ !report.type ||
report.isHidden ||
(report.participantAccountIDs &&
report.participantAccountIDs.length === 0 &&
@@ -3801,29 +3880,6 @@ function getParticipantsIDs(report) {
return participants;
}
-/**
- * Get the last 3 transactions with receipts of an IOU report that will be displayed on the report preview
- *
- * @param {Object} reportPreviewAction
- * @returns {Object}
- */
-function getReportPreviewDisplayTransactions(reportPreviewAction) {
- const transactionIDs = lodashGet(reportPreviewAction, ['childRecentReceiptTransactionIDs']);
- return _.reduce(
- _.keys(transactionIDs),
- (transactions, transactionID) => {
- if (transactionIDs[transactionID] !== null) {
- const transaction = TransactionUtils.getTransaction(transactionID);
- if (TransactionUtils.hasReceipt(transaction)) {
- transactions.push(transaction);
- }
- }
- return transactions;
- },
- [],
- );
-}
-
/**
* Return iou report action display message
*
@@ -3837,7 +3893,7 @@ function getIOUReportActionDisplayMessage(reportAction) {
const {amount, currency, IOUReportID} = originalMessage;
const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency);
const iouReport = getReport(IOUReportID);
- const payerName = isExpenseReport(iouReport) ? getPolicyName(iouReport) : getDisplayNameForParticipant(iouReport.managerID);
+ const payerName = isExpenseReport(iouReport) ? getPolicyName(iouReport) : getDisplayNameForParticipant(iouReport.managerID, true);
let translationKey;
switch (originalMessage.paymentType) {
case CONST.IOU.PAYMENT_TYPE.ELSEWHERE:
@@ -3872,6 +3928,14 @@ function isReportDraft(report) {
return isExpenseReport(report) && lodashGet(report, 'stateNum') === CONST.REPORT.STATE_NUM.OPEN && lodashGet(report, 'statusNum') === CONST.REPORT.STATUS.OPEN;
}
+/**
+ * @param {Object} report
+ * @returns {Boolean}
+ */
+function shouldUseFullTitleToDisplay(report) {
+ return isMoneyRequestReport(report) || isPolicyExpenseChat(report) || isChatRoom(report) || isChatThread(report) || isTaskReport(report);
+}
+
export {
getReportParticipantsTitle,
isReportMessageAttachment,
@@ -3904,7 +3968,8 @@ export {
hasExpensifyGuidesEmails,
isWaitingForIOUActionFromCurrentUser,
isIOUOwnedByCurrentUser,
- getMoneyRequestTotal,
+ getMoneyRequestReimbursableTotal,
+ getMoneyRequestSpendBreakdown,
canShowReportRecipientLocalTime,
formatReportLastMessageText,
chatIncludesConcierge,
@@ -3919,6 +3984,8 @@ export {
getReport,
getReportIDFromLink,
getRouteFromLink,
+ getDeletedParentActionMessageForChatReport,
+ getLastVisibleMessage,
navigateToDetailsPage,
generateReportID,
hasReportNameError,
@@ -4011,11 +4078,11 @@ export {
canEditMoneyRequest,
buildTransactionThread,
areAllRequestsBeingSmartScanned,
- getReportPreviewDisplayTransactions,
getTransactionsWithReceipts,
hasNonReimbursableTransactions,
hasMissingSmartscanFields,
getIOUReportActionDisplayMessage,
isWaitingForTaskCompleteFromAssignee,
isReportDraft,
+ shouldUseFullTitleToDisplay,
};
diff --git a/src/libs/Request.ts b/src/libs/Request.ts
index 903e70358da9..9c4af4aa7e18 100644
--- a/src/libs/Request.ts
+++ b/src/libs/Request.ts
@@ -3,24 +3,24 @@ import enhanceParameters from './Network/enhanceParameters';
import * as NetworkStore from './Network/NetworkStore';
import Request from '../types/onyx/Request';
import Response from '../types/onyx/Response';
-
-type Middleware = (response: Promise, request: Request, isFromSequentialQueue: boolean) => Promise;
+import Middleware from './Middleware/types';
let middlewares: Middleware[] = [];
-function makeXHR(request: Request): Promise {
+function makeXHR(request: Request): Promise {
const finalParameters = enhanceParameters(request.command, request?.data ?? {});
- return NetworkStore.hasReadRequiredDataFromStorage().then(() => {
+ return NetworkStore.hasReadRequiredDataFromStorage().then((): Promise => {
// If we're using the Supportal token and this is not a Supportal request
// let's just return a promise that will resolve itself.
if (NetworkStore.getSupportAuthToken() && !NetworkStore.isSupportRequest(request.command)) {
return new Promise((resolve) => resolve());
}
- return HttpUtils.xhr(request.command, finalParameters, request.type, request.shouldUseSecure);
- }) as Promise;
+
+ return HttpUtils.xhr(request.command, finalParameters, request.type, request.shouldUseSecure) as Promise;
+ });
}
-function processWithMiddleware(request: Request, isFromSequentialQueue = false): Promise {
+function processWithMiddleware(request: Request, isFromSequentialQueue = false): Promise {
return middlewares.reduce((last, middleware) => middleware(last, request, isFromSequentialQueue), makeXHR(request));
}
diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js
index 314a1d63760e..dd6db33902fb 100644
--- a/src/libs/SidebarUtils.js
+++ b/src/libs/SidebarUtils.js
@@ -158,7 +158,7 @@ function getOrderedReportIDs(currentReportId, allReportsDict, betas, policies, p
report.displayName = ReportUtils.getReportName(report);
// eslint-disable-next-line no-param-reassign
- report.iouReportAmount = ReportUtils.getMoneyRequestTotal(report, allReportsDict);
+ report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReportsDict);
});
// The LHN is split into five distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order:
@@ -384,7 +384,7 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale,
}
result.isIOUReportOwner = ReportUtils.isIOUOwnedByCurrentUser(result);
- result.iouReportAmount = ReportUtils.getMoneyRequestTotal(result);
+ result.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(result);
if (!hasMultipleParticipants) {
result.accountID = personalDetail.accountID;
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index 77fc4f04f99d..31cad217666c 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -6,7 +6,7 @@ import DateUtils from './DateUtils';
import {isExpensifyCard} from './CardUtils';
import * as NumberUtils from './NumberUtils';
import {RecentWaypoint, ReportAction, Transaction} from '../types/onyx';
-import {Receipt, Comment, WaypointCollection} from '../types/onyx/Transaction';
+import {Receipt, Comment, WaypointCollection, Waypoint} from '../types/onyx/Transaction';
type AdditionalTransactionChanges = {comment?: string; waypoints?: WaypointCollection};
@@ -76,8 +76,15 @@ function buildOptimisticTransaction(
};
}
+/**
+ * Check if the transaction has an Ereceipt
+ */
+function hasEReceipt(transaction: Transaction | undefined | null): boolean {
+ return !!transaction?.hasEReceipt;
+}
+
function hasReceipt(transaction: Transaction | undefined | null): boolean {
- return !!transaction?.receipt?.state;
+ return !!transaction?.receipt?.state || hasEReceipt(transaction);
}
function isMerchantMissing(transaction: Transaction) {
@@ -365,13 +372,6 @@ function hasRoute(transaction: Transaction): boolean {
return !!transaction?.routes?.route0?.geometry?.coordinates;
}
-/**
- * Check if the transaction has an Ereceipt
- */
-function hasEreceipt(transaction: Transaction): boolean {
- return !!transaction?.hasEReceipt;
-}
-
/**
* Get the transactions related to a report preview with receipts
* Get the details linked to the IOU reportAction
@@ -399,7 +399,7 @@ function getAllReportTransactions(reportID?: string): Transaction[] {
/**
* Checks if a waypoint has a valid address
*/
-function waypointHasValidAddress(waypoint: RecentWaypoint | null): boolean {
+function waypointHasValidAddress(waypoint: RecentWaypoint | Waypoint): boolean {
return !!waypoint?.address?.trim();
}
@@ -423,7 +423,7 @@ function getValidWaypoints(waypoints: WaypointCollection, reArrangeIndexes = fal
let lastWaypointIndex = -1;
- return waypointValues.reduce((acc, currentWaypoint, index) => {
+ return waypointValues.reduce((acc, currentWaypoint, index) => {
const previousWaypoint = waypointValues[lastWaypointIndex];
// Check if the waypoint has a valid address
@@ -472,7 +472,7 @@ export {
getLinkedTransaction,
getAllReportTransactions,
hasReceipt,
- hasEreceipt,
+ hasEReceipt,
hasRoute,
isReceiptBeingScanned,
getValidWaypoints,
diff --git a/src/libs/UpdateMultilineInputRange/index.ios.js b/src/libs/UpdateMultilineInputRange/index.ios.js
index 85ed529a33bc..4c10f768a2a2 100644
--- a/src/libs/UpdateMultilineInputRange/index.ios.js
+++ b/src/libs/UpdateMultilineInputRange/index.ios.js
@@ -8,8 +8,9 @@
* See https://github.com/Expensify/App/issues/20836 for more details.
*
* @param {Object} input the input element
+ * @param {boolean} shouldAutoFocus
*/
-export default function updateMultilineInputRange(input) {
+export default function updateMultilineInputRange(input, shouldAutoFocus = true) {
if (!input) {
return;
}
@@ -19,5 +20,7 @@ export default function updateMultilineInputRange(input) {
* Issue: does not scroll multiline input when text exceeds the maximum number of lines
* For more details: https://github.com/Expensify/App/pull/27702#issuecomment-1728651132
*/
- input.focus();
+ if (shouldAutoFocus) {
+ input.focus();
+ }
}
diff --git a/src/libs/UpdateMultilineInputRange/index.js b/src/libs/UpdateMultilineInputRange/index.js
index 179d30dc611d..66fb1889be21 100644
--- a/src/libs/UpdateMultilineInputRange/index.js
+++ b/src/libs/UpdateMultilineInputRange/index.js
@@ -8,8 +8,10 @@
* See https://github.com/Expensify/App/issues/20836 for more details.
*
* @param {Object} input the input element
+ * @param {boolean} shouldAutoFocus
*/
-export default function updateMultilineInputRange(input) {
+// eslint-disable-next-line no-unused-vars
+export default function updateMultilineInputRange(input, shouldAutoFocus = true) {
if (!input) {
return;
}
diff --git a/src/libs/actions/App.js b/src/libs/actions/App.js
index a1d64154906c..75520d483f98 100644
--- a/src/libs/actions/App.js
+++ b/src/libs/actions/App.js
@@ -350,6 +350,40 @@ function createWorkspaceAndNavigateToIt(policyOwnerEmail = '', makeMeAdmin = fal
.then(endSignOnTransition);
}
+/**
+ * Create a new draft workspace and navigate to it
+ *
+ * @param {String} [policyOwnerEmail] Optional, the email of the account to make the owner of the policy
+ * @param {String} [policyName] Optional, custom policy name we will use for created workspace
+ * @param {Boolean} [transitionFromOldDot] Optional, if the user is transitioning from old dot
+ */
+function createWorkspaceWithPolicyDraftAndNavigateToIt(policyOwnerEmail = '', policyName = '', transitionFromOldDot = false) {
+ const policyID = Policy.generatePolicyID();
+ Policy.createDraftInitialWorkspace(policyOwnerEmail, policyName, policyID);
+
+ Navigation.isNavigationReady()
+ .then(() => {
+ if (transitionFromOldDot) {
+ // We must call goBack() to remove the /transition route from history
+ Navigation.goBack(ROUTES.HOME);
+ }
+ Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(policyID));
+ })
+ .then(endSignOnTransition);
+}
+
+/**
+ * Create a new workspace and delete the draft
+ *
+ * @param {String} [policyID] the ID of the policy to use
+ * @param {String} [policyName] custom policy name we will use for created workspace
+ * @param {String} [policyOwnerEmail] Optional, the email of the account to make the owner of the policy
+ * @param {Boolean} [makeMeAdmin] Optional, leave the calling account as an admin on the policy
+ */
+function savePolicyDraftByNewWorkspace(policyID, policyName, policyOwnerEmail = '', makeMeAdmin = false) {
+ Policy.createWorkspace(policyOwnerEmail, makeMeAdmin, policyName, policyID);
+}
+
/**
* This action runs when the Navigator is ready and the current route changes
*
@@ -389,9 +423,6 @@ function setUpPoliciesAndNavigate(session, shouldNavigateToAdminChat) {
// Sign out the current user if we're transitioning with a different user
const isTransitioning = Str.startsWith(url.pathname, Str.normalizeUrl(ROUTES.TRANSITION_BETWEEN_APPS));
- if (isLoggingInAsNewUser && isTransitioning) {
- Session.signOut();
- }
const shouldCreateFreePolicy = !isLoggingInAsNewUser && isTransitioning && exitTo === ROUTES.WORKSPACE_NEW;
if (shouldCreateFreePolicy) {
@@ -527,4 +558,6 @@ export {
createWorkspaceAndNavigateToIt,
getMissingOnyxUpdates,
finalReconnectAppAfterActivatingReliableUpdates,
+ savePolicyDraftByNewWorkspace,
+ createWorkspaceWithPolicyDraftAndNavigateToIt,
};
diff --git a/src/libs/actions/BankAccounts.js b/src/libs/actions/BankAccounts.ts
similarity index 55%
rename from src/libs/actions/BankAccounts.js
rename to src/libs/actions/BankAccounts.ts
index 4d3c880b5983..249d7de9293a 100644
--- a/src/libs/actions/BankAccounts.js
+++ b/src/libs/actions/BankAccounts.ts
@@ -7,6 +7,10 @@ import * as PlaidDataProps from '../../pages/ReimbursementAccount/plaidDataPropT
import Navigation from '../Navigation/Navigation';
import ROUTES from '../../ROUTES';
import * as ReimbursementAccount from './ReimbursementAccount';
+import type PlaidBankAccount from '../../types/onyx/PlaidBankAccount';
+import type {ACHContractStepProps, BankAccountStepProps, CompanyStepProps, OnfidoData, ReimbursementAccountProps, RequestorStepProps} from '../../types/onyx/ReimbursementAccountDraft';
+import type {OnyxData} from '../../types/onyx/Request';
+import type {BankAccountStep, BankAccountSubStep} from '../../types/onyx/ReimbursementAccount';
export {
goToWithdrawalAccountSetupStep,
@@ -23,7 +27,13 @@ export {
export {openPlaidBankAccountSelector, openPlaidBankLogin} from './Plaid';
export {openOnfidoFlow, answerQuestionsForWallet, verifyIdentity, acceptWalletTerms} from './Wallet';
-function clearPlaid() {
+type BankAccountCompanyInformation = BankAccountStepProps & CompanyStepProps & ReimbursementAccountProps;
+
+type ReimbursementAccountStep = BankAccountStep | '';
+
+type ReimbursementAccountSubStep = BankAccountSubStep | '';
+
+function clearPlaid(): Promise {
Onyx.set(ONYXKEYS.PLAID_LINK_TOKEN, '');
return Onyx.set(ONYXKEYS.PLAID_DATA, PlaidDataProps.plaidDataDefaultProps);
@@ -35,9 +45,8 @@ function openPlaidView() {
/**
* Open the personal bank account setup flow, with an optional exitReportID to redirect to once the flow is finished.
- * @param {String} exitReportID
*/
-function openPersonalBankAccountSetupView(exitReportID) {
+function openPersonalBankAccountSetupView(exitReportID: string) {
clearPlaid().then(() => {
if (exitReportID) {
Onyx.merge(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {exitReportID});
@@ -57,10 +66,8 @@ function clearOnfidoToken() {
/**
* Helper method to build the Onyx data required during setup of a Verified Business Bank Account
- * @param {String | undefined} currentStep The name of the bank account setup step for which we will update the draft value when we receive the response from the API.
- * @returns {Object}
*/
-function getVBBADataForOnyx(currentStep = undefined) {
+function getVBBADataForOnyx(currentStep?: BankAccountStep): OnyxData {
return {
optimisticData: [
{
@@ -103,14 +110,20 @@ function getVBBADataForOnyx(currentStep = undefined) {
/**
* Submit Bank Account step with Plaid data so php can perform some checks.
- *
- * @param {Number} bankAccountID
- * @param {Object} selectedPlaidBankAccount
*/
-function connectBankAccountWithPlaid(bankAccountID, selectedPlaidBankAccount) {
+function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAccount: PlaidBankAccount) {
const commandName = 'ConnectBankAccountWithPlaid';
- const parameters = {
+ type ConnectBankAccountWithPlaidParams = {
+ bankAccountID: number;
+ routingNumber: string;
+ accountNumber: string;
+ bank?: string;
+ plaidAccountID: string;
+ plaidAccessToken: string;
+ };
+
+ const parameters: ConnectBankAccountWithPlaidParams = {
bankAccountID,
routingNumber: selectedPlaidBankAccount.routingNumber,
accountNumber: selectedPlaidBankAccount.accountNumber,
@@ -125,13 +138,23 @@ function connectBankAccountWithPlaid(bankAccountID, selectedPlaidBankAccount) {
/**
* Adds a bank account via Plaid
*
- * @param {Object} account
* @TODO offline pattern for this command will have to be added later once the pattern B design doc is complete
*/
-function addPersonalBankAccount(account) {
+function addPersonalBankAccount(account: PlaidBankAccount) {
const commandName = 'AddPersonalBankAccount';
- const parameters = {
+ type AddPersonalBankAccountParams = {
+ addressName: string;
+ routingNumber: string;
+ accountNumber: string;
+ isSavings: boolean;
+ setupType: string;
+ bank?: string;
+ plaidAccountID: string;
+ plaidAccessToken: string;
+ };
+
+ const parameters: AddPersonalBankAccountParams = {
addressName: account.addressName,
routingNumber: account.routingNumber,
accountNumber: account.accountNumber,
@@ -142,7 +165,7 @@ function addPersonalBankAccount(account) {
plaidAccessToken: account.plaidAccessToken,
};
- const onyxData = {
+ const onyxData: OnyxData = {
optimisticData: [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -180,107 +203,94 @@ function addPersonalBankAccount(account) {
API.write(commandName, parameters, onyxData);
}
-function deletePaymentBankAccount(bankAccountID) {
- API.write(
- 'DeletePaymentBankAccount',
- {
- bankAccountID,
- },
- {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.BANK_ACCOUNT_LIST}`,
- value: {[bankAccountID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}},
- },
- ],
-
- // Sometimes pusher updates aren't received when we close the App while still offline,
- // so we are setting the bankAccount to null here to ensure that it gets cleared out once we come back online.
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.BANK_ACCOUNT_LIST}`,
- value: {[bankAccountID]: null},
- },
- ],
- },
- );
+function deletePaymentBankAccount(bankAccountID: number) {
+ type DeletePaymentBankAccountParams = {bankAccountID: number};
+
+ const parameters: DeletePaymentBankAccountParams = {bankAccountID};
+
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.BANK_ACCOUNT_LIST}`,
+ value: {[bankAccountID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}},
+ },
+ ],
+
+ // Sometimes pusher updates aren't received when we close the App while still offline,
+ // so we are setting the bankAccount to null here to ensure that it gets cleared out once we come back online.
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.BANK_ACCOUNT_LIST}`,
+ value: {[bankAccountID]: null},
+ },
+ ],
+ };
+
+ API.write('DeletePaymentBankAccount', parameters, onyxData);
}
/**
* Update the user's personal information on the bank account in database.
*
* This action is called by the requestor step in the Verified Bank Account flow
- *
- * @param {Object} params
- *
- * @param {String} [params.dob]
- * @param {String} [params.firstName]
- * @param {String} [params.lastName]
- * @param {String} [params.requestorAddressStreet]
- * @param {String} [params.requestorAddressCity]
- * @param {String} [params.requestorAddressState]
- * @param {String} [params.requestorAddressZipCode]
- * @param {String} [params.ssnLast4]
- * @param {String} [params.isControllingOfficer]
- * @param {Object} [params.onfidoData]
- * @param {Boolean} [params.isOnfidoSetupComplete]
*/
-function updatePersonalInformationForBankAccount(params) {
+function updatePersonalInformationForBankAccount(params: RequestorStepProps) {
API.write('UpdatePersonalInformationForBankAccount', params, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.REQUESTOR));
}
-/**
- * @param {Number} bankAccountID
- * @param {String} validateCode
- */
-function validateBankAccount(bankAccountID, validateCode) {
- API.write(
- 'ValidateBankAccountWithTransactions',
- {
- bankAccountID,
- validateCode,
- },
- {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- value: {
- isLoading: true,
- errors: null,
- },
+function validateBankAccount(bankAccountID: number, validateCode: string) {
+ type ValidateBankAccountWithTransactionsParams = {
+ bankAccountID: number;
+ validateCode: string;
+ };
+
+ const parameters: ValidateBankAccountWithTransactionsParams = {
+ bankAccountID,
+ validateCode,
+ };
+
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {
+ isLoading: true,
+ errors: null,
},
- ],
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- value: {
- isLoading: false,
- },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {
+ isLoading: false,
},
- ],
- failureData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- value: {
- isLoading: false,
- },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {
+ isLoading: false,
},
- ],
- },
- );
+ },
+ ],
+ };
+
+ API.write('ValidateBankAccountWithTransactions', parameters, onyxData);
}
function clearReimbursementAccount() {
Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, null);
}
-function openReimbursementAccountPage(stepToOpen, subStep, localCurrentStep) {
- const onyxData = {
+function openReimbursementAccountPage(stepToOpen: ReimbursementAccountStep, subStep: ReimbursementAccountSubStep, localCurrentStep: ReimbursementAccountStep) {
+ const onyxData: OnyxData = {
optimisticData: [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -310,122 +320,104 @@ function openReimbursementAccountPage(stepToOpen, subStep, localCurrentStep) {
],
};
- const param = {
+ type OpenReimbursementAccountPageParams = {
+ stepToOpen: ReimbursementAccountStep;
+ subStep: ReimbursementAccountSubStep;
+ localCurrentStep: ReimbursementAccountStep;
+ };
+
+ const parameters: OpenReimbursementAccountPageParams = {
stepToOpen,
subStep,
localCurrentStep,
};
- return API.read('OpenReimbursementAccountPage', param, onyxData);
+ return API.read('OpenReimbursementAccountPage', parameters, onyxData);
}
/**
* Updates the bank account in the database with the company step data
- *
- * @param {Object} bankAccount
- * @param {Number} [bankAccount.bankAccountID]
- *
- * Fields from BankAccount step
- * @param {String} [bankAccount.routingNumber]
- * @param {String} [bankAccount.accountNumber]
- * @param {String} [bankAccount.bankName]
- * @param {String} [bankAccount.plaidAccountID]
- * @param {String} [bankAccount.plaidAccessToken]
- * @param {Boolean} [bankAccount.isSavings]
- *
- * Fields from Company step
- * @param {String} [bankAccount.companyName]
- * @param {String} [bankAccount.addressStreet]
- * @param {String} [bankAccount.addressCity]
- * @param {String} [bankAccount.addressState]
- * @param {String} [bankAccount.addressZipCode]
- * @param {String} [bankAccount.companyPhone]
- * @param {String} [bankAccount.website]
- * @param {String} [bankAccount.companyTaxID]
- * @param {String} [bankAccount.incorporationType]
- * @param {String} [bankAccount.incorporationState]
- * @param {String} [bankAccount.incorporationDate]
- * @param {Boolean} [bankAccount.hasNoConnectionToCannabis]
- * @param {String} policyID
*/
-function updateCompanyInformationForBankAccount(bankAccount, policyID) {
- API.write('UpdateCompanyInformationForBankAccount', {...bankAccount, policyID}, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.COMPANY));
+function updateCompanyInformationForBankAccount(bankAccount: BankAccountCompanyInformation, policyID: string) {
+ type UpdateCompanyInformationForBankAccountParams = BankAccountCompanyInformation & {policyID: string};
+
+ const parameters: UpdateCompanyInformationForBankAccountParams = {...bankAccount, policyID};
+
+ API.write('UpdateCompanyInformationForBankAccount', parameters, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.COMPANY));
}
/**
* Add beneficial owners for the bank account, accept the ACH terms and conditions and verify the accuracy of the information provided
- *
- * @param {Object} params
- *
- * // ACH Contract Step
- * @param {Boolean} [params.ownsMoreThan25Percent]
- * @param {Boolean} [params.hasOtherBeneficialOwners]
- * @param {Boolean} [params.acceptTermsAndConditions]
- * @param {Boolean} [params.certifyTrueInformation]
- * @param {String} [params.beneficialOwners]
*/
-function updateBeneficialOwnersForBankAccount(params) {
- API.write('UpdateBeneficialOwnersForBankAccount', {...params}, getVBBADataForOnyx());
+function updateBeneficialOwnersForBankAccount(params: ACHContractStepProps) {
+ API.write('UpdateBeneficialOwnersForBankAccount', params, getVBBADataForOnyx());
}
/**
* Create the bank account with manually entered data.
*
- * @param {number} [bankAccountID]
- * @param {String} [accountNumber]
- * @param {String} [routingNumber]
- * @param {String} [plaidMask]
- *
*/
-function connectBankAccountManually(bankAccountID, accountNumber, routingNumber, plaidMask) {
- API.write(
- 'ConnectBankAccountManually',
- {
- bankAccountID,
- accountNumber,
- routingNumber,
- plaidMask,
- },
- getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT),
- );
+function connectBankAccountManually(bankAccountID: number, accountNumber?: string, routingNumber?: string, plaidMask?: string) {
+ type ConnectBankAccountManuallyParams = {
+ bankAccountID: number;
+ accountNumber?: string;
+ routingNumber?: string;
+ plaidMask?: string;
+ };
+
+ const parameters: ConnectBankAccountManuallyParams = {
+ bankAccountID,
+ accountNumber,
+ routingNumber,
+ plaidMask,
+ };
+
+ API.write('ConnectBankAccountManually', parameters, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT));
}
/**
* Verify the user's identity via Onfido
- *
- * @param {Number} bankAccountID
- * @param {Object} onfidoData
*/
-function verifyIdentityForBankAccount(bankAccountID, onfidoData) {
- API.write(
- 'VerifyIdentityForBankAccount',
- {
- bankAccountID,
- onfidoData: JSON.stringify(onfidoData),
- },
- getVBBADataForOnyx(),
- );
+function verifyIdentityForBankAccount(bankAccountID: number, onfidoData: OnfidoData) {
+ type VerifyIdentityForBankAccountParams = {
+ bankAccountID: number;
+ onfidoData: string;
+ };
+
+ const parameters: VerifyIdentityForBankAccountParams = {
+ bankAccountID,
+ onfidoData: JSON.stringify(onfidoData),
+ };
+
+ API.write('VerifyIdentityForBankAccount', parameters, getVBBADataForOnyx());
}
function openWorkspaceView() {
- API.read('OpenWorkspaceView');
+ API.read('OpenWorkspaceView', {}, {});
}
-function handlePlaidError(bankAccountID, error, error_description, plaidRequestID) {
- API.write('BankAccount_HandlePlaidError', {
+function handlePlaidError(bankAccountID: number, error: string, errorDescription: string, plaidRequestID: string) {
+ type BankAccountHandlePlaidErrorParams = {
+ bankAccountID: number;
+ error: string;
+ errorDescription: string;
+ plaidRequestID: string;
+ };
+
+ const parameters: BankAccountHandlePlaidErrorParams = {
bankAccountID,
error,
- error_description,
+ errorDescription,
plaidRequestID,
- });
+ };
+
+ API.write('BankAccount_HandlePlaidError', parameters);
}
/**
* Set the reimbursement account loading so that it happens right away, instead of when the API command is processed.
- *
- * @param {Boolean} isLoading
*/
-function setReimbursementAccountLoading(isLoading) {
+function setReimbursementAccountLoading(isLoading: boolean) {
Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {isLoading});
}
diff --git a/src/libs/actions/Chronos.ts b/src/libs/actions/Chronos.ts
index 1b46a68a1afe..ce821e524722 100644
--- a/src/libs/actions/Chronos.ts
+++ b/src/libs/actions/Chronos.ts
@@ -1,11 +1,11 @@
-import Onyx from 'react-native-onyx';
+import Onyx, {OnyxUpdate} from 'react-native-onyx';
import CONST from '../../CONST';
import ONYXKEYS from '../../ONYXKEYS';
import * as API from '../API';
import {ChronosOOOEvent} from '../../types/onyx/OriginalMessage';
const removeEvent = (reportID: string, reportActionID: string, eventID: string, events: ChronosOOOEvent[]) => {
- const optimisticData = [
+ const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
@@ -20,7 +20,7 @@ const removeEvent = (reportID: string, reportActionID: string, eventID: string,
},
];
- const successData = [
+ const successData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
@@ -32,7 +32,7 @@ const removeEvent = (reportID: string, reportActionID: string, eventID: string,
},
];
- const failureData = [
+ const failureData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
diff --git a/src/libs/actions/DemoActions.js b/src/libs/actions/DemoActions.js
index 29c983c35262..e7ce02d2796b 100644
--- a/src/libs/actions/DemoActions.js
+++ b/src/libs/actions/DemoActions.js
@@ -17,7 +17,7 @@ Onyx.connect({
function runMoney2020Demo() {
// Try to navigate to existing demo chat if it exists in Onyx
- const money2020AccountID = Number(Config ? Config.EXPENSIFY_ACCOUNT_ID_MONEY2020 : 15864555);
+ const money2020AccountID = Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_MONEY2020', 15864555));
const existingChatReport = ReportUtils.getChatByParticipants([money2020AccountID]);
if (existingChatReport) {
// We must call goBack() to remove the demo route from nav history
@@ -63,7 +63,7 @@ function runDemoByURL(url = '') {
});
} else {
// No demo is being run, so clear out demo info
- Onyx.set(ONYXKEYS.DEMO_INFO, null);
+ Onyx.set(ONYXKEYS.DEMO_INFO, {});
}
}
diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js
index 4e3fc91fc4d9..b3fa78d07614 100644
--- a/src/libs/actions/IOU.js
+++ b/src/libs/actions/IOU.js
@@ -1,6 +1,7 @@
import Onyx from 'react-native-onyx';
import _ from 'underscore';
import lodashGet from 'lodash/get';
+import lodashHas from 'lodash/has';
import Str from 'expensify-common/lib/str';
import {format} from 'date-fns';
import CONST from '../../CONST';
@@ -1064,6 +1065,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
let oneOnOneChatReport;
let isNewOneOnOneChatReport = false;
let shouldCreateOptimisticPersonalDetails = false;
+ const personalDetailExists = lodashHas(allPersonalDetails, accountID);
// If this is a split between two people only and the function
// wasn't provided with an existing group chat report id
@@ -1072,11 +1074,11 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
// entering code that creates optimistic personal details
if ((!hasMultipleParticipants && !existingSplitChatReportID) || isOwnPolicyExpenseChat) {
oneOnOneChatReport = splitChatReport;
- shouldCreateOptimisticPersonalDetails = !existingSplitChatReport;
+ shouldCreateOptimisticPersonalDetails = !existingSplitChatReport && !personalDetailExists;
} else {
const existingChatReport = ReportUtils.getChatByParticipants([accountID]);
isNewOneOnOneChatReport = !existingChatReport;
- shouldCreateOptimisticPersonalDetails = isNewOneOnOneChatReport;
+ shouldCreateOptimisticPersonalDetails = isNewOneOnOneChatReport && !personalDetailExists;
oneOnOneChatReport = existingChatReport || ReportUtils.buildOptimisticChatReport([accountID]);
}
@@ -2645,7 +2647,7 @@ function submitReport(expenseReport) {
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`,
value: {
- state: CONST.REPORT.STATE.OPEN,
+ statusNum: CONST.REPORT.STATUS.OPEN,
stateNum: CONST.REPORT.STATE_NUM.OPEN,
},
},
diff --git a/src/libs/actions/PaymentMethods.js b/src/libs/actions/PaymentMethods.js
deleted file mode 100644
index 0ed6f8b036bb..000000000000
--- a/src/libs/actions/PaymentMethods.js
+++ /dev/null
@@ -1,356 +0,0 @@
-import _ from 'underscore';
-import {createRef} from 'react';
-import Onyx from 'react-native-onyx';
-import ONYXKEYS from '../../ONYXKEYS';
-import * as API from '../API';
-import CONST from '../../CONST';
-import Navigation from '../Navigation/Navigation';
-import * as CardUtils from '../CardUtils';
-import ROUTES from '../../ROUTES';
-
-/**
- * Sets up a ref to an instance of the KYC Wall component.
- */
-const kycWallRef = createRef();
-
-/**
- * When we successfully add a payment method or pass the KYC checks we will continue with our setup action if we have one set.
- */
-function continueSetup() {
- if (!kycWallRef.current || !kycWallRef.current.continue) {
- Navigation.goBack(ROUTES.HOME);
- return;
- }
-
- // Close the screen (Add Debit Card, Add Bank Account, or Enable Payments) on success and continue with setup
- Navigation.goBack(ROUTES.HOME);
- kycWallRef.current.continue();
-}
-
-function openWalletPage() {
- const onyxData = {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
- value: true,
- },
- ],
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
- value: false,
- },
- ],
- failureData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
- value: false,
- },
- ],
- };
-
- return API.read('OpenPaymentsPage', {}, onyxData);
-}
-
-/**
- *
- * @param {Number} bankAccountID
- * @param {Number} fundID
- * @param {Object} previousPaymentMethod
- * @param {Object} currentPaymentMethod
- * @param {Boolean} isOptimisticData
- * @return {Array}
- *
- */
-function getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, isOptimisticData = true) {
- const onyxData = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.USER_WALLET,
- value: {
- walletLinkedAccountID: bankAccountID || fundID,
- walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD,
- },
- },
- ];
-
- // Only clear the error if this is optimistic data. If this is failure data, we do not want to clear the error that came from the server.
- if (isOptimisticData) {
- onyxData[0].value.errors = null;
- }
-
- if (previousPaymentMethod) {
- onyxData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: previousPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
- value: {
- [previousPaymentMethod.methodID]: {
- isDefault: !isOptimisticData,
- },
- },
- });
- }
-
- if (currentPaymentMethod) {
- onyxData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: currentPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
- value: {
- [currentPaymentMethod.methodID]: {
- isDefault: isOptimisticData,
- },
- },
- });
- }
-
- return onyxData;
-}
-
-/**
- * Sets the default bank account or debit card for an Expensify Wallet
- *
- * @param {Number} bankAccountID
- * @param {Number} fundID
- * @param {Object} previousPaymentMethod
- * @param {Object} currentPaymentMethod
- *
- */
-function makeDefaultPaymentMethod(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod) {
- API.write(
- 'MakeDefaultPaymentMethod',
- {
- bankAccountID,
- fundID,
- },
- {
- optimisticData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, true, ONYXKEYS.FUND_LIST),
- failureData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, false, ONYXKEYS.FUND_LIST),
- },
- );
-}
-
-/**
- * Calls the API to add a new card.
- *
- * @param {Object} params
- */
-function addPaymentCard(params) {
- const cardMonth = CardUtils.getMonthFromExpirationDateString(params.expirationDate);
- const cardYear = CardUtils.getYearFromExpirationDateString(params.expirationDate);
-
- API.write(
- 'AddPaymentCard',
- {
- cardNumber: params.cardNumber,
- cardYear,
- cardMonth,
- cardCVV: params.securityCode,
- addressName: params.nameOnCard,
- addressZip: params.addressZipCode,
- currency: CONST.CURRENCY.USD,
- isP2PDebitCard: true,
- },
- {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
- value: {isLoading: true},
- },
- ],
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
- value: {isLoading: false},
- },
- ],
- failureData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
- value: {isLoading: false},
- },
- ],
- },
- );
-}
-
-/**
- * Resets the values for the add debit card form back to their initial states
- */
-function clearDebitCardFormErrorAndSubmit() {
- Onyx.set(ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM, {
- isLoading: false,
- errors: null,
- });
-}
-
-/**
- * Call the API to transfer wallet balance.
- * @param {Object} paymentMethod
- * @param {*} paymentMethod.methodID
- * @param {String} paymentMethod.accountType
- */
-function transferWalletBalance(paymentMethod) {
- const paymentMethodIDKey = paymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? CONST.PAYMENT_METHOD_ID_KEYS.BANK_ACCOUNT : CONST.PAYMENT_METHOD_ID_KEYS.DEBIT_CARD;
- const parameters = {
- [paymentMethodIDKey]: paymentMethod.methodID,
- };
-
- API.write('TransferWalletBalance', parameters, {
- optimisticData: [
- {
- onyxMethod: 'merge',
- key: ONYXKEYS.WALLET_TRANSFER,
- value: {
- loading: true,
- errors: null,
- },
- },
- ],
- successData: [
- {
- onyxMethod: 'merge',
- key: ONYXKEYS.WALLET_TRANSFER,
- value: {
- loading: false,
- shouldShowSuccess: true,
- paymentMethodType: paymentMethod.accountType,
- },
- },
- ],
- failureData: [
- {
- onyxMethod: 'merge',
- key: ONYXKEYS.WALLET_TRANSFER,
- value: {
- loading: false,
- shouldShowSuccess: false,
- },
- },
- ],
- });
-}
-
-function resetWalletTransferData() {
- Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {
- selectedAccountType: '',
- selectedAccountID: null,
- filterPaymentMethodType: null,
- loading: false,
- shouldShowSuccess: false,
- });
-}
-
-/**
- * @param {String} selectedAccountType
- * @param {String} selectedAccountID
- */
-function saveWalletTransferAccountTypeAndID(selectedAccountType, selectedAccountID) {
- Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {selectedAccountType, selectedAccountID});
-}
-
-/**
- * Toggles the user's selected type of payment method (bank account or debit card) on the wallet transfer balance screen.
- * @param {String} filterPaymentMethodType
- */
-function saveWalletTransferMethodType(filterPaymentMethodType) {
- Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {filterPaymentMethodType});
-}
-
-function dismissSuccessfulTransferBalancePage() {
- Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {shouldShowSuccess: false});
- Navigation.goBack(ROUTES.SETTINGS_WALLET);
-}
-
-/**
- * Looks through each payment method to see if there is an existing error
- * @param {Object} bankList
- * @param {Object} fundList
- * @returns {Boolean}
- */
-function hasPaymentMethodError(bankList, fundList) {
- const combinedPaymentMethods = {...bankList, ...fundList};
- return _.some(combinedPaymentMethods, (item) => !_.isEmpty(item.errors));
-}
-
-/**
- * Clears the error for the specified payment item
- * @param {String} paymentListKey The onyx key for the provided payment method
- * @param {String} paymentMethodID
- */
-function clearDeletePaymentMethodError(paymentListKey, paymentMethodID) {
- Onyx.merge(paymentListKey, {
- [paymentMethodID]: {
- pendingAction: null,
- errors: null,
- },
- });
-}
-
-/**
- * If there was a failure adding a payment method, clearing it removes the payment method from the list entirely
- * @param {String} paymentListKey The onyx key for the provided payment method
- * @param {String} paymentMethodID
- */
-function clearAddPaymentMethodError(paymentListKey, paymentMethodID) {
- Onyx.merge(paymentListKey, {
- [paymentMethodID]: null,
- });
-}
-
-/**
- * Clear any error(s) related to the user's wallet
- */
-function clearWalletError() {
- Onyx.merge(ONYXKEYS.USER_WALLET, {errors: null});
-}
-
-/**
- * Clear any error(s) related to the user's wallet terms
- */
-function clearWalletTermsError() {
- Onyx.merge(ONYXKEYS.WALLET_TERMS, {errors: null});
-}
-
-function deletePaymentCard(fundID) {
- API.write(
- 'DeletePaymentCard',
- {
- fundID,
- },
- {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.FUND_LIST}`,
- value: {[fundID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}},
- },
- ],
- },
- );
-}
-
-export {
- deletePaymentCard,
- addPaymentCard,
- openWalletPage,
- makeDefaultPaymentMethod,
- kycWallRef,
- continueSetup,
- clearDebitCardFormErrorAndSubmit,
- dismissSuccessfulTransferBalancePage,
- transferWalletBalance,
- resetWalletTransferData,
- saveWalletTransferAccountTypeAndID,
- saveWalletTransferMethodType,
- hasPaymentMethodError,
- clearDeletePaymentMethodError,
- clearAddPaymentMethodError,
- clearWalletError,
- clearWalletTermsError,
-};
diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts
new file mode 100644
index 000000000000..c532d0fbeb63
--- /dev/null
+++ b/src/libs/actions/PaymentMethods.ts
@@ -0,0 +1,393 @@
+import {createRef} from 'react';
+import Onyx, {OnyxUpdate} from 'react-native-onyx';
+import {ValueOf} from 'type-fest';
+import ONYXKEYS, {OnyxValues} from '../../ONYXKEYS';
+import * as API from '../API';
+import CONST from '../../CONST';
+import Navigation from '../Navigation/Navigation';
+import * as CardUtils from '../CardUtils';
+import ROUTES from '../../ROUTES';
+import {FilterMethodPaymentType} from '../../types/onyx/WalletTransfer';
+import PaymentMethod from '../../types/onyx/PaymentMethod';
+
+type KYCWallRef = {
+ continue?: () => void;
+};
+
+/**
+ * Sets up a ref to an instance of the KYC Wall component.
+ */
+const kycWallRef = createRef();
+
+/**
+ * When we successfully add a payment method or pass the KYC checks we will continue with our setup action if we have one set.
+ */
+function continueSetup() {
+ if (!kycWallRef.current?.continue) {
+ Navigation.goBack(ROUTES.HOME);
+ return;
+ }
+
+ // Close the screen (Add Debit Card, Add Bank Account, or Enable Payments) on success and continue with setup
+ Navigation.goBack(ROUTES.HOME);
+ kycWallRef.current.continue();
+}
+
+function openWalletPage() {
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
+ value: true,
+ },
+ ];
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
+ value: false,
+ },
+ ];
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
+ value: false,
+ },
+ ];
+
+ return API.read(
+ 'OpenPaymentsPage',
+ {},
+ {
+ optimisticData,
+ successData,
+ failureData,
+ },
+ );
+}
+
+function getMakeDefaultPaymentOnyxData(
+ bankAccountID: number,
+ fundID: number,
+ previousPaymentMethod: PaymentMethod,
+ currentPaymentMethod: PaymentMethod,
+ isOptimisticData = true,
+): OnyxUpdate[] {
+ const onyxData: OnyxUpdate[] = [
+ isOptimisticData
+ ? {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.USER_WALLET,
+ value: {
+ walletLinkedAccountID: bankAccountID || fundID,
+ walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD,
+ // Only clear the error if this is optimistic data. If this is failure data, we do not want to clear the error that came from the server.
+ errors: null,
+ },
+ }
+ : {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.USER_WALLET,
+ value: {
+ walletLinkedAccountID: bankAccountID || fundID,
+ walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD,
+ },
+ },
+ ];
+
+ if (previousPaymentMethod?.methodID) {
+ onyxData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: previousPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
+ value: {
+ [previousPaymentMethod.methodID]: {
+ isDefault: !isOptimisticData,
+ },
+ },
+ });
+ }
+
+ if (currentPaymentMethod?.methodID) {
+ onyxData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: currentPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
+ value: {
+ [currentPaymentMethod.methodID]: {
+ isDefault: isOptimisticData,
+ },
+ },
+ });
+ }
+
+ return onyxData;
+}
+
+/**
+ * Sets the default bank account or debit card for an Expensify Wallet
+ *
+ */
+function makeDefaultPaymentMethod(bankAccountID: number, fundID: number, previousPaymentMethod: PaymentMethod, currentPaymentMethod: PaymentMethod) {
+ type MakeDefaultPaymentMethodParams = {
+ bankAccountID: number;
+ fundID: number;
+ };
+
+ const parameters: MakeDefaultPaymentMethodParams = {
+ bankAccountID,
+ fundID,
+ };
+
+ API.write('MakeDefaultPaymentMethod', parameters, {
+ optimisticData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, true),
+ failureData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, false),
+ });
+}
+
+type PaymentCardParams = {expirationDate: string; cardNumber: string; securityCode: string; nameOnCard: string; addressZipCode: string};
+
+/**
+ * Calls the API to add a new card.
+ *
+ */
+function addPaymentCard(params: PaymentCardParams) {
+ const cardMonth = CardUtils.getMonthFromExpirationDateString(params.expirationDate);
+ const cardYear = CardUtils.getYearFromExpirationDateString(params.expirationDate);
+
+ type AddPaymentCardParams = {
+ cardNumber: string;
+ cardYear: string;
+ cardMonth: string;
+ cardCVV: string;
+ addressName: string;
+ addressZip: string;
+ currency: ValueOf;
+ isP2PDebitCard: boolean;
+ };
+
+ const parameters: AddPaymentCardParams = {
+ cardNumber: params.cardNumber,
+ cardYear,
+ cardMonth,
+ cardCVV: params.securityCode,
+ addressName: params.nameOnCard,
+ addressZip: params.addressZipCode,
+ currency: CONST.CURRENCY.USD,
+ isP2PDebitCard: true,
+ };
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
+ value: {isLoading: true},
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
+ value: {isLoading: false},
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
+ value: {isLoading: false},
+ },
+ ];
+
+ API.write('AddPaymentCard', parameters, {
+ optimisticData,
+ successData,
+ failureData,
+ });
+}
+
+/**
+ * Resets the values for the add debit card form back to their initial states
+ */
+function clearDebitCardFormErrorAndSubmit() {
+ Onyx.set(ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM, {
+ isLoading: false,
+ errors: undefined,
+ setupComplete: true,
+ });
+}
+
+/**
+ * Call the API to transfer wallet balance.
+ *
+ */
+function transferWalletBalance(paymentMethod: PaymentMethod) {
+ const paymentMethodIDKey = paymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? CONST.PAYMENT_METHOD_ID_KEYS.BANK_ACCOUNT : CONST.PAYMENT_METHOD_ID_KEYS.DEBIT_CARD;
+
+ type TransferWalletBalanceParameters = Partial, number | undefined>>;
+
+ const parameters: TransferWalletBalanceParameters = {
+ [paymentMethodIDKey]: paymentMethod.methodID,
+ };
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: 'merge',
+ key: ONYXKEYS.WALLET_TRANSFER,
+ value: {
+ loading: true,
+ errors: null,
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: 'merge',
+ key: ONYXKEYS.WALLET_TRANSFER,
+ value: {
+ loading: false,
+ shouldShowSuccess: true,
+ paymentMethodType: paymentMethod.accountType,
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: 'merge',
+ key: ONYXKEYS.WALLET_TRANSFER,
+ value: {
+ loading: false,
+ shouldShowSuccess: false,
+ },
+ },
+ ];
+
+ API.write('TransferWalletBalance', parameters, {
+ optimisticData,
+ successData,
+ failureData,
+ });
+}
+
+function resetWalletTransferData() {
+ Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {
+ selectedAccountType: '',
+ selectedAccountID: null,
+ filterPaymentMethodType: null,
+ loading: false,
+ shouldShowSuccess: false,
+ });
+}
+
+function saveWalletTransferAccountTypeAndID(selectedAccountType: string, selectedAccountID: string) {
+ Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {selectedAccountType, selectedAccountID});
+}
+
+/**
+ * Toggles the user's selected type of payment method (bank account or debit card) on the wallet transfer balance screen.
+ *
+ */
+function saveWalletTransferMethodType(filterPaymentMethodType?: FilterMethodPaymentType) {
+ Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {filterPaymentMethodType});
+}
+
+function dismissSuccessfulTransferBalancePage() {
+ Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {shouldShowSuccess: false});
+ Navigation.goBack(ROUTES.SETTINGS_WALLET);
+}
+
+/**
+ * Looks through each payment method to see if there is an existing error
+ *
+ */
+function hasPaymentMethodError(bankList: OnyxValues[typeof ONYXKEYS.BANK_ACCOUNT_LIST], fundList: OnyxValues[typeof ONYXKEYS.FUND_LIST]): boolean {
+ const combinedPaymentMethods = {...bankList, ...fundList};
+
+ return Object.values(combinedPaymentMethods).some((item) => Object.keys(item.errors ?? {}).length);
+}
+
+type PaymentListKey = typeof ONYXKEYS.BANK_ACCOUNT_LIST | typeof ONYXKEYS.FUND_LIST;
+
+/**
+ * Clears the error for the specified payment item
+ * @param paymentListKey The onyx key for the provided payment method
+ * @param paymentMethodID
+ */
+function clearDeletePaymentMethodError(paymentListKey: PaymentListKey, paymentMethodID: string) {
+ Onyx.merge(paymentListKey, {
+ [paymentMethodID]: {
+ pendingAction: null,
+ errors: null,
+ },
+ });
+}
+
+/**
+ * If there was a failure adding a payment method, clearing it removes the payment method from the list entirely
+ * @param paymentListKey The onyx key for the provided payment method
+ * @param paymentMethodID
+ */
+function clearAddPaymentMethodError(paymentListKey: PaymentListKey, paymentMethodID: string) {
+ Onyx.merge(paymentListKey, {
+ [paymentMethodID]: null,
+ });
+}
+
+/**
+ * Clear any error(s) related to the user's wallet
+ */
+function clearWalletError() {
+ Onyx.merge(ONYXKEYS.USER_WALLET, {errors: null});
+}
+
+/**
+ * Clear any error(s) related to the user's wallet terms
+ */
+function clearWalletTermsError() {
+ Onyx.merge(ONYXKEYS.WALLET_TERMS, {errors: null});
+}
+
+function deletePaymentCard(fundID: number) {
+ type DeletePaymentCardParams = {
+ fundID: number;
+ };
+
+ const parameters: DeletePaymentCardParams = {
+ fundID,
+ };
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.FUND_LIST}`,
+ value: {[fundID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}},
+ },
+ ];
+
+ API.write('DeletePaymentCard', parameters, {
+ optimisticData,
+ });
+}
+
+export {
+ deletePaymentCard,
+ addPaymentCard,
+ openWalletPage,
+ makeDefaultPaymentMethod,
+ kycWallRef,
+ continueSetup,
+ clearDebitCardFormErrorAndSubmit,
+ dismissSuccessfulTransferBalancePage,
+ transferWalletBalance,
+ resetWalletTransferData,
+ saveWalletTransferAccountTypeAndID,
+ saveWalletTransferMethodType,
+ hasPaymentMethodError,
+ clearDeletePaymentMethodError,
+ clearAddPaymentMethodError,
+ clearWalletError,
+ clearWalletTermsError,
+};
diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js
index 1a73b148e100..89324dd35485 100644
--- a/src/libs/actions/Policy.js
+++ b/src/libs/actions/Policy.js
@@ -73,6 +73,13 @@ Onyx.connect({
callback: (val) => (allRecentlyUsedCategories = val),
});
+let networkStatus = {};
+Onyx.connect({
+ key: ONYXKEYS.NETWORK,
+ waitForCollectionCallback: true,
+ callback: (val) => (networkStatus = val),
+});
+
/**
* Stores in Onyx the policy ID of the last workspace that was accessed by the user
* @param {String|null} policyID
@@ -766,7 +773,7 @@ function updateWorkspaceCustomUnitAndRate(policyID, currentCustomUnit, newCustom
'UpdateWorkspaceCustomUnitAndRate',
{
policyID,
- lastModified,
+ ...(!networkStatus.isOffline && {lastModified}),
customUnit: JSON.stringify(newCustomUnitParam),
customUnitRate: JSON.stringify(newCustomUnitParam.rates),
},
@@ -909,6 +916,48 @@ function buildOptimisticCustomUnits() {
};
}
+/**
+ * Optimistically creates a Policy Draft for a new workspace
+ *
+ * @param {String} [policyOwnerEmail] Optional, the email of the account to make the owner of the policy
+ * @param {String} [policyName] Optional, custom policy name we will use for created workspace
+ * @param {String} [policyID] Optional, custom policy id we will use for created workspace
+ */
+function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', policyID = generatePolicyID()) {
+ const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail);
+ const {customUnits} = buildOptimisticCustomUnits();
+
+ const optimisticData = [
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`,
+ value: {
+ id: policyID,
+ type: CONST.POLICY.TYPE.FREE,
+ name: workspaceName,
+ role: CONST.POLICY.ROLE.ADMIN,
+ owner: sessionEmail,
+ isPolicyExpenseChatEnabled: true,
+ outputCurrency: lodashGet(allPersonalDetails, [sessionAccountID, 'localCurrencyCode'], CONST.CURRENCY.USD),
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ customUnits,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS}${policyID}`,
+ value: {
+ [sessionAccountID]: {
+ role: CONST.POLICY.ROLE.ADMIN,
+ errors: {},
+ },
+ },
+ },
+ ];
+
+ Onyx.update(optimisticData);
+}
+
/**
* Optimistically creates a new workspace and default workspace chats
*
@@ -1027,6 +1076,16 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`,
value: expenseReportActionData,
},
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS}${policyID}`,
+ value: null,
+ },
],
successData: [
{
@@ -1131,6 +1190,7 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName
],
},
);
+
return adminsChatReportID;
}
@@ -1259,4 +1319,5 @@ export {
clearErrors,
openDraftWorkspaceRequest,
buildOptimisticPolicyRecentlyUsedCategories,
+ createDraftInitialWorkspace,
};
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index c56e9c567745..c9f3ba6318db 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -1054,7 +1054,7 @@ function deleteReportComment(reportID, reportAction) {
isLastMessageDeletedParentAction: true,
};
} else {
- const {lastMessageText = '', lastMessageTranslationKey = ''} = ReportActionsUtils.getLastVisibleMessage(originalReportID, optimisticReportActions);
+ const {lastMessageText = '', lastMessageTranslationKey = ''} = ReportUtils.getLastVisibleMessage(originalReportID, optimisticReportActions);
if (lastMessageText || lastMessageTranslationKey) {
const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(originalReportID, optimisticReportActions);
const lastVisibleActionCreated = lastVisibleAction.created;
@@ -1889,6 +1889,7 @@ function toggleEmojiReaction(reportID, reportAction, reactionObject, existingRea
* @param {Boolean} isAuthenticated
*/
function openReportFromDeepLink(url, isAuthenticated) {
+ const route = ReportUtils.getRouteFromLink(url);
const reportID = ReportUtils.getReportIDFromLink(url);
if (reportID && !isAuthenticated) {
@@ -1907,16 +1908,11 @@ function openReportFromDeepLink(url, isAuthenticated) {
// Navigate to the report after sign-in/sign-up.
InteractionManager.runAfterInteractions(() => {
Session.waitForUserSignIn().then(() => {
- Navigation.waitForProtectedRoutes()
- .then(() => {
- const route = ReportUtils.getRouteFromLink(url);
- if (route === ROUTES.CONCIERGE) {
- navigateToConciergeChat(true);
- return;
- }
- Navigation.navigate(route, CONST.NAVIGATION.TYPE.PUSH);
- })
- .catch((error) => Log.warn(error.message));
+ if (route === ROUTES.CONCIERGE) {
+ navigateToConciergeChat(true);
+ return;
+ }
+ Navigation.navigate(route, CONST.NAVIGATION.TYPE.PUSH);
});
});
}
diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js
index 117a092c3875..3b623a42689d 100644
--- a/src/libs/actions/Session/index.js
+++ b/src/libs/actions/Session/index.js
@@ -316,7 +316,7 @@ function signInWithShortLivedAuthToken(email, authToken) {
// If the user is signing in with a different account from the current app, should not pass the auto-generated login as it may be tied to the old account.
// scene 1: the user is transitioning to newDot from a different account on oldDot.
// scene 2: the user is transitioning to desktop app from a different account on web app.
- const oldPartnerUserID = credentials.login === email ? credentials.autoGeneratedLogin : '';
+ const oldPartnerUserID = credentials.login === email && credentials.autoGeneratedLogin ? credentials.autoGeneratedLogin : '';
API.read('SignInWithShortLivedAuthToken', {authToken, oldPartnerUserID, skipReauthentication: true}, {optimisticData, successData, failureData});
}
@@ -541,6 +541,10 @@ function clearAccountMessages() {
});
}
+function setAccountError(error) {
+ Onyx.merge(ONYXKEYS.ACCOUNT, {errors: ErrorUtils.getMicroSecondOnyxError(error)});
+}
+
// It's necessary to throttle requests to reauthenticate since calling this multiple times will cause Pusher to
// reconnect each time when we only need to reconnect once. This way, if an authToken is expired and we try to
// subscribe to a bunch of channels at once we will only reauthenticate and force reconnect Pusher once.
@@ -807,6 +811,7 @@ export {
unlinkLogin,
clearSignInData,
clearAccountMessages,
+ setAccountError,
authenticatePusher,
reauthenticatePusher,
invalidateCredentials,
diff --git a/src/libs/actions/Session/updateSessionAuthTokens.js b/src/libs/actions/Session/updateSessionAuthTokens.js
index 5be53c77a92c..e88b3b993c7a 100644
--- a/src/libs/actions/Session/updateSessionAuthTokens.js
+++ b/src/libs/actions/Session/updateSessionAuthTokens.js
@@ -2,8 +2,8 @@ import Onyx from 'react-native-onyx';
import ONYXKEYS from '../../../ONYXKEYS';
/**
- * @param {String} authToken
- * @param {String} encryptedAuthToken
+ * @param {String | undefined} authToken
+ * @param {String | undefined} encryptedAuthToken
*/
export default function updateSessionAuthTokens(authToken, encryptedAuthToken) {
Onyx.merge(ONYXKEYS.SESSION, {authToken, encryptedAuthToken});
diff --git a/src/libs/actions/SignInRedirect.js b/src/libs/actions/SignInRedirect.ts
similarity index 74%
rename from src/libs/actions/SignInRedirect.js
rename to src/libs/actions/SignInRedirect.ts
index a010621c4eea..67f5f2d8586f 100644
--- a/src/libs/actions/SignInRedirect.js
+++ b/src/libs/actions/SignInRedirect.ts
@@ -1,7 +1,5 @@
import Onyx from 'react-native-onyx';
-import lodashGet from 'lodash/get';
-import _ from 'underscore';
-import ONYXKEYS from '../../ONYXKEYS';
+import ONYXKEYS, {OnyxKey} from '../../ONYXKEYS';
import * as MainQueue from '../Network/MainQueue';
import * as PersistedRequests from './PersistedRequests';
import NetworkConnection from '../NetworkConnection';
@@ -12,27 +10,21 @@ import Navigation from '../Navigation/Navigation';
import * as ErrorUtils from '../ErrorUtils';
import * as SessionUtils from '../SessionUtils';
-let currentIsOffline;
-let currentShouldForceOffline;
+let currentIsOffline: boolean | undefined;
+let currentShouldForceOffline: boolean | undefined;
Onyx.connect({
key: ONYXKEYS.NETWORK,
callback: (network) => {
- if (!network) {
- return;
- }
- currentIsOffline = network.isOffline;
- currentShouldForceOffline = Boolean(network.shouldForceOffline);
+ currentIsOffline = network?.isOffline;
+ currentShouldForceOffline = network?.shouldForceOffline;
},
});
-/**
- * @param {String} errorMessage
- */
-function clearStorageAndRedirect(errorMessage) {
+function clearStorageAndRedirect(errorMessage?: string) {
// Under certain conditions, there are key-values we'd like to keep in storage even when a user is logged out.
// We pass these into the clear() method in order to avoid having to reset them on a delayed tick and getting
// flashes of unwanted default state.
- const keysToPreserve = [];
+ const keysToPreserve: OnyxKey[] = [];
keysToPreserve.push(ONYXKEYS.NVP_PREFERRED_LOCALE);
keysToPreserve.push(ONYXKEYS.ACTIVE_CLIENTS);
keysToPreserve.push(ONYXKEYS.DEVICE_ID);
@@ -58,15 +50,15 @@ function clearStorageAndRedirect(errorMessage) {
*/
function resetHomeRouteParams() {
Navigation.isNavigationReady().then(() => {
- const routes = navigationRef.current && lodashGet(navigationRef.current.getState(), 'routes');
- const homeRoute = _.find(routes, (route) => route.name === SCREENS.HOME);
+ const routes = navigationRef.current?.getState().routes;
+ const homeRoute = routes?.find((route) => route.name === SCREENS.HOME);
- const emptyParams = {};
- _.keys(lodashGet(homeRoute, 'params')).forEach((paramKey) => {
+ const emptyParams: Record = {};
+ Object.keys(homeRoute?.params ?? {}).forEach((paramKey) => {
emptyParams[paramKey] = undefined;
});
- Navigation.setParams(emptyParams, lodashGet(homeRoute, 'key', ''));
+ Navigation.setParams(emptyParams, homeRoute?.key ?? '');
Onyx.set(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, false);
});
}
@@ -79,9 +71,9 @@ function resetHomeRouteParams() {
*
* Normally this method would live in Session.js, but that would cause a circular dependency with Network.js.
*
- * @param {String} [errorMessage] error message to be displayed on the sign in page
+ * @param [errorMessage] error message to be displayed on the sign in page
*/
-function redirectToSignIn(errorMessage) {
+function redirectToSignIn(errorMessage?: string) {
MainQueue.clear();
HttpUtils.cancelPendingRequests();
PersistedRequests.clear();
diff --git a/src/libs/actions/Timing.js b/src/libs/actions/Timing.ts
similarity index 76%
rename from src/libs/actions/Timing.js
rename to src/libs/actions/Timing.ts
index 2be2cdc6fa63..13f40bab87c9 100644
--- a/src/libs/actions/Timing.js
+++ b/src/libs/actions/Timing.ts
@@ -4,15 +4,20 @@ import Firebase from '../Firebase';
import * as API from '../API';
import Log from '../Log';
-let timestampData = {};
+type TimestampData = {
+ startTime: number;
+ shouldUseFirebase: boolean;
+};
+
+let timestampData: Record = {};
/**
* Start a performance timing measurement
*
- * @param {String} eventName
- * @param {Boolean} shouldUseFirebase - adds an additional trace in Firebase
+ * @param eventName
+ * @param shouldUseFirebase - adds an additional trace in Firebase
*/
-function start(eventName, shouldUseFirebase = false) {
+function start(eventName: string, shouldUseFirebase = false) {
timestampData[eventName] = {startTime: Date.now(), shouldUseFirebase};
if (!shouldUseFirebase) {
@@ -25,11 +30,11 @@ function start(eventName, shouldUseFirebase = false) {
/**
* End performance timing. Measure the time between event start/end in milliseconds, and push to Grafana
*
- * @param {String} eventName - event name used as timestamp key
- * @param {String} [secondaryName] - optional secondary event name, passed to grafana
- * @param {number} [maxExecutionTime] - optional amount of time (ms) to wait before logging a warn
+ * @param eventName - event name used as timestamp key
+ * @param [secondaryName] - optional secondary event name, passed to grafana
+ * @param [maxExecutionTime] - optional amount of time (ms) to wait before logging a warn
*/
-function end(eventName, secondaryName = '', maxExecutionTime = 0) {
+function end(eventName: string, secondaryName = '', maxExecutionTime = 0) {
if (!timestampData[eventName]) {
return;
}
diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts
index 8653b038e381..8a7f0f7bd533 100644
--- a/src/libs/actions/Transaction.ts
+++ b/src/libs/actions/Transaction.ts
@@ -32,8 +32,8 @@ function createInitialWaypoints(transactionID: string) {
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
comment: {
waypoints: {
- waypoint0: null,
- waypoint1: null,
+ waypoint0: {},
+ waypoint1: {},
},
},
});
@@ -107,15 +107,15 @@ function removeWaypoint(transactionID: string, currentIndex: string) {
const transaction = allTransactions?.[transactionID] ?? {};
const existingWaypoints = transaction?.comment?.waypoints ?? {};
const totalWaypoints = Object.keys(existingWaypoints).length;
- // Prevents removing the starting or ending waypoint but clear the stored address only if there are only two waypoints
- if (totalWaypoints === 2 && (index === 0 || index === totalWaypoints - 1)) {
- saveWaypoint(transactionID, index.toString(), null);
- return;
- }
const waypointValues = Object.values(existingWaypoints);
const removed = waypointValues.splice(index, 1);
- const isRemovedWaypointEmpty = removed.length > 0 && !TransactionUtils.waypointHasValidAddress(removed[0] ?? null);
+ const isRemovedWaypointEmpty = removed.length > 0 && !TransactionUtils.waypointHasValidAddress(removed[0] ?? {});
+
+ // When there are only two waypoints we are adding empty waypoint back
+ if (totalWaypoints === 2 && (index === 0 || index === totalWaypoints - 1)) {
+ waypointValues.splice(index, 0, {});
+ }
const reIndexedWaypoints: WaypointCollection = {};
waypointValues.forEach((waypoint, idx) => {
diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js
index 78bd52988cdf..f65c20cd7e5b 100644
--- a/src/libs/actions/User.js
+++ b/src/libs/actions/User.js
@@ -541,7 +541,7 @@ function subscribeToUserEvents() {
/**
* Sync preferredSkinTone with Onyx and Server
- * @param {String} skinTone
+ * @param {Number} skinTone
*/
function updatePreferredSkinTone(skinTone) {
const optimisticData = [
diff --git a/src/libs/getComponentDisplayName.ts b/src/libs/getComponentDisplayName.ts
index fd1bbcaea521..0bf52d543a84 100644
--- a/src/libs/getComponentDisplayName.ts
+++ b/src/libs/getComponentDisplayName.ts
@@ -1,6 +1,6 @@
import {ComponentType} from 'react';
/** Returns the display name of a component */
-export default function getComponentDisplayName(component: ComponentType): string {
+export default function getComponentDisplayName(component: ComponentType): string {
return component.displayName ?? component.name ?? 'Component';
}
diff --git a/src/pages/EditRequestReceiptPage.js b/src/pages/EditRequestReceiptPage.js
index 6744f027b404..54ed5a8897a4 100644
--- a/src/pages/EditRequestReceiptPage.js
+++ b/src/pages/EditRequestReceiptPage.js
@@ -1,5 +1,6 @@
import React, {useState} from 'react';
import PropTypes from 'prop-types';
+import {View} from 'react-native';
import ScreenWrapper from '../components/ScreenWrapper';
import HeaderWithBackButton from '../components/HeaderWithBackButton';
import Navigation from '../libs/Navigation/Navigation';
@@ -40,17 +41,21 @@ function EditRequestReceiptPage({route, transactionID}) {
testID={EditRequestReceiptPage.displayName}
headerGapStyles={isDraggingOver ? [styles.receiptDropHeaderGap] : []}
>
-
-
-
-
+ {({safeAreaPaddingBottomStyle}) => (
+
+
+
+
+
+
+ )}
);
}
diff --git a/src/pages/EnablePayments/ActivateStep.js b/src/pages/EnablePayments/ActivateStep.js
index 268c2664e01d..2d23f39d25e5 100644
--- a/src/pages/EnablePayments/ActivateStep.js
+++ b/src/pages/EnablePayments/ActivateStep.js
@@ -1,3 +1,4 @@
+import _ from 'underscore';
import React from 'react';
import {withOnyx} from 'react-native-onyx';
import * as LottieAnimations from '../../components/LottieAnimations';
@@ -29,8 +30,8 @@ const defaultProps = {
};
function ActivateStep(props) {
- const isGoldWallet = props.userWallet.tierName === CONST.WALLET.TIER_NAME.GOLD;
- const animation = isGoldWallet ? LottieAnimations.Fireworks : LottieAnimations.ReviewingBankInfo;
+ const isActivatedWallet = _.contains([CONST.WALLET.TIER_NAME.GOLD, CONST.WALLET.TIER_NAME.PLATINUM], props.userWallet.tierName);
+ const animation = isActivatedWallet ? LottieAnimations.Fireworks : LottieAnimations.ReviewingBankInfo;
const continueButtonText = props.walletTerms.chatReportID ? props.translate('activateStep.continueToPayment') : props.translate('activateStep.continueToTransfer');
return (
@@ -38,9 +39,9 @@ function ActivateStep(props) {
diff --git a/src/pages/LogInWithShortLivedAuthTokenPage.js b/src/pages/LogInWithShortLivedAuthTokenPage.js
index 62eff262611d..875cdf7e8072 100644
--- a/src/pages/LogInWithShortLivedAuthTokenPage.js
+++ b/src/pages/LogInWithShortLivedAuthTokenPage.js
@@ -12,8 +12,7 @@ import themeColors from '../styles/themes/default';
import Icon from '../components/Icon';
import * as Expensicons from '../components/Icon/Expensicons';
import * as Illustrations from '../components/Icon/Illustrations';
-import withLocalize, {withLocalizePropTypes} from '../components/withLocalize';
-import compose from '../libs/compose';
+import useLocalize from '../hooks/useLocalize';
import TextLink from '../components/TextLink';
import ONYXKEYS from '../ONYXKEYS';
@@ -33,8 +32,6 @@ const propTypes = {
}),
}).isRequired,
- ...withLocalizePropTypes,
-
/** The details about the account that the user is signing in with */
account: PropTypes.shape({
/** Whether a sign is loading */
@@ -49,15 +46,26 @@ const defaultProps = {
};
function LogInWithShortLivedAuthTokenPage(props) {
+ const {translate} = useLocalize();
+
useEffect(() => {
const email = lodashGet(props, 'route.params.email', '');
// We have to check for both shortLivedAuthToken and shortLivedToken, as the old mobile app uses shortLivedToken, and is not being actively updated.
const shortLivedAuthToken = lodashGet(props, 'route.params.shortLivedAuthToken', '') || lodashGet(props, 'route.params.shortLivedToken', '');
- if (shortLivedAuthToken) {
+
+ // Try to authenticate using the shortLivedToken if we're not already trying to load the accounts
+ if (shortLivedAuthToken && !props.account.isLoading) {
Session.signInWithShortLivedAuthToken(email, shortLivedAuthToken);
return;
}
+
+ // If an error is returned as part of the route, ensure we set it in the onyxData for the account
+ const error = lodashGet(props, 'route.params.error', '');
+ if (error) {
+ Session.setAccountError(error);
+ }
+
const exitTo = lodashGet(props, 'route.params.exitTo', '');
if (exitTo) {
Navigation.isNavigationReady().then(() => {
@@ -82,10 +90,18 @@ function LogInWithShortLivedAuthTokenPage(props) {
src={Illustrations.RocketBlue}
/>
- {props.translate('deeplinkWrapper.launching')}
+ {translate('deeplinkWrapper.launching')}
- {props.translate('deeplinkWrapper.expired')} Navigation.navigate()}>{props.translate('deeplinkWrapper.signIn')}
+ {translate('deeplinkWrapper.expired')}{' '}
+ {
+ Session.clearSignInData();
+ Navigation.navigate();
+ }}
+ >
+ {translate('deeplinkWrapper.signIn')}
+
@@ -105,9 +121,7 @@ LogInWithShortLivedAuthTokenPage.propTypes = propTypes;
LogInWithShortLivedAuthTokenPage.defaultProps = defaultProps;
LogInWithShortLivedAuthTokenPage.displayName = 'LogInWithShortLivedAuthTokenPage';
-export default compose(
- withLocalize,
- withOnyx({
- account: {key: ONYXKEYS.ACCOUNT},
- }),
-)(LogInWithShortLivedAuthTokenPage);
+export default withOnyx({
+ account: {key: ONYXKEYS.ACCOUNT},
+ session: {key: ONYXKEYS.SESSION},
+})(LogInWithShortLivedAuthTokenPage);
diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js
index 42a535844c72..00bb27892792 100644
--- a/src/pages/ReportDetailsPage.js
+++ b/src/pages/ReportDetailsPage.js
@@ -61,7 +61,7 @@ const defaultProps = {
function ReportDetailsPage(props) {
const policy = useMemo(() => props.policies[`${ONYXKEYS.COLLECTION.POLICY}${props.report.policyID}`], [props.policies, props.report.policyID]);
const isPolicyAdmin = useMemo(() => PolicyUtils.isPolicyAdmin(policy), [policy]);
- const shouldUseFullTitle = ReportUtils.isTaskReport(props.report);
+ const shouldUseFullTitle = useMemo(() => ReportUtils.shouldUseFullTitleToDisplay(props.report), [props.report]);
const isChatRoom = useMemo(() => ReportUtils.isChatRoom(props.report), [props.report]);
const isThread = useMemo(() => ReportUtils.isChatThread(props.report), [props.report]);
const isUserCreatedPolicyRoom = useMemo(() => ReportUtils.isUserCreatedPolicyRoom(props.report), [props.report]);
@@ -160,7 +160,18 @@ function ReportDetailsPage(props) {
return (
-
+ {
+ const topMostReportID = Navigation.getTopmostReportId();
+ if (topMostReportID) {
+ Navigation.goBack(ROUTES.HOME);
+ return;
+ }
+ Navigation.goBack();
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(props.report.reportID));
+ }}
+ />
diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js
index d7f8c3605564..5e68e852c60b 100644
--- a/src/pages/home/HeaderView.js
+++ b/src/pages/home/HeaderView.js
@@ -30,6 +30,7 @@ import * as Link from '../../libs/actions/Link';
import * as Report from '../../libs/actions/Report';
import * as Task from '../../libs/actions/Task';
import compose from '../../libs/compose';
+import * as Session from '../../libs/actions/Session';
import styles from '../../styles/styles';
import themeColors from '../../styles/themes/default';
import reportPropTypes from '../reportPropTypes';
@@ -101,7 +102,7 @@ function HeaderView(props) {
threeDotMenuItems.push({
icon: Expensicons.Checkmark,
text: props.translate('task.markAsIncomplete'),
- onSelected: () => Task.reopenTask(props.report),
+ onSelected: Session.checkIfActionIsAllowed(() => Task.reopenTask(props.report)),
});
}
@@ -110,7 +111,7 @@ function HeaderView(props) {
threeDotMenuItems.push({
icon: Expensicons.Trashcan,
text: props.translate('common.cancel'),
- onSelected: () => Task.cancelTask(props.report.reportID, props.report.reportName, props.report.stateNum, props.report.statusNum),
+ onSelected: Session.checkIfActionIsAllowed(() => Task.cancelTask(props.report.reportID, props.report.reportName, props.report.stateNum, props.report.statusNum)),
});
}
}
@@ -120,13 +121,15 @@ function HeaderView(props) {
threeDotMenuItems.push({
icon: Expensicons.ChatBubbles,
text: props.translate('common.joinThread'),
- onSelected: () => Report.updateNotificationPreference(props.report.reportID, props.report.notificationPreference, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, false),
+ onSelected: Session.checkIfActionIsAllowed(() =>
+ 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,
text: props.translate('common.leaveThread'),
- onSelected: () => Report.leaveRoom(props.report.reportID),
+ onSelected: Session.checkIfActionIsAllowed(() => Report.leaveRoom(props.report.reportID)),
});
}
}
@@ -137,24 +140,24 @@ function HeaderView(props) {
threeDotMenuItems.push({
icon: Expensicons.Phone,
text: props.translate('videoChatButtonAndMenu.tooltip'),
- onSelected: () => {
+ onSelected: Session.checkIfActionIsAllowed(() => {
Link.openExternalLink(props.guideCalendarLink);
- },
+ }),
});
} else if (!isAutomatedExpensifyAccount && !isTaskReport && !isArchivedRoom) {
threeDotMenuItems.push({
icon: ZoomIcon,
text: props.translate('videoChatButtonAndMenu.zoom'),
- onSelected: () => {
+ onSelected: Session.checkIfActionIsAllowed(() => {
Link.openExternalLink(CONST.NEW_ZOOM_MEETING_URL);
- },
+ }),
});
threeDotMenuItems.push({
icon: GoogleMeetIcon,
text: props.translate('videoChatButtonAndMenu.googleMeet'),
- onSelected: () => {
+ onSelected: Session.checkIfActionIsAllowed(() => {
Link.openExternalLink(CONST.NEW_GOOGLE_MEET_MEETING_URL);
- },
+ }),
});
}
diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js
index 51981d5fe80e..4903ab2160a5 100644
--- a/src/pages/home/ReportScreen.js
+++ b/src/pages/home/ReportScreen.js
@@ -127,7 +127,7 @@ const defaultProps = {
* @returns {String}
*/
function getReportID(route) {
- return String(lodashGet(route, 'params.reportID', null));
+ return String(lodashGet(route, 'params.reportID', ''));
}
function ReportScreen({
@@ -340,6 +340,9 @@ function ReportScreen({
}, [route, report, errors, fetchReportIfNeeded, prevReport.reportID, prevUserLeavingStatus, userLeavingStatus, prevReport.statusNum, prevReport.parentReportID]);
useEffect(() => {
+ if (!ReportUtils.isValidReportIDFromPath(reportID)) {
+ return;
+ }
// Ensures subscription event succeeds when the report/workspace room is created optimistically.
// Check if the optimistic `OpenReport` or `AddWorkspaceRoom` has succeeded by confirming
// any `pendingFields.createChat` or `pendingFields.addWorkspaceRoom` fields are set to null.
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
index 849db381a549..ffd7f65185ce 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
@@ -34,6 +34,7 @@ import withKeyboardState from '../../../../components/withKeyboardState';
import {propTypes, defaultProps} from './composerWithSuggestionsProps';
import focusWithDelay from '../../../../libs/focusWithDelay';
import useDebounce from '../../../../hooks/useDebounce';
+import updateMultilineInputRange from '../../../../libs/UpdateMultilineInputRange';
import * as InputFocus from '../../../../libs/actions/InputFocus';
const {RNTextInputReset} = NativeModules;
@@ -215,6 +216,10 @@ function ComposerWithSuggestions({
if (!_.isEmpty(emojis)) {
const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current);
if (!_.isEmpty(newEmojis)) {
+ // Ensure emoji suggestions are hidden after inserting emoji even when the selection is not changed
+ if (suggestionsRef.current) {
+ suggestionsRef.current.resetSuggestions();
+ }
insertedEmojisRef.current = [...insertedEmojisRef.current, ...newEmojis];
debouncedUpdateFrequentlyUsedEmojis();
}
@@ -223,11 +228,6 @@ function ComposerWithSuggestions({
setIsCommentEmpty(!!newComment.match(/^(\s)*$/));
setValue(newComment);
if (commentValue !== newComment) {
- // Ensure emoji suggestions are hidden even when the selection is not changed (so calculateEmojiSuggestion would not be called).
- if (suggestionsRef.current) {
- suggestionsRef.current.resetSuggestions();
- }
-
const remainder = ComposerUtils.getCommonSuffixLength(commentValue, newComment);
setSelection({
start: newComment.length - remainder,
@@ -496,9 +496,13 @@ function ComposerWithSuggestions({
focus();
}, [focus, prevIsFocused, editFocused, prevIsModalVisible, isFocused, modal.isVisible, isNextModalWillOpenRef]);
useEffect(() => {
+ // Scrolls the composer to the bottom and sets the selection to the end, so that longer drafts are easier to edit
+ updateMultilineInputRange(textInputRef.current, shouldAutoFocus);
+
if (value.length === 0) {
return;
}
+
Report.setReportWithDraft(reportID, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js
index 84bee9c80c7f..67d87bdbce6f 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js
+++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js
@@ -188,17 +188,17 @@ function SuggestionMention({
}
const valueAfterTheCursor = value.substring(selectionEnd);
- const indexOfFirstWhitespaceCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI);
+ const indexOfFirstSpecialCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.MENTION_BREAKER);
- let indexOfLastNonWhitespaceCharAfterTheCursor;
- if (indexOfFirstWhitespaceCharOrEmojiAfterTheCursor === -1) {
- // we didn't find a whitespace/emoji after the cursor, so we will use the entire string
- indexOfLastNonWhitespaceCharAfterTheCursor = value.length;
+ let suggestionEndIndex;
+ if (indexOfFirstSpecialCharOrEmojiAfterTheCursor === -1) {
+ // We didn't find a special char/whitespace/emoji after the cursor, so we will use the entire string
+ suggestionEndIndex = value.length;
} else {
- indexOfLastNonWhitespaceCharAfterTheCursor = indexOfFirstWhitespaceCharOrEmojiAfterTheCursor + selectionEnd;
+ suggestionEndIndex = indexOfFirstSpecialCharOrEmojiAfterTheCursor + selectionEnd;
}
- const leftString = value.substring(0, indexOfLastNonWhitespaceCharAfterTheCursor);
+ const leftString = value.substring(0, suggestionEndIndex);
const words = leftString.split(CONST.REGEX.SPACE_OR_EMOJI);
const lastWord = _.last(words);
diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js
index d0e84499a443..f5ca7080249c 100644
--- a/src/pages/home/report/ReportActionItem.js
+++ b/src/pages/home/report/ReportActionItem.js
@@ -730,6 +730,7 @@ export default compose(
prevProps.report.managerEmail === nextProps.report.managerEmail &&
prevProps.shouldHideThreadDividerLine === nextProps.shouldHideThreadDividerLine &&
lodashGet(prevProps.report, 'total', 0) === lodashGet(nextProps.report, 'total', 0) &&
+ lodashGet(prevProps.report, 'nonReimbursableTotal', 0) === lodashGet(nextProps.report, 'nonReimbursableTotal', 0) &&
prevProps.linkedReportActionID === nextProps.linkedReportActionID,
),
);
diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js
index 24501e307759..0b6333e31ef8 100644
--- a/src/pages/home/report/ReportActionItemFragment.js
+++ b/src/pages/home/report/ReportActionItemFragment.js
@@ -18,6 +18,7 @@ import CONST from '../../../CONST';
import editedLabelStyles from '../../../styles/editedLabelStyles';
import UserDetailsTooltip from '../../../components/UserDetailsTooltip';
import avatarPropTypes from '../../../components/avatarPropTypes';
+import * as Browser from '../../../libs/Browser';
const propTypes = {
/** Users accountID */
@@ -66,6 +67,9 @@ const propTypes = {
/** localization props */
...withLocalizePropTypes,
+
+ /** Should the comment have the appearance of being grouped with the previous comment? */
+ displayAsGroup: PropTypes.bool,
};
const defaultProps = {
@@ -82,9 +86,28 @@ const defaultProps = {
delegateAccountID: 0,
actorIcon: {},
isThreadParentMessage: false,
+ displayAsGroup: false,
};
function ReportActionItemFragment(props) {
+ /**
+ * Checks text element for presence of emoji as first character
+ * and insert Zero-Width character to avoid selection issue
+ * mentioned here https://github.com/Expensify/App/issues/29021
+ *
+ * @param {String} text
+ * @param {Boolean} displayAsGroup
+ * @returns {ReactNode | null} Text component with zero width character
+ */
+
+ const checkForEmojiForSelection = (text, displayAsGroup) => {
+ const firstLetterIsEmoji = EmojiUtils.isFirstLetterEmoji(text);
+ if (firstLetterIsEmoji && !displayAsGroup && !Browser.isMobile()) {
+ return ;
+ }
+ return null;
+ };
+
switch (props.fragment.type) {
case 'COMMENT': {
const {html, text} = props.fragment;
@@ -116,6 +139,7 @@ function ReportActionItemFragment(props) {
return (
+ {checkForEmojiForSelection(text, props.displayAsGroup)}
))
) : (
diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js
index 39f950b18ebe..f76f884dca52 100644
--- a/src/pages/home/report/ReportActionItemMessageEdit.js
+++ b/src/pages/home/report/ReportActionItemMessageEdit.js
@@ -246,16 +246,16 @@ function ReportActionItemMessageEdit(props) {
}
}
emojisPresentBefore.current = emojis;
- setDraft((prevDraft) => {
- if (newDraftInput !== newDraft) {
- const remainder = ComposerUtils.getCommonSuffixLength(prevDraft, newDraft);
- setSelection({
- start: newDraft.length - remainder,
- end: newDraft.length - remainder,
- });
- }
- return newDraft;
- });
+
+ setDraft(newDraft);
+
+ if (newDraftInput !== newDraft) {
+ const remainder = ComposerUtils.getCommonSuffixLength(newDraftInput, newDraft);
+ setSelection({
+ start: newDraft.length - remainder,
+ end: newDraft.length - remainder,
+ });
+ }
// This component is rendered only when draft is set to a non-empty string. In order to prevent component
// unmount when user deletes content of textarea, we set previous message instead of empty string.
diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js
index 438b6e9b68d5..c673c06470f8 100644
--- a/src/pages/home/report/ReportActionsList.js
+++ b/src/pages/home/report/ReportActionsList.js
@@ -278,45 +278,57 @@ function ReportActionsList({
);
/**
- * @param {Object} args
- * @param {Number} args.index
- * @returns {React.Component}
+ * Evaluate new unread marker visibility for each of the report actions.
+ * @returns boolean
*/
- const renderItem = useCallback(
- ({item: reportAction, index}) => {
- let shouldDisplayNewMarker = false;
+ const shouldDisplayNewMarker = useCallback(
+ (reportAction, index) => {
+ let shouldDisplay = false;
if (!currentUnreadMarker) {
const nextMessage = sortedReportActions[index + 1];
const isCurrentMessageUnread = isMessageUnread(reportAction, report.lastReadTime);
- shouldDisplayNewMarker = isCurrentMessageUnread && !isMessageUnread(nextMessage, report.lastReadTime);
-
+ shouldDisplay = isCurrentMessageUnread && !isMessageUnread(nextMessage, report.lastReadTime);
if (!messageManuallyMarkedUnread) {
- shouldDisplayNewMarker = shouldDisplayNewMarker && reportAction.actorAccountID !== Report.getCurrentUserAccountID();
- }
- const canDisplayMarker = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? reportAction.created < userActiveSince.current : true;
-
- if (!currentUnreadMarker && shouldDisplayNewMarker && canDisplayMarker) {
- setCurrentUnreadMarker(reportAction.reportActionID);
+ shouldDisplay = shouldDisplay && reportAction.actorAccountID !== Report.getCurrentUserAccountID();
}
} else {
- shouldDisplayNewMarker = reportAction.reportActionID === currentUnreadMarker;
+ shouldDisplay = reportAction.reportActionID === currentUnreadMarker;
}
- return (
-
- );
+ return shouldDisplay;
},
- [report, linkedReportActionID, hasOutstandingIOU, sortedReportActions, mostRecentIOUReportActionID, messageManuallyMarkedUnread, shouldHideThreadDividerLine, currentUnreadMarker],
+ [currentUnreadMarker, sortedReportActions, report.lastReadTime, messageManuallyMarkedUnread],
+ );
+
+ useEffect(() => {
+ // Iterate through the report actions and set appropriate unread marker.
+ // This is to avoid a warning of:
+ // Cannot update a component (ReportActionsList) while rendering a different component (CellRenderer).
+ _.each(sortedReportActions, (reportAction, index) => {
+ if (!shouldDisplayNewMarker(reportAction, index)) {
+ return;
+ }
+ if (!currentUnreadMarker && currentUnreadMarker !== reportAction.reportActionID) {
+ setCurrentUnreadMarker(reportAction.reportActionID);
+ }
+ });
+ }, [sortedReportActions, report.lastReadTime, messageManuallyMarkedUnread, shouldDisplayNewMarker, currentUnreadMarker]);
+
+ const renderItem = useCallback(
+ ({item: reportAction, index}) => (
+
+ ),
+ [report, linkedReportActionID, hasOutstandingIOU, sortedReportActions, mostRecentIOUReportActionID, shouldHideThreadDividerLine, shouldDisplayNewMarker],
);
// Native mobile does not render updates flatlist the changes even though component did update called.
diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js
index f58c6644cd47..a3671faf194c 100755
--- a/src/pages/home/report/ReportActionsView.js
+++ b/src/pages/home/report/ReportActionsView.js
@@ -278,6 +278,10 @@ function arePropsEqual(oldProps, newProps) {
return false;
}
+ if (lodashGet(newProps, 'report.nonReimbursableTotal') !== lodashGet(oldProps, 'report.nonReimbursableTotal')) {
+ return false;
+ }
+
if (lodashGet(newProps, 'report.writeCapability') !== lodashGet(oldProps, 'report.writeCapability')) {
return false;
}
diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js
index 9dbdde14c50d..394f6c5ddc5a 100644
--- a/src/pages/home/sidebar/SidebarLinksData.js
+++ b/src/pages/home/sidebar/SidebarLinksData.js
@@ -141,6 +141,7 @@ const chatReportSelector = (report) =>
lastVisibleActionCreated: report.lastVisibleActionCreated,
iouReportID: report.iouReportID,
total: report.total,
+ nonReimbursableTotal: report.nonReimbursableTotal,
hasOutstandingIOU: report.hasOutstandingIOU,
isWaitingOnBankAccount: report.isWaitingOnBankAccount,
statusNum: report.statusNum,
diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js
index cd14dcd25f11..554a869de30f 100644
--- a/src/pages/iou/IOUCurrencySelection.js
+++ b/src/pages/iou/IOUCurrencySelection.js
@@ -1,4 +1,5 @@
import React, {useState, useMemo, useCallback, useRef} from 'react';
+import {Keyboard} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
@@ -78,6 +79,8 @@ function IOUCurrencySelection(props) {
const confirmCurrencySelection = useCallback(
(option) => {
const backTo = lodashGet(props.route, 'params.backTo', '');
+ Keyboard.dismiss();
+
// When we refresh the web, the money request route gets cleared from the navigation stack.
// Navigating to "backTo" will result in forward navigation instead, causing disruption to the currency selection.
// To prevent any negative experience, we have made the decision to simply close the currency selection page.
diff --git a/src/pages/iou/MoneyRequestDescriptionPage.js b/src/pages/iou/MoneyRequestDescriptionPage.js
index 6570cffb58d4..cfdbb60b4f0d 100644
--- a/src/pages/iou/MoneyRequestDescriptionPage.js
+++ b/src/pages/iou/MoneyRequestDescriptionPage.js
@@ -45,11 +45,12 @@ const propTypes = {
}).isRequired,
/** The current tab we have navigated to in the request modal. String that corresponds to the request type. */
- selectedTab: PropTypes.oneOf([CONST.TAB.DISTANCE, CONST.TAB.MANUAL, CONST.TAB.SCAN]).isRequired,
+ selectedTab: PropTypes.oneOf(['', CONST.TAB.DISTANCE, CONST.TAB.MANUAL, CONST.TAB.SCAN]),
};
const defaultProps = {
iou: iouDefaultProps,
+ selectedTab: '',
};
function MoneyRequestDescriptionPage({iou, route, selectedTab}) {
diff --git a/src/pages/iou/ReceiptSelector/index.js b/src/pages/iou/ReceiptSelector/index.js
index fba792029914..ca9fe90575e7 100644
--- a/src/pages/iou/ReceiptSelector/index.js
+++ b/src/pages/iou/ReceiptSelector/index.js
@@ -165,7 +165,6 @@ function ReceiptSelector({route, transactionID, iou, report}) {
const panResponder = useRef(
PanResponder.create({
- onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
}),
).current;
@@ -181,7 +180,7 @@ function ReceiptSelector({route, transactionID, iou, report}) {
/>
)}
{cameraPermissionState === 'denied' && (
-
+
diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js
index 6503d488e805..8bf13422f70c 100644
--- a/src/pages/iou/ReceiptSelector/index.native.js
+++ b/src/pages/iou/ReceiptSelector/index.native.js
@@ -159,7 +159,7 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator})
return (
{cameraPermissionStatus !== RESULTS.GRANTED && (
-
+
-
+ {
- setIsFetchingLocation(false);
-
- const waypoint = {
- lat: geolocationData.coords.latitude,
- lng: geolocationData.coords.longitude,
- address: CONST.YOUR_LOCATION_TEXT,
- };
-
- selectWaypoint(waypoint);
- };
-
return (
(textInput.current = e)}
hint={!isOffline ? 'distance.errors.selectSuggestedAddress' : ''}
@@ -265,17 +243,6 @@ function WaypointEditor({route: {params: {iouType = '', transactionID = '', wayp
resultTypes=""
/>
- {
- Keyboard.dismiss();
-
- setIsFetchingLocation(true);
- }}
- onLocationError={() => setIsFetchingLocation(false)}
- onLocationFetched={selectWaypointFromCurrentLocation}
- />
- {isFetchingLocation && }
diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js
index 46367e275af4..9061d4b1193c 100644
--- a/src/pages/iou/steps/MoneyRequestConfirmPage.js
+++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js
@@ -1,4 +1,4 @@
-import React, {useCallback, useEffect, useMemo, useRef} from 'react';
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
@@ -67,6 +67,7 @@ function MoneyRequestConfirmPage(props) {
const isDistanceRequest = MoneyRequestUtils.isDistanceRequest(iouType.current, props.selectedTab);
const isScanRequest = MoneyRequestUtils.isScanRequest(props.selectedTab);
const reportID = useRef(lodashGet(props.route, 'params.reportID', ''));
+ const [receiptFile, setReceiptFile] = useState();
const participants = useMemo(
() =>
_.map(props.iou.participants, (participant) => {
@@ -94,6 +95,21 @@ function MoneyRequestConfirmPage(props) {
}
}, [isOffline, participants, props.iou.billable, props.policy]);
+ useEffect(() => {
+ if (!props.iou.receiptPath || !props.iou.receiptFilename) {
+ return;
+ }
+ FileUtils.readFileAsync(props.iou.receiptPath, props.iou.receiptFilename).then((file) => {
+ if (!file) {
+ Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType.current, reportID.current));
+ } else {
+ const receipt = file;
+ receipt.state = file && isManualRequestDM ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCANREADY;
+ setReceiptFile(receipt);
+ }
+ });
+ }, [props.iou.receiptPath, props.iou.receiptFilename, isManualRequestDM]);
+
useEffect(() => {
// ID in Onyx could change by initiating a new request in a separate browser tab or completing a request
if (!isDistanceRequest && prevMoneyRequestId.current !== props.iou.id) {
@@ -240,12 +256,8 @@ function MoneyRequestConfirmPage(props) {
return;
}
- if (props.iou.receiptPath && props.iou.receiptFilename) {
- FileUtils.readFileAsync(props.iou.receiptPath, props.iou.receiptFilename).then((file) => {
- const receipt = file;
- receipt.state = file && isManualRequestDM ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCANREADY;
- requestMoney(selectedParticipants, trimmedComment, receipt);
- });
+ if (receiptFile) {
+ requestMoney(selectedParticipants, trimmedComment, receiptFile);
return;
}
@@ -268,7 +280,7 @@ function MoneyRequestConfirmPage(props) {
isDistanceRequest,
requestMoney,
createDistanceRequest,
- isManualRequestDM,
+ receiptFile,
],
);
diff --git a/src/pages/settings/Profile/PersonalDetails/AddressPage.js b/src/pages/settings/Profile/PersonalDetails/AddressPage.js
index b029a2085877..b8d91b4769fc 100644
--- a/src/pages/settings/Profile/PersonalDetails/AddressPage.js
+++ b/src/pages/settings/Profile/PersonalDetails/AddressPage.js
@@ -166,7 +166,7 @@ function AddressPage({privatePersonalDetails, route}) {
testID={AddressPage.displayName}
>
Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS)}
/>
diff --git a/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js b/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js
index f639a7eebc15..3b695de3fcb7 100644
--- a/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js
+++ b/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js
@@ -91,7 +91,7 @@ function PersonalDetailsInitialPage(props) {
/>
Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS)}
/>
diff --git a/src/pages/settings/Wallet/ActivatePhysicalCardPage.js b/src/pages/settings/Wallet/ActivatePhysicalCardPage.js
index e7198c009a44..0175f2ceac1f 100644
--- a/src/pages/settings/Wallet/ActivatePhysicalCardPage.js
+++ b/src/pages/settings/Wallet/ActivatePhysicalCardPage.js
@@ -78,7 +78,7 @@ function ActivatePhysicalCardPage({
return;
}
- Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(domain));
+ Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain));
}, [cardID, cardList, domain, physicalCard.isLoading]);
useEffect(
@@ -131,7 +131,7 @@ function ActivatePhysicalCardPage({
return (
Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(domain))}
+ onBackButtonPress={() => Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain))}
backgroundColor={themeColors.PAGE_BACKGROUND_COLORS[SCREENS.SETTINGS.PREFERENCES]}
illustration={LottieAnimations.Magician}
scrollViewContainerStyles={[styles.mnh100]}
diff --git a/src/pages/settings/Wallet/ExpensifyCardPage.js b/src/pages/settings/Wallet/ExpensifyCardPage.js
index cfbd26133ced..c9ee7ece8fa9 100644
--- a/src/pages/settings/Wallet/ExpensifyCardPage.js
+++ b/src/pages/settings/Wallet/ExpensifyCardPage.js
@@ -92,6 +92,7 @@ function ExpensifyCardPage({
pan="1234123412341234"
expiration="11/02/2024"
cvv="321"
+ domain={domain}
/>
) : (
CONST.EXPENSIFY_CARD.ACTIVE_STATES.includes(card.state));
+ const assignedCards = _.chain(cardList)
+ .filter((card) => CONST.EXPENSIFY_CARD.ACTIVE_STATES.includes(card.state))
+ .sortBy((card) => (CardUtils.isExpensifyCard(card.cardID) ? 0 : 1))
+ .value();
+
return _.map(assignedCards, (card) => {
const icon = getBankIcon(card.bank);
+ const isExpensifyCard = CardUtils.isExpensifyCard(card.cardID);
return {
- key: card.key,
- title: translate('walletPage.expensifyCard'),
+ key: card.cardID,
+ title: isExpensifyCard ? translate('walletPage.expensifyCard') : card.cardName,
description: card.domainName,
- onPress: () => Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(card.domainName)),
+ onPress: isExpensifyCard ? () => Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(card.domainName)) : () => {},
+ shouldShowRightIcon: isExpensifyCard,
+ interactive: isExpensifyCard,
+ canDismissError: isExpensifyCard,
+ errors: card.errors,
...icon,
};
});
@@ -274,6 +284,7 @@ function PaymentMethodList({
pendingAction={item.pendingAction}
errors={item.errors}
errorRowStyles={styles.ph6}
+ canDismissError={item.canDismissError}
>
),
- [filteredPaymentMethods, translate, shouldShowAssignedCards, shouldShowSelectedState, selectedMethodID],
+ [filteredPaymentMethods, translate, shouldShowSelectedState, selectedMethodID],
);
return (
diff --git a/src/pages/settings/Wallet/ReportVirtualCardFraudPage.js b/src/pages/settings/Wallet/ReportVirtualCardFraudPage.js
index 2652494aa1c7..1a51fc4d9453 100644
--- a/src/pages/settings/Wallet/ReportVirtualCardFraudPage.js
+++ b/src/pages/settings/Wallet/ReportVirtualCardFraudPage.js
@@ -63,7 +63,7 @@ function ReportVirtualCardFraudPage({
return;
}
- Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(domain));
+ Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain));
}, [domain, formData.isLoading, prevIsLoading, virtualCard.errors]);
if (_.isEmpty(virtualCard)) {
@@ -74,7 +74,7 @@ function ReportVirtualCardFraudPage({
Navigation.goBack(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(domain))}
+ onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain))}
/>
{translate('reportFraudPage.description')}
diff --git a/src/pages/settings/Wallet/TransferBalancePage.js b/src/pages/settings/Wallet/TransferBalancePage.js
index f33c92bea02b..ae54dab569f7 100644
--- a/src/pages/settings/Wallet/TransferBalancePage.js
+++ b/src/pages/settings/Wallet/TransferBalancePage.js
@@ -167,7 +167,9 @@ function TransferBalancePage(props) {
const isButtonDisabled = !isTransferable || !selectedAccount;
const errorMessage = !_.isEmpty(props.walletTransfer.errors) ? _.chain(props.walletTransfer.errors).values().first().value() : '';
- const shouldShowTransferView = PaymentUtils.hasExpensifyPaymentMethod(paymentCardList, props.bankAccountList) && props.userWallet.tierName === CONST.WALLET.TIER_NAME.GOLD;
+ const shouldShowTransferView =
+ PaymentUtils.hasExpensifyPaymentMethod(paymentCardList, props.bankAccountList) &&
+ _.contains([CONST.WALLET.TIER_NAME.GOLD, CONST.WALLET.TIER_NAME.PLATINUM], props.userWallet.tierName);
return (
diff --git a/src/pages/settings/Wallet/WalletPage/CardDetails.js b/src/pages/settings/Wallet/WalletPage/CardDetails.js
index 6b2b6b8bc54f..f38f90fdfcb2 100644
--- a/src/pages/settings/Wallet/WalletPage/CardDetails.js
+++ b/src/pages/settings/Wallet/WalletPage/CardDetails.js
@@ -11,6 +11,9 @@ import ONYXKEYS from '../../../../ONYXKEYS';
import * as PersonalDetailsUtils from '../../../../libs/PersonalDetailsUtils';
import PressableWithDelayToggle from '../../../../components/Pressable/PressableWithDelayToggle';
import styles from '../../../../styles/styles';
+import TextLink from '../../../../components/TextLink';
+import Navigation from '../../../../libs/Navigation/Navigation';
+import ROUTES from '../../../../ROUTES';
const propTypes = {
/** Card number */
@@ -33,6 +36,9 @@ const propTypes = {
country: PropTypes.string,
}),
}),
+
+ /** Domain name */
+ domain: PropTypes.string.isRequired,
};
const defaultProps = {
@@ -51,7 +57,7 @@ const defaultProps = {
},
};
-function CardDetails({pan, expiration, cvv, privatePersonalDetails}) {
+function CardDetails({pan, expiration, cvv, privatePersonalDetails, domain}) {
usePrivatePersonalDetails();
const {translate} = useLocalize();
@@ -92,6 +98,12 @@ function CardDetails({pan, expiration, cvv, privatePersonalDetails}) {
title={PersonalDetailsUtils.getFormattedAddress(privatePersonalDetails)}
interactive={false}
/>
+ Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS.getRoute(domain))}
+ >
+ {translate('cardPage.cardDetails.updateAddress')}
+
>
);
}
diff --git a/src/pages/signin/ChooseSSOOrMagicCode.js b/src/pages/signin/ChooseSSOOrMagicCode.js
new file mode 100644
index 000000000000..32f0776cdbc9
--- /dev/null
+++ b/src/pages/signin/ChooseSSOOrMagicCode.js
@@ -0,0 +1,108 @@
+import React from 'react';
+import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+import styles from '../../styles/styles';
+import ONYXKEYS from '../../ONYXKEYS';
+import Text from '../../components/Text';
+import Button from '../../components/Button';
+import * as Session from '../../libs/actions/Session';
+import ChangeExpensifyLoginLink from './ChangeExpensifyLoginLink';
+import Terms from './Terms';
+import CONST from '../../CONST';
+import ROUTES from '../../ROUTES';
+import Navigation from '../../libs/Navigation/Navigation';
+import * as ErrorUtils from '../../libs/ErrorUtils';
+import useLocalize from '../../hooks/useLocalize';
+import useNetwork from '../../hooks/useNetwork';
+import useWindowDimensions from '../../hooks/useWindowDimensions';
+import FormHelpMessage from '../../components/FormHelpMessage';
+
+const propTypes = {
+ /* Onyx Props */
+
+ /** The credentials of the logged in person */
+ credentials: PropTypes.shape({
+ /** The email/phone the user logged in with */
+ login: PropTypes.string,
+ }),
+
+ /** The details about the account that the user is signing in with */
+ account: PropTypes.shape({
+ /** Whether or not a sign on form is loading (being submitted) */
+ isLoading: PropTypes.bool,
+
+ /** Form that is being loaded */
+ loadingForm: PropTypes.oneOf(_.values(CONST.FORMS)),
+
+ /** Whether this account has 2FA enabled or not */
+ requiresTwoFactorAuth: PropTypes.bool,
+
+ /** Server-side errors in the submitted authentication code */
+ errors: PropTypes.objectOf(PropTypes.string),
+ }),
+
+ /** Function that returns whether the user is using SAML or magic codes to log in */
+ setIsUsingMagicCode: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ credentials: {},
+ account: {},
+};
+
+function ChooseSSOOrMagicCode({credentials, account, setIsUsingMagicCode}) {
+ const {translate} = useLocalize();
+ const {isOffline} = useNetwork();
+ const {isSmallScreenWidth} = useWindowDimensions();
+
+ return (
+ <>
+
+ {translate('samlSignIn.welcomeSAMLEnabled')}
+ {
+ Navigation.navigate(ROUTES.SAML_SIGN_IN);
+ }}
+ />
+
+
+
+ {translate('samlSignIn.orContinueWithMagicCode')}
+
+
+
+ {
+ Session.resendValidateCode(credentials.login);
+ setIsUsingMagicCode(true);
+ }}
+ />
+ {Boolean(account) && !_.isEmpty(account.errors) && }
+ Session.clearSignInData()} />
+
+
+
+
+ >
+ );
+}
+
+ChooseSSOOrMagicCode.propTypes = propTypes;
+ChooseSSOOrMagicCode.defaultProps = defaultProps;
+ChooseSSOOrMagicCode.displayName = 'ChooseSSOOrMagicCode';
+
+export default withOnyx({
+ credentials: {key: ONYXKEYS.CREDENTIALS},
+ account: {key: ONYXKEYS.ACCOUNT},
+})(ChooseSSOOrMagicCode);
diff --git a/src/pages/signin/LoginForm/BaseLoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js
index 2c65b5ff5d37..3576f92be31f 100644
--- a/src/pages/signin/LoginForm/BaseLoginForm.js
+++ b/src/pages/signin/LoginForm/BaseLoginForm.js
@@ -163,7 +163,7 @@ function LoginForm(props) {
useEffect(() => {
// Just call clearAccountMessages on the login page (home route), because when the user is in the transition route and not yet authenticated,
// this component will also be mounted, resetting account.isLoading will cause the app to briefly display the session expiration page.
- if (props.isFocused) {
+ if (props.isFocused && props.isVisible) {
Session.clearAccountMessages();
}
if (!canFocusInputOnScreenFocus() || !input.current || !props.isVisible) {
diff --git a/src/pages/signin/SAMLSignInPage/index.js b/src/pages/signin/SAMLSignInPage/index.js
new file mode 100644
index 000000000000..23ce9b93b8cc
--- /dev/null
+++ b/src/pages/signin/SAMLSignInPage/index.js
@@ -0,0 +1,66 @@
+import React, {useEffect} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import ONYXKEYS from '../../../ONYXKEYS';
+import CONFIG from '../../../CONFIG';
+import Icon from '../../../components/Icon';
+import Text from '../../../components/Text';
+import * as Expensicons from '../../../components/Icon/Expensicons';
+import * as Illustrations from '../../../components/Icon/Illustrations';
+import styles from '../../../styles/styles';
+import themeColors from '../../../styles/themes/default';
+import useLocalize from '../../../hooks/useLocalize';
+
+const propTypes = {
+ /** The credentials of the logged in person */
+ credentials: PropTypes.shape({
+ /** The email/phone the user logged in with */
+ login: PropTypes.string,
+ }),
+};
+
+const defaultProps = {
+ credentials: {},
+};
+
+function SAMLSignInPage({credentials}) {
+ const {translate} = useLocalize();
+
+ useEffect(() => {
+ window.open(`${CONFIG.EXPENSIFY.SAML_URL}?email=${credentials.login}&referer=${CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER}`, '_self');
+ }, [credentials.login]);
+
+ return (
+
+
+
+
+
+ {translate('samlSignIn.launching')}
+
+ {translate('samlSignIn.oneMoment')}
+
+
+
+
+
+
+ );
+}
+
+SAMLSignInPage.propTypes = propTypes;
+SAMLSignInPage.defaultProps = defaultProps;
+
+export default withOnyx({
+ credentials: {key: ONYXKEYS.CREDENTIALS},
+})(SAMLSignInPage);
diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js
index d5acf8803224..8aae45c279c6 100644
--- a/src/pages/signin/SignInPage.js
+++ b/src/pages/signin/SignInPage.js
@@ -19,7 +19,13 @@ import * as StyleUtils from '../../styles/StyleUtils';
import useLocalize from '../../hooks/useLocalize';
import useWindowDimensions from '../../hooks/useWindowDimensions';
import Log from '../../libs/Log';
+import getPlatform from '../../libs/getPlatform';
+import CONST from '../../CONST';
+import Navigation from '../../libs/Navigation/Navigation';
+import ROUTES from '../../ROUTES';
+import ChooseSSOOrMagicCode from './ChooseSSOOrMagicCode';
import * as ActiveClientManager from '../../libs/ActiveClientManager';
+import * as Session from '../../libs/actions/Session';
const propTypes = {
/** The details about the account that the user is signing in with */
@@ -38,6 +44,18 @@ const propTypes = {
/** Is this account having trouble receiving emails */
hasEmailDeliveryFailure: PropTypes.bool,
+
+ /** Whether or not a sign on form is loading (being submitted) */
+ isLoading: PropTypes.bool,
+
+ /** Form that is being loaded */
+ loadingForm: PropTypes.oneOf(_.values(CONST.FORMS)),
+
+ /** Whether or not the user has SAML enabled on their account */
+ isSAMLEnabled: PropTypes.bool,
+
+ /** Whether or not SAML is required on the account */
+ isSAMLRequired: PropTypes.bool,
}),
/** The credentials of the person signing in */
@@ -64,23 +82,50 @@ const defaultProps = {
/**
* @param {Boolean} hasLogin
* @param {Boolean} hasValidateCode
+ * @param {Object} account
* @param {Boolean} isPrimaryLogin
- * @param {Boolean} isAccountValidated
+ * @param {Boolean} isUsingMagicCode
+ * @param {Boolean} hasInitiatedSAMLLogin
* @param {Boolean} hasEmailDeliveryFailure
* @returns {Object}
*/
-function getRenderOptions({hasLogin, hasValidateCode, hasAccount, isPrimaryLogin, isAccountValidated, hasEmailDeliveryFailure, isClientTheLeader}) {
+function getRenderOptions({hasLogin, hasValidateCode, account, isPrimaryLogin, isUsingMagicCode, hasInitiatedSAMLLogin, isClientTheLeader}) {
+ const hasAccount = !_.isEmpty(account);
+ const isSAMLEnabled = Boolean(account.isSAMLEnabled);
+ const isSAMLRequired = Boolean(account.isSAMLRequired);
+ const hasEmailDeliveryFailure = Boolean(account.hasEmailDeliveryFailure);
+
+ // SAML is temporarily restricted to users on the beta or to users signing in on web and mweb
+ let shouldShowChooseSSOOrMagicCode = false;
+ let shouldInitiateSAMLLogin = false;
+ const platform = getPlatform();
+ if (platform === CONST.PLATFORM.WEB || platform === CONST.PLATFORM.DESKTOP) {
+ // True if the user has SAML required and we haven't already initiated SAML for their account
+ shouldInitiateSAMLLogin = hasAccount && hasLogin && isSAMLRequired && !hasInitiatedSAMLLogin && account.isLoading;
+ shouldShowChooseSSOOrMagicCode = hasAccount && hasLogin && isSAMLEnabled && !isSAMLRequired && !isUsingMagicCode;
+ }
+
+ // SAML required users may reload the login page after having already entered their login details, in which
+ // case we want to clear their sign in data so they don't end up in an infinite loop redirecting back to their
+ // SSO provider's login page
+ if (hasLogin && isSAMLRequired && !shouldInitiateSAMLLogin && !hasInitiatedSAMLLogin && !account.isLoading) {
+ Session.clearSignInData();
+ }
+
const shouldShowLoginForm = isClientTheLeader && !hasLogin && !hasValidateCode;
- const shouldShowEmailDeliveryFailurePage = hasLogin && hasEmailDeliveryFailure;
- const isUnvalidatedSecondaryLogin = hasLogin && !isPrimaryLogin && !isAccountValidated && !hasEmailDeliveryFailure;
- const shouldShowValidateCodeForm = hasAccount && (hasLogin || hasValidateCode) && !isUnvalidatedSecondaryLogin && !hasEmailDeliveryFailure;
- const shouldShowWelcomeHeader = shouldShowLoginForm || shouldShowValidateCodeForm || isUnvalidatedSecondaryLogin;
- const shouldShowWelcomeText = shouldShowLoginForm || shouldShowValidateCodeForm || !isClientTheLeader;
+ const shouldShowEmailDeliveryFailurePage = hasLogin && hasEmailDeliveryFailure && !shouldShowChooseSSOOrMagicCode && !shouldInitiateSAMLLogin;
+ const isUnvalidatedSecondaryLogin = hasLogin && !isPrimaryLogin && !account.validated && !hasEmailDeliveryFailure;
+ const shouldShowValidateCodeForm =
+ hasAccount && (hasLogin || hasValidateCode) && !isUnvalidatedSecondaryLogin && !hasEmailDeliveryFailure && !shouldShowChooseSSOOrMagicCode && !isSAMLRequired;
+ const shouldShowWelcomeHeader = shouldShowLoginForm || shouldShowValidateCodeForm || shouldShowChooseSSOOrMagicCode || isUnvalidatedSecondaryLogin;
+ const shouldShowWelcomeText = shouldShowLoginForm || shouldShowValidateCodeForm || shouldShowChooseSSOOrMagicCode || !isClientTheLeader;
return {
shouldShowLoginForm,
shouldShowEmailDeliveryFailurePage,
shouldShowUnlinkLoginForm: isUnvalidatedSecondaryLogin,
shouldShowValidateCodeForm,
+ shouldShowChooseSSOOrMagicCode,
+ shouldInitiateSAMLLogin,
shouldShowWelcomeHeader,
shouldShowWelcomeText,
};
@@ -96,24 +141,44 @@ function SignInPage({credentials, account, isInModal, activeClients}) {
* and we need it here since welcome text(`welcomeText`) also depends on it */
const [isUsingRecoveryCode, setIsUsingRecoveryCode] = useState(false);
+ /** This state is needed to keep track of whether the user has opted to use magic codes
+ * instead of signing in via SAML when SAML is enabled and not required */
+ const [isUsingMagicCode, setIsUsingMagicCode] = useState(false);
+
+ /** This state is needed to keep track of whether the user has been directed to their SSO provider's login page and
+ * if we need to clear their sign in details so they can enter a login */
+ const [hasInitiatedSAMLLogin, setHasInitiatedSAMLLogin] = useState(false);
+
+ const isClientTheLeader = activeClients && ActiveClientManager.isClientTheLeader();
+
useEffect(() => Performance.measureTTI(), []);
useEffect(() => {
App.setLocale(Localize.getDevicePreferredLocale());
}, []);
- const isClientTheLeader = activeClients && ActiveClientManager.isClientTheLeader();
+ const {
+ shouldShowLoginForm,
+ shouldShowEmailDeliveryFailurePage,
+ shouldShowUnlinkLoginForm,
+ shouldShowValidateCodeForm,
+ shouldShowChooseSSOOrMagicCode,
+ shouldInitiateSAMLLogin,
+ shouldShowWelcomeHeader,
+ shouldShowWelcomeText,
+ } = getRenderOptions({
+ hasLogin: Boolean(credentials.login),
+ hasValidateCode: Boolean(credentials.validateCode),
+ account,
+ isPrimaryLogin: !account.primaryLogin || account.primaryLogin === credentials.login,
+ isUsingMagicCode,
+ hasInitiatedSAMLLogin,
+ isClientTheLeader,
+ });
- const {shouldShowLoginForm, shouldShowEmailDeliveryFailurePage, shouldShowUnlinkLoginForm, shouldShowValidateCodeForm, shouldShowWelcomeHeader, shouldShowWelcomeText} = getRenderOptions(
- {
- hasLogin: Boolean(credentials.login),
- hasValidateCode: Boolean(credentials.validateCode),
- hasAccount: !_.isEmpty(account),
- isPrimaryLogin: !account.primaryLogin || account.primaryLogin === credentials.login,
- isAccountValidated: Boolean(account.validated),
- hasEmailDeliveryFailure: Boolean(account.hasEmailDeliveryFailure),
- isClientTheLeader,
- },
- );
+ if (shouldInitiateSAMLLogin) {
+ setHasInitiatedSAMLLogin(true);
+ Navigation.isNavigationReady().then(() => Navigation.navigate(ROUTES.SAML_SIGN_IN));
+ }
let welcomeHeader = '';
let welcomeText = '';
@@ -147,14 +212,14 @@ function SignInPage({credentials, account, isInModal, activeClients}) {
: translate('welcomeText.newFaceEnterMagicCode', {login: userLoginToDisplay});
}
}
- } else if (shouldShowUnlinkLoginForm || shouldShowEmailDeliveryFailurePage) {
+ } else if (shouldShowUnlinkLoginForm || shouldShowEmailDeliveryFailurePage || shouldShowChooseSSOOrMagicCode) {
welcomeHeader = shouldShowSmallScreen ? headerText : translate('welcomeText.welcomeBack');
// Don't show any welcome text if we're showing the user the email delivery failed view
- if (shouldShowEmailDeliveryFailurePage) {
+ if (shouldShowEmailDeliveryFailurePage || shouldShowChooseSSOOrMagicCode) {
welcomeText = '';
}
- } else {
+ } else if (!shouldInitiateSAMLLogin) {
Log.warn('SignInPage in unexpected state!');
}
@@ -182,9 +247,11 @@ function SignInPage({credentials, account, isInModal, activeClients}) {
)}
{shouldShowUnlinkLoginForm && }
+ {shouldShowChooseSSOOrMagicCode && }
{shouldShowEmailDeliveryFailurePage && }
diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
index 335df7be3188..dc100fffe4f1 100755
--- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
+++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
@@ -38,6 +38,12 @@ const propTypes = {
/** Whether or not a sign on form is loading (being submitted) */
isLoading: PropTypes.bool,
+
+ /** Whether or not the user has SAML enabled on their account */
+ isSAMLEnabled: PropTypes.bool,
+
+ /** Whether or not SAML is required on the account */
+ isSAMLRequired: PropTypes.bool,
}),
/** The credentials of the person signing in */
@@ -64,6 +70,9 @@ const propTypes = {
/** Function to change `isUsingRecoveryCode` state when user toggles between 2fa code and recovery code */
setIsUsingRecoveryCode: PropTypes.func.isRequired,
+ /** Function to change `isUsingMagicCode` state when the user goes back to the login page */
+ setIsUsingMagicCode: PropTypes.func.isRequired,
+
...withLocalizePropTypes,
};
@@ -199,6 +208,8 @@ function BaseValidateCodeForm(props) {
* Clears local and Onyx sign in states
*/
const clearSignInData = () => {
+ // Reset the user's preference for signing in with SAML versus magic codes
+ props.setIsUsingMagicCode(false);
clearLocalSignInData();
Session.clearSignInData();
};
diff --git a/src/pages/workspace/WorkspaceInitialPage.js b/src/pages/workspace/WorkspaceInitialPage.js
index 567aef1274e1..d275b7f0dd10 100644
--- a/src/pages/workspace/WorkspaceInitialPage.js
+++ b/src/pages/workspace/WorkspaceInitialPage.js
@@ -11,6 +11,7 @@ import Tooltip from '../../components/Tooltip';
import Text from '../../components/Text';
import ConfirmModal from '../../components/ConfirmModal';
import * as Expensicons from '../../components/Icon/Expensicons';
+import * as App from '../../libs/actions/App';
import ScreenWrapper from '../../components/ScreenWrapper';
import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
import MenuItem from '../../components/MenuItem';
@@ -65,7 +66,7 @@ function dismissError(policyID) {
}
function WorkspaceInitialPage(props) {
- const policy = props.policy;
+ const policy = props.policyDraft && props.policyDraft.id ? props.policyDraft : props.policy;
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isCurrencyModalOpen, setIsCurrencyModalOpen] = useState(false);
const hasPolicyCreationError = Boolean(policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && policy.errors);
@@ -81,6 +82,17 @@ function WorkspaceInitialPage(props) {
Navigation.goBack(ROUTES.SETTINGS_WORKSPACES);
}, [props.reports, policy]);
+ useEffect(() => {
+ const policyDraftId = lodashGet(props.policyDraft, 'id', null);
+ if (!policyDraftId) {
+ return;
+ }
+
+ App.savePolicyDraftByNewWorkspace(props.policyDraft.id, props.policyDraft.name, '', false);
+ // We only care when the component renders the first time
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
useEffect(() => {
if (!isCurrencyModalOpen || policy.outputCurrency !== CONST.CURRENCY.USD) {
return;
@@ -189,8 +201,8 @@ function WorkspaceInitialPage(props) {
{({safeAreaPaddingBottomStyle}) => (
Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
- shouldShow={_.isEmpty(props.policy) || !PolicyUtils.isPolicyAdmin(props.policy) || PolicyUtils.isPendingDeletePolicy(props.policy)}
- subtitleKey={_.isEmpty(props.policy) ? undefined : 'workspace.common.notAuthorized'}
+ shouldShow={_.isEmpty(policy) || !PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPendingDeletePolicy(policy)}
+ subtitleKey={_.isEmpty(policy) ? undefined : 'workspace.common.notAuthorized'}
>
_.keys(props.policyMembers), [props.policyMembers]);
+ const accountIDs = useMemo(() => _.map(_.keys(props.policyMembers), (accountID) => Number(accountID)), [props.policyMembers]);
const prevAccountIDs = usePrevious(accountIDs);
const textInputRef = useRef(null);
const isOfflineAndNoMemberDataAvailable = _.isEmpty(props.policyMembers) && props.network.isOffline;
+ const prevPersonalDetails = usePrevious(props.personalDetails);
+
+ /**
+ * Get filtered personalDetails list with current policyMembers
+ * @param {Object} policyMembers
+ * @param {Object} personalDetails
+ * @returns {Object}
+ */
+ const filterPersonalDetails = (policyMembers, personalDetails) =>
+ _.reduce(
+ _.keys(policyMembers),
+ (result, key) => {
+ if (personalDetails[key]) {
+ return {
+ ...result,
+ [key]: personalDetails[key],
+ };
+ }
+ return result;
+ },
+ {},
+ );
+
/**
* Get members for the current workspace
*/
@@ -116,12 +139,17 @@ function WorkspaceMembersPage(props) {
if (removeMembersConfirmModalVisible && !_.isEqual(accountIDs, prevAccountIDs)) {
setRemoveMembersConfirmModalVisible(false);
}
- setSelectedEmployees((prevSelected) =>
- _.intersection(
- prevSelected,
- _.map(_.values(PolicyUtils.getMemberAccountIDsForWorkspace(props.policyMembers, props.personalDetails)), (accountID) => Number(accountID)),
- ),
- );
+ setSelectedEmployees((prevSelected) => {
+ // Filter all personal details in order to use the elements needed for the current workspace
+ const currentPersonalDetails = filterPersonalDetails(props.policyMembers, props.personalDetails);
+ // We need to filter the previous selected employees by the new personal details, since unknown/new user id's change when transitioning from offline to online
+ const prevSelectedElements = _.map(prevSelected, (id) => {
+ const prevItem = lodashGet(prevPersonalDetails, id);
+ const res = _.find(_.values(currentPersonalDetails), (item) => lodashGet(prevItem, 'login') === lodashGet(item, 'login'));
+ return lodashGet(res, 'accountID', id);
+ });
+ return _.intersection(prevSelectedElements, _.values(PolicyUtils.getMemberAccountIDsForWorkspace(props.policyMembers, props.personalDetails)));
+ });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.policyMembers]);
@@ -173,12 +201,12 @@ function WorkspaceMembersPage(props) {
*/
const toggleAllUsers = (memberList) => {
const enabledAccounts = _.filter(memberList, (member) => !member.isDisabled);
- const everyoneSelected = _.every(enabledAccounts, (member) => _.contains(selectedEmployees, Number(member.keyForList)));
+ const everyoneSelected = _.every(enabledAccounts, (member) => _.contains(selectedEmployees, member.accountID));
if (everyoneSelected) {
setSelectedEmployees([]);
} else {
- const everyAccountId = _.map(enabledAccounts, (member) => Number(member.keyForList));
+ const everyAccountId = _.map(enabledAccounts, (member) => member.accountID);
setSelectedEmployees(everyAccountId);
}
@@ -225,10 +253,10 @@ function WorkspaceMembersPage(props) {
}
// Add or remove the user if the checkbox is enabled
- if (_.contains(selectedEmployees, Number(accountID))) {
- removeUser(Number(accountID));
+ if (_.contains(selectedEmployees, accountID)) {
+ removeUser(accountID);
} else {
- addUser(Number(accountID));
+ addUser(accountID);
}
},
[selectedEmployees, addUser, removeUser],
@@ -265,7 +293,8 @@ function WorkspaceMembersPage(props) {
const getMemberOptions = () => {
let result = [];
- _.each(props.policyMembers, (policyMember, accountID) => {
+ _.each(props.policyMembers, (policyMember, accountIDKey) => {
+ const accountID = Number(accountIDKey);
if (isDeletedPolicyMember(policyMember)) {
return;
}
@@ -313,9 +342,9 @@ function WorkspaceMembersPage(props) {
const isAdmin = props.session.email === details.login || policyMember.role === CONST.POLICY.ROLE.ADMIN;
result.push({
- keyForList: accountID,
- accountID: Number(accountID),
- isSelected: _.contains(selectedEmployees, Number(accountID)),
+ keyForList: accountIDKey,
+ accountID,
+ isSelected: _.contains(selectedEmployees, accountID),
isDisabled:
accountID === props.session.accountID ||
details.login === props.policy.owner ||
@@ -417,7 +446,7 @@ function WorkspaceMembersPage(props) {
textInputValue={searchValue}
onChangeText={setSearchValue}
headerMessage={getHeaderMessage()}
- onSelectRow={(item) => toggleUser(item.keyForList)}
+ onSelectRow={(item) => toggleUser(item.accountID)}
onSelectAll={() => toggleAllUsers(data)}
onDismissError={dismissError}
showLoadingPlaceholder={!isOfflineAndNoMemberDataAvailable && (!OptionsListUtils.isPersonalDetailsReady(props.personalDetails) || _.isEmpty(props.policyMembers))}
diff --git a/src/pages/workspace/WorkspacesListPage.js b/src/pages/workspace/WorkspacesListPage.js
index 369e84bab5d0..5a2e01562cc1 100755
--- a/src/pages/workspace/WorkspacesListPage.js
+++ b/src/pages/workspace/WorkspacesListPage.js
@@ -24,7 +24,6 @@ import * as App from '../../libs/actions/App';
import useLocalize from '../../hooks/useLocalize';
import useNetwork from '../../hooks/useNetwork';
import usePermissions from '../../hooks/usePermissions';
-import useWindowDimensions from '../../hooks/useWindowDimensions';
import IllustratedHeaderPageLayout from '../../components/IllustratedHeaderPageLayout';
import SCREENS from '../../SCREENS';
import * as LottieAnimations from '../../components/LottieAnimations';
@@ -112,7 +111,6 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, u
const {translate} = useLocalize();
const {isOffline} = useNetwork();
const {canUseWallet} = usePermissions();
- const {isSmallScreenWidth} = useWindowDimensions();
/**
* @param {Boolean} isPaymentItem whether the item being rendered is the payments menu item
@@ -194,7 +192,7 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, u
accessibilityLabel={translate('workspace.new.newWorkspace')}
success
text={translate('workspace.new.newWorkspace')}
- onPress={() => App.createWorkspaceAndNavigateToIt('', false, '', false, !isSmallScreenWidth)}
+ onPress={() => App.createWorkspaceWithPolicyDraftAndNavigateToIt()}
/>
}
>
diff --git a/src/pages/workspace/withPolicy.js b/src/pages/workspace/withPolicy.js
index b1659ea2b7a6..6ca0ca67fce1 100644
--- a/src/pages/workspace/withPolicy.js
+++ b/src/pages/workspace/withPolicy.js
@@ -120,6 +120,12 @@ export default function (WrappedComponent) {
policyMembers: {
key: (props) => `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${getPolicyIDFromRoute(props.route)}`,
},
+ policyDraft: {
+ key: (props) => `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${getPolicyIDFromRoute(props.route)}`,
+ },
+ policyMembersDraft: {
+ key: (props) => `${ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS}${getPolicyIDFromRoute(props.route)}`,
+ },
})(withPolicy);
}
diff --git a/src/pages/workspace/withPolicyAndFullscreenLoading.js b/src/pages/workspace/withPolicyAndFullscreenLoading.js
index 29f1424a26f6..8265169434a3 100644
--- a/src/pages/workspace/withPolicyAndFullscreenLoading.js
+++ b/src/pages/workspace/withPolicyAndFullscreenLoading.js
@@ -1,7 +1,8 @@
import PropTypes from 'prop-types';
import React from 'react';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
+import isEmpty from 'lodash/isEmpty';
+import omit from 'lodash/omit';
import compose from '../../libs/compose';
import ONYXKEYS from '../../ONYXKEYS';
import withPolicy, {policyPropTypes, policyDefaultProps} from './withPolicy';
@@ -27,11 +28,11 @@ export default function (WrappedComponent) {
};
function WithPolicyAndFullscreenLoading(props) {
- if (props.isLoadingReportData && _.isEmpty(props.policy)) {
+ if (props.isLoadingReportData && isEmpty(props.policy) && isEmpty(props.policyDraft)) {
return ;
}
- const rest = _.omit(props, ['forwardedRef']);
+ const rest = omit(props, ['forwardedRef']);
return (
({addListener: () => () => {}, removeListener: () => () => {}}), []);
return (
-
+
+
+
},
cardMenuItem: {
- paddingLeft: 0,
+ paddingLeft: 8,
paddingRight: 0,
borderRadius: variables.buttonBorderRadius,
height: variables.componentSizeLarge,
@@ -3339,16 +3339,12 @@ const styles = (theme: ThemeDefault) =>
eReceiptAmountLarge: {
...headlineFont,
fontSize: variables.fontSizeEReceiptLarge,
- lineHeight: variables.lineHeightXXLarge,
- wordBreak: 'break-word',
textAlign: 'center',
},
eReceiptCurrency: {
...headlineFont,
fontSize: variables.fontSizeXXLarge,
- lineHeight: variables.lineHeightXXLarge,
- wordBreak: 'break-all',
},
eReceiptMerchant: {
@@ -3406,7 +3402,6 @@ const styles = (theme: ThemeDefault) =>
},
eReceiptContainer: {
- flex: 1,
width: 335,
minHeight: 540,
borderRadius: 20,
@@ -3674,6 +3669,7 @@ const styles = (theme: ThemeDefault) =>
paddingRight: 4,
marginBottom: 32,
alignSelf: 'flex-start',
+ ...userSelect.userSelectNone,
},
emojiPickerButtonDropdownIcon: {
@@ -3689,7 +3685,7 @@ const styles = (theme: ThemeDefault) =>
reportPreviewBox: {
backgroundColor: theme.cardBG,
borderRadius: variables.componentBorderRadiusLarge,
- maxWidth: variables.sideBarWidth,
+ maxWidth: variables.reportPreviewMaxWidth,
width: '100%',
},
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index b3a074234828..ea0af11d1b7a 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -167,7 +167,8 @@ export default {
eReceiptWordmarkWidth: 86,
eReceiptBGHeight: 540,
eReceiptBGHWidth: 335,
- reportPreviewMaxWidth: 302,
+ eReceiptTextContainerWidth: 263,
+ reportPreviewMaxWidth: 335,
reportActionImagesSingleImageHeight: 147,
reportActionImagesDoubleImageHeight: 138,
reportActionImagesMultipleImageHeight: 110,
diff --git a/src/types/onyx/AccountData.ts b/src/types/onyx/AccountData.ts
new file mode 100644
index 000000000000..79484e7886af
--- /dev/null
+++ b/src/types/onyx/AccountData.ts
@@ -0,0 +1,55 @@
+import * as OnyxCommon from './OnyxCommon';
+
+type AdditionalData = {
+ isP2PDebitCard?: boolean;
+ beneficialOwners?: string[];
+ currency?: string;
+ bankName?: string;
+ fieldsType?: string;
+ country?: string;
+};
+
+type AccountData = {
+ /** The masked bank account number */
+ accountNumber?: string;
+
+ /** The name of the institution (bank of america, etc */
+ addressName?: string;
+
+ /** Can we use this account to pay other people? */
+ allowDebit?: boolean;
+
+ /** Can we use this account to receive money from other people? */
+ defaultCredit?: boolean;
+
+ /** Is a saving account */
+ isSavings?: boolean;
+
+ /** Return whether or not this bank account has been risk checked */
+ riskChecked?: boolean;
+
+ /** Account routing number */
+ routingNumber?: string;
+
+ /** The status of the bank account */
+ state?: string;
+
+ /** All user emails that have access to this bank account */
+ sharees?: string[];
+
+ processor?: string;
+
+ /** The bankAccountID in the bankAccounts db */
+ bankAccountID?: number;
+
+ /** All data related to the bank account */
+ additionalData?: AdditionalData;
+
+ /** The bank account type */
+ type?: string;
+
+ /** Any error message to show */
+ errors?: OnyxCommon.Errors;
+};
+
+export default AccountData;
diff --git a/src/types/onyx/BankAccount.ts b/src/types/onyx/BankAccount.ts
index 5d309023e94a..58d9654f0c6f 100644
--- a/src/types/onyx/BankAccount.ts
+++ b/src/types/onyx/BankAccount.ts
@@ -1,53 +1,6 @@
import CONST from '../../CONST';
-
-type AdditionalData = {
- isP2PDebitCard?: boolean;
- beneficialOwners?: string[];
- currency?: string;
- bankName?: string;
- fieldsType?: string;
- country?: string;
-};
-
-type AccountData = {
- /** The masked bank account number */
- accountNumber?: string;
-
- /** The name of the institution (bank of america, etc */
- addressName?: string;
-
- /** Can we use this account to pay other people? */
- allowDebit?: boolean;
-
- /** Can we use this account to receive money from other people? */
- defaultCredit?: boolean;
-
- /** Is a saving account */
- isSavings?: boolean;
-
- /** Return whether or not this bank account has been risk checked */
- riskChecked?: boolean;
-
- /** Account routing number */
- routingNumber?: string;
-
- /** The status of the bank account */
- state?: string;
-
- /** All user emails that have access to this bank account */
- sharees?: string[];
-
- processor?: string;
-
- /** The bankAccountID in the bankAccounts db */
- bankAccountID?: number;
-
- /** All data related to the bank account */
- additionalData?: AdditionalData;
-
- /** The bank account type */
- type?: string;
-};
+import AccountData from './AccountData';
+import * as OnyxCommon from './OnyxCommon';
type BankAccount = {
/** The bank account type */
@@ -69,6 +22,13 @@ type BankAccount = {
/** All data related to the bank account */
accountData?: AccountData;
+
+ /** Any additional error message to show */
+ errors?: OnyxCommon.Errors;
+
+ /** Indicates the type of change made to the bank account that hasn't been synced with the server yet */
+ pendingAction?: OnyxCommon.PendingAction;
};
export default BankAccount;
+export type {AccountData};
diff --git a/src/types/onyx/Credentials.ts b/src/types/onyx/Credentials.ts
index f6a9ce669ad0..6bc36079f363 100644
--- a/src/types/onyx/Credentials.ts
+++ b/src/types/onyx/Credentials.ts
@@ -7,9 +7,12 @@ type Credentials = {
/** The validate code */
validateCode?: string;
- autoGeneratedLogin?: string;
- autoGeneratedPassword?: string;
+ autoGeneratedLogin: string;
+ autoGeneratedPassword: string;
accountID?: number;
+
+ partnerUserID: string;
+ partnerUserSecret: string;
};
export default Credentials;
diff --git a/src/types/onyx/Fund.ts b/src/types/onyx/Fund.ts
index 2da0edf78045..e27cc0e20e0e 100644
--- a/src/types/onyx/Fund.ts
+++ b/src/types/onyx/Fund.ts
@@ -1,4 +1,5 @@
import CONST from '../../CONST';
+import * as OnyxCommon from './OnyxCommon';
type AdditionalData = {
isBillingCard?: boolean;
@@ -30,6 +31,9 @@ type Fund = {
key?: string;
methodID?: number;
title?: string;
+ isDefault?: boolean;
+ errors?: OnyxCommon.Errors;
+ pendingAction?: OnyxCommon.PendingAction;
};
export default Fund;
diff --git a/src/types/onyx/PaymentMethod.ts b/src/types/onyx/PaymentMethod.ts
new file mode 100644
index 000000000000..773e6ff1197c
--- /dev/null
+++ b/src/types/onyx/PaymentMethod.ts
@@ -0,0 +1,11 @@
+import {SvgProps} from 'react-native-svg';
+import BankAccount from './BankAccount';
+import Fund from './Fund';
+
+type PaymentMethod = (BankAccount | Fund) & {
+ description: string;
+ icon: React.FC;
+ iconSize?: number;
+};
+
+export default PaymentMethod;
diff --git a/src/types/onyx/PersonalBankAccount.ts b/src/types/onyx/PersonalBankAccount.ts
index 06f505a04196..ea993d7393e8 100644
--- a/src/types/onyx/PersonalBankAccount.ts
+++ b/src/types/onyx/PersonalBankAccount.ts
@@ -1,6 +1,8 @@
+import * as OnyxCommon from './OnyxCommon';
+
type PersonalBankAccount = {
/** An error message to display to the user */
- error?: string;
+ errors?: OnyxCommon.Errors;
/** Whether we should show the view that the bank account was successfully added */
shouldShowSuccess?: boolean;
@@ -10,6 +12,9 @@ type PersonalBankAccount = {
/** The account ID of the selected bank account from Plaid */
plaidAccountID?: string;
+
+ /** Any reportID we should redirect to at the end of the flow */
+ exitReportID?: string;
};
export default PersonalBankAccount;
diff --git a/src/types/onyx/PlaidBankAccount.ts b/src/types/onyx/PlaidBankAccount.ts
index d89e8ac3082d..c7cd7b5d83e4 100644
--- a/src/types/onyx/PlaidBankAccount.ts
+++ b/src/types/onyx/PlaidBankAccount.ts
@@ -19,6 +19,9 @@ type PlaidBankAccount = {
/** Plaid access token, used to then retrieve Assets and Balances */
plaidAccessToken: string;
+
+ /** Name of the bank */
+ bankName?: string;
};
export default PlaidBankAccount;
diff --git a/src/types/onyx/ReimbursementAccount.ts b/src/types/onyx/ReimbursementAccount.ts
index 4f2a05d7f4e1..fc46c9aa3132 100644
--- a/src/types/onyx/ReimbursementAccount.ts
+++ b/src/types/onyx/ReimbursementAccount.ts
@@ -2,12 +2,16 @@ import {ValueOf} from 'type-fest';
import * as OnyxCommon from './OnyxCommon';
import CONST from '../../CONST';
+type BankAccountStep = ValueOf;
+
+type BankAccountSubStep = ValueOf;
+
type ACHData = {
/** Step of the setup flow that we are on. Determines which view is presented. */
- currentStep: ValueOf;
+ currentStep: BankAccountStep;
/** Optional subStep we would like the user to start back on */
- subStep?: ValueOf;
+ subStep?: BankAccountSubStep;
/** Bank account state */
state?: string;
@@ -38,7 +42,11 @@ type ReimbursementAccount = {
/** Any additional error message to show */
errors?: OnyxCommon.Errors;
+ /** Draft step of the setup flow from Onyx */
+ draftStep?: BankAccountStep;
+
pendingAction?: OnyxCommon.PendingAction;
};
export default ReimbursementAccount;
+export type {BankAccountStep, BankAccountSubStep};
diff --git a/src/types/onyx/ReimbursementAccountDraft.ts b/src/types/onyx/ReimbursementAccountDraft.ts
index d55c2b5b3567..cab1283943bc 100644
--- a/src/types/onyx/ReimbursementAccountDraft.ts
+++ b/src/types/onyx/ReimbursementAccountDraft.ts
@@ -1,10 +1,19 @@
-type ReimbursementAccountDraft = {
+type OnfidoData = Record;
+
+type BankAccountStepProps = {
accountNumber?: string;
routingNumber?: string;
acceptTerms?: boolean;
plaidAccountID?: string;
plaidMask?: string;
+};
+
+type CompanyStepProps = {
companyName?: string;
+ addressStreet?: string;
+ addressCity?: string;
+ addressState?: string;
+ addressZipCode?: string;
companyPhone?: string;
website?: string;
companyTaxID?: string;
@@ -12,13 +21,32 @@ type ReimbursementAccountDraft = {
incorporationDate?: string | Date;
incorporationState?: string;
hasNoConnectionToCannabis?: boolean;
+};
+
+type RequestorStepProps = {
+ firstName?: string;
+ lastName?: string;
+ requestorAddressStreet?: string;
+ requestorAddressCity?: string;
+ requestorAddressState?: string;
+ requestorAddressZipCode?: string;
+ dob?: string | Date;
+ ssnLast4?: string;
isControllingOfficer?: boolean;
isOnfidoSetupComplete?: boolean;
+ onfidoData?: OnfidoData;
+};
+
+type ACHContractStepProps = {
ownsMoreThan25Percent?: boolean;
hasOtherBeneficialOwners?: boolean;
acceptTermsAndConditions?: boolean;
certifyTrueInformation?: boolean;
beneficialOwners?: string[];
+};
+
+type ReimbursementAccountProps = {
+ bankAccountID?: number;
isSavings?: boolean;
bankName?: string;
plaidAccessToken?: string;
@@ -27,4 +55,7 @@ type ReimbursementAccountDraft = {
amount3?: string;
};
+type ReimbursementAccountDraft = BankAccountStepProps & CompanyStepProps & RequestorStepProps & ACHContractStepProps & ReimbursementAccountProps;
+
export default ReimbursementAccountDraft;
+export type {ACHContractStepProps, RequestorStepProps, OnfidoData, BankAccountStepProps, CompanyStepProps, ReimbursementAccountProps};
diff --git a/src/types/onyx/Request.ts b/src/types/onyx/Request.ts
index 94f14af0ddb3..836138ca99ba 100644
--- a/src/types/onyx/Request.ts
+++ b/src/types/onyx/Request.ts
@@ -1,12 +1,26 @@
import {OnyxUpdate} from 'react-native-onyx';
+import Response from './Response';
-type Request = {
+type OnyxData = {
+ successData?: OnyxUpdate[];
+ failureData?: OnyxUpdate[];
+ optimisticData?: OnyxUpdate[];
+};
+
+type RequestData = {
command: string;
+ commandName?: string;
data?: Record;
type?: string;
shouldUseSecure?: boolean;
successData?: OnyxUpdate[];
failureData?: OnyxUpdate[];
+
+ resolve?: (value: Response) => void;
+ reject?: (value?: unknown) => void;
};
+type Request = RequestData & OnyxData;
+
export default Request;
+export type {OnyxData};
diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts
index 255ac6d9bae4..3d834d0bcb2b 100644
--- a/src/types/onyx/Response.ts
+++ b/src/types/onyx/Response.ts
@@ -6,6 +6,9 @@ type Response = {
jsonCode?: number | string;
onyxData?: OnyxUpdate[];
requestID?: string;
+ shouldPauseQueue?: boolean;
+ authToken?: string;
+ encryptedAuthToken?: string;
message?: string;
};
diff --git a/src/types/onyx/Session.ts b/src/types/onyx/Session.ts
index 75cb4f4818ad..62930e3b2c27 100644
--- a/src/types/onyx/Session.ts
+++ b/src/types/onyx/Session.ts
@@ -1,3 +1,5 @@
+import * as OnyxCommon from './OnyxCommon';
+
type Session = {
/** The user's email for the current session */
email?: string;
@@ -5,6 +7,8 @@ type Session = {
/** Currently logged in user authToken */
authToken?: string;
+ supportAuthToken?: string;
+
/** Currently logged in user encrypted authToken */
encryptedAuthToken?: string;
@@ -12,6 +16,8 @@ type Session = {
accountID?: number;
autoAuthState?: string;
+ /** Server side errors keyed by microtime */
+ errors?: OnyxCommon.Errors;
};
export default Session;
diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts
index 292addbb142e..21be3c49497e 100644
--- a/src/types/onyx/Transaction.ts
+++ b/src/types/onyx/Transaction.ts
@@ -3,10 +3,22 @@ import * as OnyxCommon from './OnyxCommon';
import CONST from '../../CONST';
import RecentWaypoint from './RecentWaypoint';
-type WaypointCollection = Record;
+type Waypoint = {
+ /** The full address of the waypoint */
+ address?: string;
+
+ /** The lattitude of the waypoint */
+ lat?: number;
+
+ /** The longitude of the waypoint */
+ lng?: number;
+};
+
+type WaypointCollection = Record;
type Comment = {
comment?: string;
waypoints?: WaypointCollection;
+ isLoading?: boolean;
type?: string;
customUnit?: Record;
source?: string;
@@ -77,4 +89,4 @@ type Transaction = {
};
export default Transaction;
-export type {WaypointCollection, Comment, Receipt};
+export type {WaypointCollection, Comment, Receipt, Waypoint};
diff --git a/src/types/onyx/WalletTransfer.ts b/src/types/onyx/WalletTransfer.ts
index 3dd28729ba96..18b223a0b1ef 100644
--- a/src/types/onyx/WalletTransfer.ts
+++ b/src/types/onyx/WalletTransfer.ts
@@ -1,5 +1,7 @@
+import {ValueOf} from 'type-fest';
import CONST from '../../CONST';
import * as OnyxCommon from './OnyxCommon';
+import PaymentMethod from './PaymentMethod';
type WalletTransfer = {
/** Selected accountID for transfer */
@@ -9,7 +11,7 @@ type WalletTransfer = {
selectedAccountType?: string;
/** Type to filter the payment Method list */
- filterPaymentMethodType?: typeof CONST.PAYMENT_METHODS.DEBIT_CARD | typeof CONST.PAYMENT_METHODS.BANK_ACCOUNT;
+ filterPaymentMethodType?: FilterMethodPaymentType;
/** Whether the success screen is shown to user. */
shouldShowSuccess?: boolean;
@@ -19,6 +21,12 @@ type WalletTransfer = {
/** Whether or not data is loading */
loading?: boolean;
+
+ paymentMethodType?: ValueOf>;
};
+type FilterMethodPaymentType = typeof CONST.PAYMENT_METHODS.DEBIT_CARD | typeof CONST.PAYMENT_METHODS.BANK_ACCOUNT | null;
+
export default WalletTransfer;
+
+export type {FilterMethodPaymentType};
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
index e50925e7adf2..571c2e04a390 100644
--- a/src/types/onyx/index.ts
+++ b/src/types/onyx/index.ts
@@ -46,6 +46,7 @@ import RecentWaypoint from './RecentWaypoint';
import RecentlyUsedCategories from './RecentlyUsedCategories';
import RecentlyUsedTags from './RecentlyUsedTags';
import PolicyTag from './PolicyTag';
+import AccountData from './AccountData';
export type {
Account,
@@ -98,4 +99,5 @@ export type {
RecentlyUsedCategories,
RecentlyUsedTags,
PolicyTag,
+ AccountData,
};
diff --git a/src/types/utils/ChildrenProps.ts b/src/types/utils/ChildrenProps.ts
new file mode 100644
index 000000000000..896f6ff62006
--- /dev/null
+++ b/src/types/utils/ChildrenProps.ts
@@ -0,0 +1,8 @@
+import type {ReactNode} from 'react';
+
+type ChildrenProps = {
+ /** Rendered child component */
+ children: ReactNode;
+};
+
+export default ChildrenProps;
diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js
index 63fd7a0dd78b..f530e5892e94 100644
--- a/tests/actions/IOUTest.js
+++ b/tests/actions/IOUTest.js
@@ -13,6 +13,7 @@ import OnyxUpdateManager from '../../src/libs/actions/OnyxUpdateManager';
import waitForNetworkPromises from '../utils/waitForNetworkPromises';
import * as ReportUtils from '../../src/libs/ReportUtils';
import * as ReportActionsUtils from '../../src/libs/ReportActionsUtils';
+import * as PolicyActions from '../../src/libs/actions/Policy';
import * as PersonalDetailsUtils from '../../src/libs/PersonalDetailsUtils';
import * as User from '../../src/libs/actions/User';
import PusherHelper from '../utils/PusherHelper';
@@ -2158,4 +2159,206 @@ describe('actions/IOU', () => {
expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(chatReport.reportID));
});
});
+
+ describe('submitReport', () => {
+ it('correctly submits a report', () => {
+ const amount = 10000;
+ const comment = '💸💸💸💸';
+ const merchant = 'NASDAQ';
+ let expenseReport = {};
+ let chatReport = {};
+ return waitForBatchedUpdates()
+ .then(() => {
+ PolicyActions.createWorkspace(CARLOS_EMAIL, true, "Carlos's Workspace");
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ chatReport = _.find(allReports, (report) => report.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT);
+
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(() => {
+ IOU.requestMoney(
+ chatReport,
+ amount,
+ CONST.CURRENCY.USD,
+ '',
+ merchant,
+ RORY_EMAIL,
+ RORY_ACCOUNT_ID,
+ {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID},
+ comment,
+ );
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ expenseReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.EXPENSE);
+ Onyx.merge(`report_${expenseReport.reportID}`, {
+ statusNum: 0,
+ stateNum: 0,
+ });
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ expenseReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.EXPENSE);
+
+ // Verify report is a draft
+ expect(expenseReport.stateNum).toBe(0);
+ expect(expenseReport.statusNum).toBe(0);
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(() => {
+ IOU.submitReport(expenseReport);
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ expenseReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.EXPENSE);
+
+ // Report was submitted correctly
+ expect(expenseReport.stateNum).toBe(1);
+ expect(expenseReport.statusNum).toBe(1);
+ resolve();
+ },
+ });
+ }),
+ );
+ });
+ it('correctly implements error handling', () => {
+ const amount = 10000;
+ const comment = '💸💸💸💸';
+ const merchant = 'NASDAQ';
+ let expenseReport = {};
+ let chatReport = {};
+ return waitForBatchedUpdates()
+ .then(() => {
+ PolicyActions.createWorkspace(CARLOS_EMAIL, true, "Carlos's Workspace");
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ chatReport = _.find(allReports, (report) => report.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT);
+
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(() => {
+ IOU.requestMoney(
+ chatReport,
+ amount,
+ CONST.CURRENCY.USD,
+ '',
+ merchant,
+ RORY_EMAIL,
+ RORY_ACCOUNT_ID,
+ {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID},
+ comment,
+ );
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ expenseReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.EXPENSE);
+ Onyx.merge(`report_${expenseReport.reportID}`, {
+ statusNum: 0,
+ stateNum: 0,
+ });
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ expenseReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.EXPENSE);
+
+ // Verify report is a draft
+ expect(expenseReport.stateNum).toBe(0);
+ expect(expenseReport.statusNum).toBe(0);
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(() => {
+ fetch.fail();
+ IOU.submitReport(expenseReport);
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ expenseReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.EXPENSE);
+
+ // Report was submitted with some fail
+ expect(expenseReport.stateNum).toBe(0);
+ expect(expenseReport.statusNum).toBe(0);
+ resolve();
+ },
+ });
+ }),
+ );
+ });
+ });
});
diff --git a/tests/perf-test/GooglePlacesUtils.perf-test.js b/tests/perf-test/GooglePlacesUtils.perf-test.js
new file mode 100644
index 000000000000..92c3e0eb87b7
--- /dev/null
+++ b/tests/perf-test/GooglePlacesUtils.perf-test.js
@@ -0,0 +1,163 @@
+import {measureFunction} from 'reassure';
+import * as GooglePlacesUtils from '../../src/libs/GooglePlacesUtils';
+
+jest.setTimeout(60000);
+
+const addressComponents = [
+ {
+ long_name: 'Bushwick',
+ short_name: 'Bushwick',
+ types: ['neighborhood', 'political'],
+ },
+ {
+ long_name: 'Brooklyn',
+ short_name: 'Brooklyn',
+ types: ['sublocality_level_1', 'sublocality', 'political'],
+ },
+ {
+ long_name: 'New York',
+ short_name: 'NY',
+ types: ['administrative_area_level_1', 'political'],
+ },
+ {
+ long_name: 'United States',
+ short_name: 'US',
+ types: ['country', 'political'],
+ },
+ {
+ long_name: '11206',
+ short_name: '11206',
+ types: ['postal_code'],
+ },
+ {
+ long_name: 'United Kingdom',
+ short_name: 'UK',
+ types: ['country', 'political'],
+ },
+];
+
+const bigObjectToFind = {
+ sublocality: 'long_name',
+ administrative_area_level_1: 'short_name',
+ postal_code: 'long_name',
+ 'doesnt-exist': 'long_name',
+ s1ublocality: 'long_name',
+ a1dministrative_area_level_1: 'short_name',
+ p1ostal_code: 'long_name',
+ '1doesnt-exist': 'long_name',
+ s2ublocality: 'long_name',
+ a2dministrative_area_level_1: 'short_name',
+ p2ostal_code: 'long_name',
+ '2doesnt-exist': 'long_name',
+ s3ublocality: 'long_name',
+ a3dministrative_area_level_1: 'short_name',
+ p3ostal_code: 'long_name',
+ '3doesnt-exist': 'long_name',
+ s4ublocality: 'long_name',
+ a4dministrative_area_level_1: 'short_name',
+ p4ostal_code: 'long_name',
+ '4doesnt-exist': 'long_name',
+ s5ublocality: 'long_name',
+ a5dministrative_area_level_1: 'short_name',
+ p5ostal_code: 'long_name',
+ '5doesnt-exist': 'long_name',
+ s6ublocality: 'long_name',
+ a6dministrative_area_level_1: 'short_name',
+ p6ostal_code: 'long_name',
+ '6doesnt-exist': 'long_name',
+ s7ublocality: 'long_name',
+ a7dministrative_area_level_1: 'short_name',
+ p7ostal_code: 'long_name',
+ '7doesnt-exist': 'long_name',
+ s8ublocality: 'long_name',
+ a8dministrative_area_level_1: 'short_name',
+ p8ostal_code: 'long_name',
+ '8doesnt-exist': 'long_name',
+ s9ublocality: 'long_name',
+ a9dministrative_area_level_1: 'short_name',
+ p9ostal_code: 'long_name',
+ '9doesnt-exist': 'long_name',
+ s10ublocality: 'long_name',
+ a10dministrative_area_level_1: 'short_name',
+ p10ostal_code: 'long_name',
+ '10doesnt-exist': 'long_name',
+ s11ublocality: 'long_name',
+ a11dministrative_area_level_1: 'short_name',
+ p11ostal_code: 'long_name',
+ '11doesnt-exist': 'long_name',
+ s12ublocality: 'long_name',
+ a12dministrative_area_level_1: 'short_name',
+ p12ostal_code: 'long_name',
+ '12doesnt-exist': 'long_name',
+ s13ublocality: 'long_name',
+ a13dministrative_area_level_1: 'short_name',
+ p13ostal_code: 'long_name',
+ '13doesnt-exist': 'long_name',
+ s14ublocality: 'long_name',
+ a14dministrative_area_level_1: 'short_name',
+ p14ostal_code: 'long_name',
+ '14doesnt-exist': 'long_name',
+ s15ublocality: 'long_name',
+ a15dministrative_area_level_1: 'short_name',
+ p15ostal_code: 'long_name',
+ '15doesnt-exist': 'long_name',
+ s16ublocality: 'long_name',
+ a16dministrative_area_level_1: 'short_name',
+ p16ostal_code: 'long_name',
+ '16doesnt-exist': 'long_name',
+ s17ublocality: 'long_name',
+ a17dministrative_area_level_1: 'short_name',
+ p17ostal_code: 'long_name',
+ '17doesnt-exist': 'long_name',
+ s18ublocality: 'long_name',
+ a18dministrative_area_level_1: 'short_name',
+ p18ostal_code: 'long_name',
+ '18doesnt-exist': 'long_name',
+ s19ublocality: 'long_name',
+ a19dministrative_area_level_1: 'short_name',
+ p19ostal_code: 'long_name',
+ '19doesnt-exist': 'long_name',
+ s20ublocality: 'long_name',
+ a20dministrative_area_level_1: 'short_name',
+ p20ostal_code: 'long_name',
+ '20doesnt-exist': 'long_name',
+ s21ublocality: 'long_name',
+ a21dministrative_area_level_1: 'short_name',
+ p21ostal_code: 'long_name',
+ '21doesnt-exist': 'long_name',
+ s22ublocality: 'long_name',
+ a22dministrative_area_level_1: 'short_name',
+ p22ostal_code: 'long_name',
+ '22doesnt-exist': 'long_name',
+ s23ublocality: 'long_name',
+ a23dministrative_area_level_1: 'short_name',
+ p23ostal_code: 'long_name',
+ '23doesnt-exist': 'long_name',
+ s24ublocality: 'long_name',
+ a24dministrative_area_level_1: 'short_name',
+ p24ostal_code: 'long_name',
+ '24doesnt-exist': 'long_name',
+ s25ublocality: 'long_name',
+ a25dministrative_area_level_1: 'short_name',
+ p25ostal_code: 'long_name',
+ '25doesnt-exist': 'long_name',
+};
+
+/**
+ * This function will be executed 20 times and the average time will be used on the comparison.
+ * It will fail based on the CI configuration around Reassure:
+ * @see /.github/workflows/reassurePerformanceTests.yml
+ *
+ * Max deviation on the duration is set to 20% at the time of writing.
+ *
+ * More on the measureFunction API:
+ * @see https://callstack.github.io/reassure/docs/api#measurefunction-function
+ */
+test('getAddressComponents on a big dataset', async () => {
+ await measureFunction(
+ () => {
+ GooglePlacesUtils.getAddressComponents(addressComponents, bigObjectToFind);
+ },
+ {runs: 20},
+ );
+});
diff --git a/tests/ui/UnreadIndicatorsTest.js b/tests/ui/UnreadIndicatorsTest.js
index 361eb8f87081..a9ffe258ac7f 100644
--- a/tests/ui/UnreadIndicatorsTest.js
+++ b/tests/ui/UnreadIndicatorsTest.js
@@ -309,6 +309,7 @@ describe('Unread Indicators', () => {
lastVisibleActionCreated: DateUtils.getDBTime(utcToZonedTime(NEW_REPORT_FIST_MESSAGE_CREATED_DATE, 'UTC').valueOf()),
lastMessageText: 'Comment 1',
participantAccountIDs: [USER_C_ACCOUNT_ID],
+ type: CONST.REPORT.TYPE.CHAT,
},
},
{
diff --git a/tests/unit/CalendarPickerTest.js b/tests/unit/CalendarPickerTest.js
index 512a86a25e19..235dff45f631 100644
--- a/tests/unit/CalendarPickerTest.js
+++ b/tests/unit/CalendarPickerTest.js
@@ -1,17 +1,10 @@
import {render, fireEvent, within} from '@testing-library/react-native';
-import {format, eachMonthOfInterval, subYears, addYears} from 'date-fns';
+import {subYears, addYears} from 'date-fns';
import DateUtils from '../../src/libs/DateUtils';
import CalendarPicker from '../../src/components/NewDatePicker/CalendarPicker';
import CONST from '../../src/CONST';
-DateUtils.setLocale(CONST.LOCALES.EN);
-const fullYear = new Date().getFullYear();
-const monthsArray = eachMonthOfInterval({
- start: new Date(fullYear, 0, 1), // January 1st of the current year
- end: new Date(fullYear, 11, 31), // December 31st of the current year
-});
-// eslint-disable-next-line rulesdir/prefer-underscore-method
-const monthNames = monthsArray.map((monthDate) => format(monthDate, CONST.DATE.MONTH_FORMAT));
+const monthNames = DateUtils.getMonthNames(CONST.LOCALES.EN);
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({navigate: jest.fn()}),
diff --git a/tests/unit/GooglePlacesUtilsTest.js b/tests/unit/GooglePlacesUtilsTest.js
index 4805e180eedc..b94068f521ff 100644
--- a/tests/unit/GooglePlacesUtilsTest.js
+++ b/tests/unit/GooglePlacesUtilsTest.js
@@ -15,93 +15,6 @@ const objectWithCountryToFind = {
country: 'long_name',
};
-const bigObjectToFind = {
- sublocality: 'long_name',
- administrative_area_level_1: 'short_name',
- postal_code: 'long_name',
- 'doesnt-exist': 'long_name',
- s1ublocality: 'long_name',
- a1dministrative_area_level_1: 'short_name',
- p1ostal_code: 'long_name',
- '1doesnt-exist': 'long_name',
- s2ublocality: 'long_name',
- a2dministrative_area_level_1: 'short_name',
- p2ostal_code: 'long_name',
- '2doesnt-exist': 'long_name',
- s3ublocality: 'long_name',
- a3dministrative_area_level_1: 'short_name',
- p3ostal_code: 'long_name',
- '3doesnt-exist': 'long_name',
- s4ublocality: 'long_name',
- a4dministrative_area_level_1: 'short_name',
- p4ostal_code: 'long_name',
- '4doesnt-exist': 'long_name',
- s5ublocality: 'long_name',
- a5dministrative_area_level_1: 'short_name',
- p5ostal_code: 'long_name',
- '5doesnt-exist': 'long_name',
- s6ublocality: 'long_name',
- a6dministrative_area_level_1: 'short_name',
- p6ostal_code: 'long_name',
- '6doesnt-exist': 'long_name',
- s7ublocality: 'long_name',
- a7dministrative_area_level_1: 'short_name',
- p7ostal_code: 'long_name',
- '7doesnt-exist': 'long_name',
- s8ublocality: 'long_name',
- a8dministrative_area_level_1: 'short_name',
- p8ostal_code: 'long_name',
- '8doesnt-exist': 'long_name',
- s9ublocality: 'long_name',
- a9dministrative_area_level_1: 'short_name',
- p9ostal_code: 'long_name',
- '9doesnt-exist': 'long_name',
- s10ublocality: 'long_name',
- a10dministrative_area_level_1: 'short_name',
- p10ostal_code: 'long_name',
- '10doesnt-exist': 'long_name',
- s11ublocality: 'long_name',
- a11dministrative_area_level_1: 'short_name',
- p11ostal_code: 'long_name',
- '11doesnt-exist': 'long_name',
- s12ublocality: 'long_name',
- a12dministrative_area_level_1: 'short_name',
- p12ostal_code: 'long_name',
- '12doesnt-exist': 'long_name',
- s13ublocality: 'long_name',
- a13dministrative_area_level_1: 'short_name',
- p13ostal_code: 'long_name',
- '13doesnt-exist': 'long_name',
- s14ublocality: 'long_name',
- a14dministrative_area_level_1: 'short_name',
- p14ostal_code: 'long_name',
- '14doesnt-exist': 'long_name',
- s15ublocality: 'long_name',
- a15dministrative_area_level_1: 'short_name',
- p15ostal_code: 'long_name',
- '15doesnt-exist': 'long_name',
- s16ublocality: 'long_name',
- a16dministrative_area_level_1: 'short_name',
- p16ostal_code: 'long_name',
- '16doesnt-exist': 'long_name',
- s17ublocality: 'long_name',
- a17dministrative_area_level_1: 'short_name',
- p17ostal_code: 'long_name',
- '17doesnt-exist': 'long_name',
- s18ublocality: 'long_name',
- a18dministrative_area_level_1: 'short_name',
- p18ostal_code: 'long_name',
- '18doesnt-exist': 'long_name',
- s19ublocality: 'long_name',
- a19dministrative_area_level_1: 'short_name',
- p19ostal_code: 'long_name',
- '19doesnt-exist': 'long_name',
- s20ublocality: 'long_name',
- a20dministrative_area_level_1: 'short_name',
- p20ostal_code: 'long_name',
- '20doesnt-exist': 'long_name',
-};
-
const addressComponents = [
{
long_name: 'Bushwick',
@@ -166,35 +79,6 @@ describe('GooglePlacesUtilsTest', () => {
});
});
});
- describe.skip('getAddressComponents small data set timing', () => {
- it('should not be slow when executing', () => {
- const startTime = performance.now();
- for (let i = 100; i > 0; i--) {
- GooglePlacesUtils.getAddressComponents(addressComponents, standardObjectToFind);
- }
- const endTime = performance.now();
- const executionTime = endTime - startTime;
-
- // When timing this method it was roughly 0.45087499999999636ms so this would be almost twice as slow
- // which I think is a meaningful regression we should avoid
- expect(executionTime).toBeLessThan(1.0);
- });
- });
- describe.skip('getAddressComponents big data set timing', () => {
- it('should not be slow when executing', () => {
- const startTime = performance.now();
- for (let i = 100; i > 0; i--) {
- GooglePlacesUtils.getAddressComponents(addressComponents, bigObjectToFind);
- }
- const endTime = performance.now();
- const executionTime = endTime - startTime;
-
- // When timing this method it was roughly 1.211708999999928ms locally
- // but 3.2214480000000094ms on github actions so using 5ms arbitrarily here for now
- // and we can change if needed later.
- expect(executionTime).toBeLessThan(5.0);
- });
- });
describe('getPlaceAutocompleteTerms', () => {
it('should find auto complete terms', () => {
expect(GooglePlacesUtils.getPlaceAutocompleteTerms(autoCompleteTerms)).toStrictEqual({
diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js
index 437d37e625dd..eda743f85aa2 100644
--- a/tests/unit/OptionsListUtilsTest.js
+++ b/tests/unit/OptionsListUtilsTest.js
@@ -18,6 +18,7 @@ describe('OptionsListUtils', () => {
participantAccountIDs: [2, 1],
reportName: 'Iron Man, Mister Fantastic',
hasDraft: true,
+ type: CONST.REPORT.TYPE.CHAT,
},
2: {
lastReadTime: '2021-01-14 11:25:39.296',
@@ -26,6 +27,7 @@ describe('OptionsListUtils', () => {
reportID: 2,
participantAccountIDs: [3],
reportName: 'Spider-Man',
+ type: CONST.REPORT.TYPE.CHAT,
},
// This is the only report we are pinning in this test
@@ -36,6 +38,7 @@ describe('OptionsListUtils', () => {
reportID: 3,
participantAccountIDs: [1],
reportName: 'Mister Fantastic',
+ type: CONST.REPORT.TYPE.CHAT,
},
4: {
lastReadTime: '2021-01-14 11:25:39.298',
@@ -44,6 +47,7 @@ describe('OptionsListUtils', () => {
reportID: 4,
participantAccountIDs: [4],
reportName: 'Black Panther',
+ type: CONST.REPORT.TYPE.CHAT,
},
5: {
lastReadTime: '2021-01-14 11:25:39.299',
@@ -52,6 +56,7 @@ describe('OptionsListUtils', () => {
reportID: 5,
participantAccountIDs: [5],
reportName: 'Invisible Woman',
+ type: CONST.REPORT.TYPE.CHAT,
},
6: {
lastReadTime: '2021-01-14 11:25:39.300',
@@ -60,6 +65,7 @@ describe('OptionsListUtils', () => {
reportID: 6,
participantAccountIDs: [6],
reportName: 'Thor',
+ type: CONST.REPORT.TYPE.CHAT,
},
// Note: This report has the largest lastVisibleActionCreated
@@ -70,6 +76,7 @@ describe('OptionsListUtils', () => {
reportID: 7,
participantAccountIDs: [7],
reportName: 'Captain America',
+ type: CONST.REPORT.TYPE.CHAT,
},
// Note: This report has no lastVisibleActionCreated
@@ -80,6 +87,7 @@ describe('OptionsListUtils', () => {
reportID: 8,
participantAccountIDs: [12],
reportName: 'Silver Surfer',
+ type: CONST.REPORT.TYPE.CHAT,
},
// Note: This report has an IOU
@@ -92,6 +100,7 @@ describe('OptionsListUtils', () => {
reportName: 'Mister Sinister',
iouReportID: 100,
hasOutstandingIOU: true,
+ type: CONST.REPORT.TYPE.CHAT,
},
// This report is an archived room – it does not have a name and instead falls back on oldPolicyName
@@ -105,6 +114,7 @@ describe('OptionsListUtils', () => {
oldPolicyName: "SHIELD's workspace",
chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
isOwnPolicyExpenseChat: true,
+ type: CONST.REPORT.TYPE.CHAT,
// This indicates that the report is archived
stateNum: 2,
@@ -179,6 +189,7 @@ describe('OptionsListUtils', () => {
reportID: 11,
participantAccountIDs: [999],
reportName: 'Concierge',
+ type: CONST.REPORT.TYPE.CHAT,
},
};
@@ -191,6 +202,7 @@ describe('OptionsListUtils', () => {
reportID: 12,
participantAccountIDs: [1000],
reportName: 'Chronos',
+ type: CONST.REPORT.TYPE.CHAT,
},
};
@@ -203,6 +215,7 @@ describe('OptionsListUtils', () => {
reportID: 13,
participantAccountIDs: [1001],
reportName: 'Receipts',
+ type: CONST.REPORT.TYPE.CHAT,
},
};
@@ -219,6 +232,7 @@ describe('OptionsListUtils', () => {
isArchivedRoom: false,
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS,
isOwnPolicyExpenseChat: true,
+ type: CONST.REPORT.TYPE.CHAT,
},
};