diff --git a/.github/workflows/authorChecklist.yml b/.github/workflows/authorChecklist.yml
index ecb0b87a6416..907b1e7be6ca 100644
--- a/.github/workflows/authorChecklist.yml
+++ b/.github/workflows/authorChecklist.yml
@@ -13,7 +13,8 @@ jobs:
runs-on: ubuntu-latest
if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]'
steps:
- - uses: actions/checkout@v4
+ - name: Checkout
+ uses: actions/checkout@v4
- name: authorChecklist.js
uses: ./.github/actions/javascript/authorChecklist
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index f6deaae963e4..63148f9e4eb5 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -14,8 +14,9 @@ jobs:
with:
ref: staging
token: ${{ secrets.OS_BOTIFY_TOKEN }}
-
- - uses: ./.github/actions/composite/setupGitForOSBotifyApp
+
+ - name: Setup git for OSBotify
+ uses: ./.github/actions/composite/setupGitForOSBotifyApp
id: setupGitForOSBotify
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
@@ -38,7 +39,8 @@ jobs:
ref: production
token: ${{ secrets.OS_BOTIFY_TOKEN }}
- - uses: ./.github/actions/composite/setupGitForOSBotifyApp
+ - name: Setup git for OSBotify
+ uses: ./.github/actions/composite/setupGitForOSBotifyApp
id: setupGitForOSBotify
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml
index 7fb5feaf6084..2285eec56065 100644
--- a/.github/workflows/finishReleaseCycle.yml
+++ b/.github/workflows/finishReleaseCycle.yml
@@ -18,7 +18,8 @@ jobs:
ref: main
token: ${{ secrets.OS_BOTIFY_TOKEN }}
- - uses: ./.github/actions/composite/setupGitForOSBotifyApp
+ - name: Setup git for OSBotify
+ uses: ./.github/actions/composite/setupGitForOSBotifyApp
id: setupGitForOSBotify
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
@@ -82,7 +83,7 @@ jobs:
ref: staging
token: ${{ secrets.OS_BOTIFY_TOKEN }}
- - name: Setup Git for OSBotify
+ - name: Setup git for OSBotify
id: setupGitForOSBotify
uses: ./.github/actions/composite/setupGitForOSBotifyApp
with:
@@ -124,7 +125,7 @@ jobs:
ref: main
token: ${{ secrets.OS_BOTIFY_TOKEN }}
- - name: Setup Git for OSBotify
+ - name: Setup git for OSBotify
uses: ./.github/actions/composite/setupGitForOSBotifyApp
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
diff --git a/.github/workflows/testGithubActionsWorkflows.yml b/.github/workflows/testGithubActionsWorkflows.yml
new file mode 100644
index 000000000000..58de3ba2d9f3
--- /dev/null
+++ b/.github/workflows/testGithubActionsWorkflows.yml
@@ -0,0 +1,35 @@
+name: Test GitHub Actions workflows
+
+on:
+ workflow_dispatch:
+ workflow_call:
+ pull_request:
+ types: [opened, reopened, edited, synchronize]
+ branches-ignore: [staging, production]
+ paths: ['.github/**']
+
+jobs:
+ testGHWorkflows:
+ if: ${{ github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' || github.event_name == 'workflow_call' }}
+ runs-on: ubuntu-latest
+ env:
+ CI: true
+ name: test GitHub Workflows
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Setup Node
+ uses: Expensify/App/.github/actions/composite/setupNode@main
+
+ - name: Setup Homebrew
+ uses: Homebrew/actions/setup-homebrew@master
+
+ - name: Install Act
+ run: brew install act
+
+ - name: Set ACT_BINARY
+ run: echo "ACT_BINARY=$(which act)" >> "$GITHUB_ENV"
+
+ - name: Run tests
+ run: npm run workflow-test
diff --git a/Gemfile.lock b/Gemfile.lock
index 079b5a5b742b..93dab195ebdd 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -18,21 +18,21 @@ GEM
rubyzip (~> 2.0)
artifactory (3.0.15)
atomos (0.1.3)
- aws-eventstream (1.2.0)
- aws-partitions (1.824.0)
- aws-sdk-core (3.181.1)
+ aws-eventstream (1.3.0)
+ aws-partitions (1.857.0)
+ aws-sdk-core (3.188.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
- aws-sdk-kms (1.71.0)
- aws-sdk-core (~> 3, >= 3.177.0)
+ aws-sdk-kms (1.73.0)
+ aws-sdk-core (~> 3, >= 3.188.0)
aws-sigv4 (~> 1.1)
- aws-sdk-s3 (1.134.0)
- aws-sdk-core (~> 3, >= 3.181.0)
+ aws-sdk-s3 (1.140.0)
+ aws-sdk-core (~> 3, >= 3.188.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.6)
- aws-sigv4 (1.6.0)
+ aws-sigv4 (1.7.0)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
claide (1.1.0)
@@ -81,14 +81,13 @@ GEM
declarative (0.0.20)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
- domain_name (0.5.20190701)
- unf (>= 0.0.5, < 1.0.0)
+ domain_name (0.6.20231109)
dotenv (2.8.1)
emoji_regex (3.2.3)
escape (0.0.4)
ethon (0.16.0)
ffi (>= 1.15.0)
- excon (0.103.0)
+ excon (0.104.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
@@ -118,7 +117,7 @@ GEM
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.7)
- fastlane (2.215.1)
+ fastlane (2.217.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
@@ -166,9 +165,9 @@ GEM
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
- google-apis-androidpublisher_v3 (0.49.0)
+ google-apis-androidpublisher_v3 (0.53.0)
google-apis-core (>= 0.11.0, < 2.a)
- google-apis-core (0.11.1)
+ google-apis-core (0.11.2)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
@@ -181,23 +180,23 @@ GEM
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
- google-apis-storage_v1 (0.19.0)
- google-apis-core (>= 0.9.0, < 2.a)
+ google-apis-storage_v1 (0.29.0)
+ google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
- google-cloud-storage (1.44.0)
+ google-cloud-storage (1.45.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
- google-apis-storage_v1 (~> 0.19.0)
+ google-apis-storage_v1 (~> 0.29.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
- googleauth (1.8.0)
+ googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
@@ -229,7 +228,7 @@ GEM
os (1.1.4)
plist (3.7.0)
public_suffix (4.0.7)
- rake (13.0.6)
+ rake (13.1.0)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
@@ -262,13 +261,10 @@ GEM
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uber (0.1.0)
- unf (0.1.4)
- unf_ext
- unf_ext (0.0.8.2)
- unicode-display_width (2.4.2)
+ unicode-display_width (2.5.0)
webrick (1.8.1)
word_wrap (1.0.0)
- xcodeproj (1.22.0)
+ xcodeproj (1.23.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 242584ef04ad..f3c691a1d351 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -91,8 +91,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001040302
- versionName "1.4.3-2"
+ versionCode 1001040400
+ versionName "1.4.4-0"
}
flavorDimensions "default"
diff --git a/assets/images/empty-state_background-fade-dark.png b/assets/images/empty-state_background-fade-dark.png
new file mode 100644
index 000000000000..1caf5630bee3
Binary files /dev/null and b/assets/images/empty-state_background-fade-dark.png differ
diff --git a/assets/images/empty-state_background-fade-light.png b/assets/images/empty-state_background-fade-light.png
new file mode 100644
index 000000000000..98456609b502
Binary files /dev/null and b/assets/images/empty-state_background-fade-light.png differ
diff --git a/assets/images/empty-state_background-fade.png b/assets/images/empty-state_background-fade.png
deleted file mode 100644
index 816ff7343310..000000000000
Binary files a/assets/images/empty-state_background-fade.png and /dev/null differ
diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md b/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md
index 3ee1c8656b4b..9d17160d3a36 100644
--- a/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md
+++ b/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md
@@ -1,5 +1,308 @@
---
-title: Coming Soon
-description: Coming Soon
+title: QuickBooks Online
+description: Everything you need to know about using Expensify's direct integration with QuickBooks Online.
---
-## Resource Coming Soon!
+# Overview
+
+The Expensify integration with QuickBooks Online brings in your expense accounts and other data and even exports reports directly to QuickBooks for easy reconciliation. Plus, with advanced features in QuickBooks Online, you can fine-tune coding settings in Expensify for automated data export to optimize your accounting workflow.
+
+## Before connecting
+
+It's crucial to understand the requirements based on your specific QuickBooks subscription:
+
+- While all the features are available in Expensify, their accessibility may vary depending on your QuickBooks Online subscription.
+- An error will occur if you try to export to QuickBooks with a feature enabled that isn't part of your subscription.
+- Please be aware that Expensify does not support the Self-Employed subscription in QuickBooks Online.
+
+# How to connect to QuickBooks Online
+
+## Step 1: Setup employees in QuickBooks Online
+
+Employees must be set up as either Vendors or Employees in QuickBooks Online. Make sure to include the submitter's email in their record.
+
+If you use vendor records, you can export as Vendor Bills, Checks, or Journal Entries. If you use employee records, you can export as Checks or Journal Entries (if exporting against a liability account).
+
+Additional Options for Streamlined Setup:
+
+- Automatic Vendor Creation: Enable “Automatically Create Entities” in your connection settings to automatically generate Vendor or Employee records upon export for submitters that don't already exist in QBO.
+- Employee Setup Considerations: If setting up submitters as Employees, ensure you activate QuickBooks Online Payroll. This will grant access to the Employee Profile tab to input employee email addresses.
+
+## Step 2: Connect Expensify and QuickBooks Online
+
+- Navigate to Settings > Workspaces > Group > [Workspace Name] > Connections > QuickBooks Online. Click Connect to QuickBooks.
+- Enter your QuickBooks Online Administrator’s login information and choose the QuickBooks Online Company File you want to connect to Expensify (you can connect one Company File per Workspace). Then click Authorize.
+- Enter your QuickBooks Online Administrator’s login information and choose the QuickBooks Online Company File you want to connect to Expensify (you can connect one Company File per Workspace):
+
+## Exporting historical Reports to QuickBooks Online:
+
+After connecting QuickBooks Online to Expensify, you may receive a prompt to export all historical reports from Expensify. To export multiple reports at once, follow these steps:
+
+- Go to the Reports page on the web.
+- Tick the checkbox next to the reports you want to export.
+- Click 'Export To' and select 'QuickBooks Online' from the drop-down list.
+
+If you don't want to export specific reports, click “Mark as manually entered” on the report.
+
+# How to configure export settings for QuickBooks Online
+
+Our QuickBooks Online integration offers a range of features. This section will focus on Export Settings and how to set them up.
+
+## Preferred Exporter
+
+Any Workspace admin can export to your accounting integration, but the Preferred Exporter can be chosen to automate specific steps. You can set this role from Settings > Workspaces > Group > [Workspace Name] > Connections > Configure > Export > Preferred Exporter.
+
+The Preferred Exporter:
+
+- Is the user whose Concierge performs all automated exports on behalf of.
+- Is the only user who will see reports awaiting export in their **Home.**
+- Must be a **Domain Admin** if you have set individual GL accounts for Company Card export.
+- Must be a **Domain Admin** if this is the Preferred Workspace for any Expensify Card domain using Automatic Reconciliation.
+
+## Date
+
+When exporting reports to QuickBooks Online, you can choose the report's **submitted date**, the report's **exported date**, or the **date of the last expense on the report.**
+
+Most export options (Check, Journal Entry, and Vendor Bill) will create a single itemized entry with one date.
+Please note that if you choose a Credit Card or Debit Card for non-reimbursable expenses, we'll use the transaction date on each expense during export.
+
+# Reimbursable expenses
+
+Reimbursable expenses export to QuickBooks Online as:
+
+- Vendor Bills
+- Checks
+- Journal Entries
+
+## Vendor bill (recommended)
+
+This is a single itemized vendor bill for each Expensify report. If the accounting period is closed, we will post the vendor bill on the first day of the next open period. If you export as Vendor Bills, you can also choose to Sync reimbursed reports (set on the Advanced tab). **An A/P account is required to export to a vendor bill.**
+
+The submitter will be listed as the vendor in the vendor bill.
+
+## Check
+
+This is a single itemized check for each Expensify report. You can mark a check to be printed later in QuickBooks Online.
+
+## Journal entry
+
+This is a single itemized journal entry for each Expensify report.
+
+# Non-reimbursable expenses
+
+Non-reimbursable expenses export to QuickBooks Online as:
+
+- Credit Card expenses
+- Debit Card Expenses
+- Vendor Bills
+
+## Credit/debit card
+
+Using Credit/Debit Card Transactions:
+
+- Each expense will be exported as a bank transaction with its transaction date.
+- If you split an expense in Expensify, we'll consolidate it into a single credit card transaction in QuickBooks with multiple line items posted to the corresponding General Ledger accounts.
+
+Pro-Tip: To ensure the payee field in QuickBooks Online reflects the merchant name for Credit Card expenses, ensure there's a matching Vendor in QuickBooks Online. Expensify checks for an exact match during export. If none are found, the payee will be mapped to a vendor we create and labeled as Credit Card Misc. or Debit Card Misc.
+
+If you centrally manage your company cards through Domains, you can export expenses from each card to a specific account in QuickBooks.
+
+## Vendor Bill
+
+- A single detailed vendor bill is generated for each Expensify report. If the accounting period is closed, the vendor bill will be posted on the first day of the next open period. If you choose to export non-reimbursable expenses as Vendor Bills, you can assign a default vendor to the bill.
+- The export will use your default vendor if you have Default Vendor enabled. If the Default Vendor is disabled, the report's submitter will be set as the Vendor in QuickBooks.
+
+## Billable Expenses
+
+- In Expensify, you can designate expenses as billable. These will be exported to QuickBooks Online with the billable flag. This feature applies only to expenses exported as Vendor Bills or Checks. To maximize this functionality, ensure that any billable expense is associated with a Customer/Job.
+
+## Export Invoices
+
+If you are creating Invoices in Expensify and exporting these to QuickBooks Online, this is the account the invoice will appear against.
+
+# Configure coding for QuickBooks Online
+
+The coding tab is where your information is configured for Expensify; this will allow employees to code expenses and reports accurately.
+
+- Categories
+- Classes and/or Customers/Projects
+- Locations
+- Items
+- Tax
+
+## Categories
+
+QuickBooks Online expense accounts will be automatically imported into Expensify as Categories.
+
+## Account Import
+
+Equity type accounts will also be imported as categories.
+
+Important notes:
+
+- Other Current Liabilities can only be exported as Journal Entries if the submitter is set up as an Employee in QuickBooks.
+- Exchange Gain or Loss detail type does not import.
+
+Recommended steps to take after importing the expense accounts from QuickBooks to Expensify:
+
+- Go to Settings > Workspaces > Groups > [Workspace Name] > Categories to see the accounts imported from QuickBooks Online.
+- Use the enable/disable button to choose which Categories to make available to your employees, and set Category specific rules via the blue settings cog.
+- If necessary, edit the names of imported Categories to make expense coding easier for your employees. (Please Note: If you make any changes to these accounts in QuickBooks Online, the category names on Expensify's side will revert to match the name of the account in QuickBooks Online the next time you sync).
+- If you use Items in QuickBooks Online, you can import them into Expensify as Categories.
+
+Please note that each expense has to have a category selected to export to QuickBooks Online. The chosen category has to be imported from QuickBooks Online and cannot be manually created within the Workspace settings.
+
+## Classes and Customers/Projects
+
+If you use Classes or Customers/Projects in QuickBooks Online, you can import those into Expensify as Tags or Report Fields:
+
+- Tags let you apply a Class and/or Customer/Project to each expense.
+- Report Fields enables you to apply a Class and/or Customer/Project to all expenses on a report.
+
+Note: Although Projects can be imported into Expensify and coded to expenses, due to the limitations of the QuickBooks API, expenses cannot be created within the Projects module in QuickBooks.
+
+## Locations
+
+Locations can be imported into Expensify as a Report Field or, if you export reimbursable expenses as Journal Entries and non-reimbursable expenses as Credit/Debit Card, you can import Locations as Tags.
+
+## Items
+
+If you use Items in QuickBooks Online, you can import Items defined with Purchasing Information (with or without Sales Information) into Expensify as Categories.
+
+## Tax
+
+- Using our tax tracking feature, you can assign a tax rate and amount to each expense.
+- To activate tax tracking, go to connection configuration and enable it. This will automatically import purchasing taxes from QuickBooks Online into Expensify.
+- After the connection is set, navigate to Settings > Workspaces > Groups > [Workspace Name] > Tax. Here, you can view the taxes imported from QuickBooks Online.
+- Use the enable/disable button to choose which taxes are accessible to your employees.
+- Set a default tax for the Company Workspace, which will automatically apply to all new expenses.
+- Please note that, at present, tax cannot be exported to Journal Entries in QuickBooks Online.
+- Expensify performs a daily sync to ensure your information is up-to-date. This minimizes errors from outdated QuickBooks Online data and saves you time on syncing.
+
+# How to configure advanced settings for QuickBooks Online
+
+The advanced settings are where functionality for automating and customizing the QuickBooks Online integration can be enabled.
+Navigate to this section of your Workspace by following Settings > Workspaces > Group > [Workspace Name] > Connections > Configure button > Advanced tab.
+
+## Auto Sync
+With QuickBooks Online auto-sync, once a non-reimbursable report is final approved in Expensify, it's automatically queued for export to QuickBooks Online. For expenses eligible for reimbursement with a linked business bank account, they'll sync when marked as reimbursed.
+
+## Newly Imported Categories
+
+This setting determines the default status of newly imported categories from QuickBooks Online to Expensify, either enabled or disabled.
+
+## Invite Employees
+
+Enabling this automatically invites all Employees from QuickBooks Online to the connected Expensify Company Workspace. If not, you can manually invite or import them using a CSV file.
+
+## Automatically Create Entities
+
+When exporting reimbursable expenses as Vendor Bills or Journal Entries, Expensify will automatically create a vendor in QuickBooks if one doesn't exist. It will also generate a customer when exporting Invoices.
+
+## Sync Reimbursed Reports
+
+Enabling this marks the Vendor Bill as paid in QuickBooks Online when you reimburse a report via ACH direct deposit in Expensify. If reimbursing outside Expensify, marking the Vendor Bill as paid will automatically in QuickBooks Online update the report as reimbursed in Expensify. Note: After enabling this feature, select your QuickBooks Account in the drop-down, indicating the bank account for reimbursements.
+
+## Collection Account
+
+If you are exporting Invoices from Expensify to Quickbooks Online, this is the account the Invoice will appear against once marked as Paid.
+
+# Deep Dive
+
+## Preventing Duplicate Transactions in QuickBooks
+
+When importing a banking feed directly into QuickBooks Online while also importing transactions from Expensify, it's possible to encounter duplicate entries in QuickBooks. To prevent this, follow these steps:
+
+Step 1: Complete the Approval Process in Expensify
+
+- Before exporting any expenses to QuickBooks Online, ensure they are added to a report and the report receives approval. Depending on your Workspace setup, reports may require approval from one or more individuals. The approval process concludes when the last user who views the report selects "Final Approve."
+
+Step 2: Exporting Reports to QuickBooks Online
+
+- To ensure expenses exported from Expensify match seamlessly in the QuickBooks Banking platform, make sure these expenses are marked as non-reimbursable within Expensify and that “Credit Card” is selected as the non-reimbursable export option for your expenses.
+
+Step 3: Importing Your Credit Card Transactions into QuickBooks Online
+
+- After completing Steps 1 and 2, you can import your credit card transactions into QuickBooks Online. These imported banking transactions will align with the ones brought in from Expensify. QuickBooks Online will guide you through the process of matching these transactions, similar to the example below:
+
+## Tax in QuickBooks Online
+
+If your country applies taxes on sales (like GST, HST, or VAT), you can utilize Expensify's Tax Tracking along with your QuickBooks Online tax rates. Please note: Tax Tracking is not available for Workspaces linked to the US version of QuickBooks Online. If you need assistance applying taxes after reports are exported, contact QuickBooks.
+
+To get started:
+
+- Go to Settings > Workspaces > Group > [Workspace Name] > Connections, and click Configure.
+- Navigate to the Coding tab.
+- Turn on **Tax**.
+- Click Save. This imports the Tax Name and rate from QuickBooks Online.
+- Visit Settings > Workspaces > Group > [Workspace Name] > Tax to view the imported taxes.
+- Use the enable/disable button in the Tax tab to choose which taxes your employees can use.
+
+Remember, you can also set a default tax rate for the entire Workspace. This will be automatically applied to all new expenses. The user can still choose a different tax rate for each expense.
+
+Tax information can't be sent to Journal Entries in QuickBooks Online. Also, when dealing with multiple tax rates, where one receipt has different tax rates (like in the EU, UK, and Canada), users should split the expense into the respective parts and set the appropriate tax rate for each part.
+
+## Multi-currency
+
+When working with QuickBooks Online Multi-Currency, there are some things to remember when exporting Vendor Bills and Check! Make sure the vendor's currency and the Accounts Payable (A/P) bank account match.
+
+In QuickBooks Online, the currency conversion rates are not applied when exporting. All transactions will be exported with a 1:1 conversion rate, so for example, if a vendor's currency is CAD (Canadian Dollar) and the home currency is USD (US Dollar), the export will show these currencies without applying conversion rates.
+
+To correct this, you must manually update the conversion rate after the report has been exported to QuickBooks Online.
+
+Specifically for Vendor Bills:
+
+If multi-currency is enabled and the Vendor's currency is different from the Workspace currency, OR if QuickBooks Online home currency is foreign from the Workspace currency, then:
+
+- We create the Vendor Bill in the Vendor's currency (this is a QuickBooks Online requirement - we don't have a choice)
+- We set the exchange rate between the home currency and the Vendor's currency
+- We convert line item amounts to the vendor's currency
+
+Let's consider this example:
+
+- QuickBooks Online home currency is USD
+- Vendor's currency is VND
+- Workspace (report) currency is JPY
+
+Upon export, we:
+
+1. Specified the bill is in VND
+2. Set the exchange rate between VND and USD (home currency), computed at the time of export.
+3. Converted line items from JPY (currency in Expensify) to VND
+4. QuickBooks Online automatically computed the USD amount (home currency) based on the exchange rate we specified
+5. Journal Entries, Credit Card, and Debit Card:
+
+Multi-currency exports will fail as the account currency must match both the vendor and home currencies.
+
+## Report Fields
+
+Report fields are a handy way to collect specific information for a report tailored to your organization's needs. They can specify a project, business trip, client, location, and more!
+
+When integrating Expensify with Your Accounting Software, you can create your report fields in your accounting software so the next time you sync your Workspace, these fields will be imported into Expensify.
+
+To select how a specific field imports to Expensify, head to Settings > Workspaces > Group >
+[Workspace Name] > Connections > Accounting Integrations > QuickBooks Online > Configure > Coding.
+
+Here are the QuickBooks Online fields that can be mapped as a report field within Expensify:
+
+- Classes
+- Customers/Projects
+- Locations
+
+# FAQ
+
+## What happens if the report can't be exported to QuickBooks Online automatically?
+
+If a report encounters an issue during automatic export to QuickBooks Online, you'll receive an email with details about the problem, including any specific error messages. These messages will also be recorded in the report's history section.
+
+The report will be placed in your Home for your attention. You can address the issues there. If you need further assistance, refer to our QuickBooks Online Export Errors page or export the report manually.
+
+## How can I ensure that I final approve reports before they're exported to QuickBooks Online?
+
+To ensure reports are reviewed before export, set up your Workspaces with the appropriate workflow in Expensify. Additionally, consider changing your Workspace settings to enforce expense Workspace workflows strictly. This guarantees that your Workspace's workflow is consistently followed.
+
+## What happens to existing approved and reimbursed reports if I enable Auto Sync?
+
+- If Auto Sync was disabled when your Workspace was linked to QuickBooks Online, enabling it won't impact existing reports that haven't been exported.
+- If a report has been exported and reimbursed via ACH, it will be automatically marked as paid in QuickBooks Online during the next sync.
+- If a report has been exported and marked as paid in QuickBooks Online, it will be automatically marked as reimbursed in Expensify during the next sync.
+- Reports that have yet to be exported to QuickBooks Online won't be automatically exported.
diff --git a/docs/articles/expensify-classic/send-payments/Pay-Bills.md b/docs/articles/expensify-classic/send-payments/Pay-Bills.md
index 41c0146126ba..8a5c7c5c7f88 100644
--- a/docs/articles/expensify-classic/send-payments/Pay-Bills.md
+++ b/docs/articles/expensify-classic/send-payments/Pay-Bills.md
@@ -1,5 +1,110 @@
---
title: Pay Bills
-description: Pay Bills
+description: How to receive and pay company bills in Expensify
---
-## Resource Coming Soon!
+
+
+# Overview
+Simplify your back office by receiving bills from vendors and suppliers in Expensify. Anyone with or without an Expensify account can send you a bill, and Expensify will file it as a Bill and help you issue the payment.
+
+# How to Receive Vendor or Supplier Bills in Expensify
+
+There are three ways to get a vendor or supplier bill into Expensify:
+
+**Option 1:** Have vendors send bills to Expensify directly: Ask your vendors to email all bills to your Expensify billing intake email.
+
+**Option 2:** Forward bills to Expensify: If your bills are emailed to you, you can forward those bills to your Expensify billing intake email yourself.
+
+**Option 3:** Manually upload bills to Expensify: If you receive physical bills, you can manually create a Bill in Expensify on the web from the Reports page:
+1. Click **New Report** and choose **Bill**
+2. Add the expense details and vendor's email address to the pop-up window
+3. Upload a pdf/image of the bill
+4. Click **Submit**
+
+# How to Pay Bills
+
+There are multiple ways to pay Bills in Expensify. Let’s go over each method below:
+
+## ACH bank-to-bank transfer
+
+To use this payment method, you must have a business bank account connected to your Expensify account.
+
+To pay with an ACH bank-to-bank transfer:
+
+1. Sign in to your Expensify account on the web at www.expensify.com.
+2. Go to the Inbox or Reports page and locate the Bill that needs to be paid.
+3. Click the **Pay** button to be redirected to the Bill.
+4. Choose the ACH option from the drop-down list.
+5. Follow the prompts to connect your business bank account to Expensify.
+
+**Fees:** None
+
+## Pay using a credit or debit card
+
+This option is available to all US and International customers receiving an bill from a US vendor with a US business bank account.
+
+To pay with a credit or debit card:
+1. Sign-in to your Expensify account on the web app at www.expensify.com.
+2, Click on the Bill you’d like to pay to see the details.
+3, Click the **Pay** button.
+4. You’ll be prompted to enter your credit card or debit card details.
+
+**Fees:** Includes 2.9% credit card payment fee
+
+## Venmo
+
+If both you and the vendor have Venmo setup in their Expensify account, you can opt to pay the bill through Venmo.
+
+**Fees:** Venmo charges a 3% sender’s fee
+
+## Pay Outside of Expensify
+
+If you are not able to pay using one of the above methods, then you can mark the Bill as paid manually in Expensify to update its status and indicate that you have made payment outside Expensify.
+
+To mark a Bill as paid outside of Expensify:
+
+1. Sign-in to your Expensify account on the web app at www.expensify.com.
+2. Click on the Bill you’d like to pay to see the details.
+3. Click on the **Reimburse** button.
+4. Choose **I’ll do it manually**
+
+**Fees:** None
+
+# FAQ
+
+## What is my company's billing intake email?
+Your billing intake email is [yourdomain.com]@expensify.cash. Example, if your domain is `company.io` your billing email is `company.io@expensify.cash`.
+
+## When a vendor or supplier bill is sent to Expensify, who receives it?
+
+Bills are received by the Primary Contact for the domain. This is the email address listed at **Settings > Domains > Domain Admins**.
+
+## Who can view a Bill in Expensify?
+
+Only the primary contact of the domain can view a Bill.
+
+## Who can pay a Bill?
+
+Only the primary domain contact (owner of the bill) will be able to pay the Mill.
+
+## How can you share access to Bills?
+
+To give others the ability to view a Bill, the primary contact can manually “share” the Bill under the Details section of the report via the Sharing Options button.
+To give someone else the ability to pay Bills, the primary domain contact will need to grant those individuals Copilot access to the primary domain contact's account.
+
+## Is Bill Pay supported internationally?
+
+Payments are currently only supported for users paying in United States Dollars (USD).
+
+## What’s the difference between a Bill and an Invoice in Expensify?
+
+A Bill is a payable which represents an amount owed to a payee (usually a vendor or supplier), and is usually created from a vendor invoice. An Invoice is a receivable, and indicates an amount owed to you by someone else.
+
+# Deep Dive: How company bills and vendor invoices are processed in Expensify
+
+Here is how a vendor or supplier bill goes from received to paid in Expensify:
+
+1. When a vendor or supplier bill is received in Expensify via, the document is SmartScanned automatically and a Bill is created. The Bill is owned by the primary domain contact, who will see the Bill on the Reports page on their default group policy.
+2. When the Bill is ready for processing, it is submitted and follows the primary domain contact’s approval workflow. Each time the Bill is approved, it is visible in the next approver's Inbox.
+3. The final approver pays the Bill from their Expensify account on the web via one of the methods.
+4. The Bill is coded with the relevant imported GL codes from a connected accounting software. After it has finished going through the approval workflow the Bill can be exported back to the accounting package connected to the policy.
diff --git a/docs/assets/images/Cancel Reimbursement.png b/docs/assets/images/Cancel Reimbursement.png
new file mode 100644
index 000000000000..a1322202ded3
Binary files /dev/null and b/docs/assets/images/Cancel Reimbursement.png differ
diff --git a/docs/assets/images/CompanyCards_Assign.png b/docs/assets/images/CompanyCards_Assign.png
new file mode 100644
index 000000000000..53effeb56b88
Binary files /dev/null and b/docs/assets/images/CompanyCards_Assign.png differ
diff --git a/docs/assets/images/CompanyCards_EmailAssign.png b/docs/assets/images/CompanyCards_EmailAssign.png
new file mode 100644
index 000000000000..a3d9683518a7
Binary files /dev/null and b/docs/assets/images/CompanyCards_EmailAssign.png differ
diff --git a/docs/assets/images/CompanyCards_Unassign.png b/docs/assets/images/CompanyCards_Unassign.png
new file mode 100644
index 000000000000..14a2fdc205a7
Binary files /dev/null and b/docs/assets/images/CompanyCards_Unassign.png differ
diff --git a/docs/assets/images/Reimbursing Default.png b/docs/assets/images/Reimbursing Default.png
new file mode 100644
index 000000000000..23ffd557ca14
Binary files /dev/null and b/docs/assets/images/Reimbursing Default.png differ
diff --git a/docs/assets/images/Reimbursing Manual Warning.png b/docs/assets/images/Reimbursing Manual Warning.png
new file mode 100644
index 000000000000..2579e21fe2e3
Binary files /dev/null and b/docs/assets/images/Reimbursing Manual Warning.png differ
diff --git a/docs/assets/images/Reimbursing Manual.png b/docs/assets/images/Reimbursing Manual.png
new file mode 100644
index 000000000000..3b9eb27bfa0b
Binary files /dev/null and b/docs/assets/images/Reimbursing Manual.png differ
diff --git a/docs/assets/images/Reimbursing Reports Dropdown.png b/docs/assets/images/Reimbursing Reports Dropdown.png
new file mode 100644
index 000000000000..2e9c6329ae19
Binary files /dev/null and b/docs/assets/images/Reimbursing Reports Dropdown.png differ
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 26e97aceb8aa..aa7aeccb9780 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 1.4.3
+ 1.4.4CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.3.2
+ 1.4.4.0ITSAppUsesNonExemptEncryptionLSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 854f911a582b..5911bd714ade 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 1.4.3
+ 1.4.4CFBundleSignature????CFBundleVersion
- 1.4.3.2
+ 1.4.4.0
diff --git a/package-lock.json b/package-lock.json
index ac172bdef99b..1712b8110a61 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.3-2",
+ "version": "1.4.4-0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.3-2",
+ "version": "1.4.4-0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index 042ab7ca6e83..a674c6f840c3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.3-2",
+ "version": "1.4.4-0",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
diff --git a/src/App.js b/src/App.js
index 698dfe4437b2..ac34ece5c6c7 100644
--- a/src/App.js
+++ b/src/App.js
@@ -24,6 +24,7 @@ import OnyxUpdateManager from './libs/actions/OnyxUpdateManager';
import * as Session from './libs/actions/Session';
import * as Environment from './libs/Environment/Environment';
import {ReportAttachmentsProvider} from './pages/home/report/ReportAttachmentsContext';
+import ThemeIllustrationsProvider from './styles/illustrations/ThemeIllustrationsProvider';
import ThemeProvider from './styles/themes/ThemeProvider';
import ThemeStylesProvider from './styles/ThemeStylesProvider';
@@ -64,6 +65,7 @@ function App() {
EnvironmentProvider,
ThemeProvider,
ThemeStylesProvider,
+ ThemeIllustrationsProvider,
]}
>
diff --git a/src/CONST.ts b/src/CONST.ts
index f1364ebbb5bf..9b284752d074 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -2813,6 +2813,7 @@ const CONST = {
START_CHAT: 'startChat',
SEND_MONEY: 'sendMoney',
REFER_FRIEND: 'referralFriend',
+ SHARE_CODE: 'shareCode',
},
REVENUE: 250,
LEARN_MORE_LINK: 'https://help.expensify.com/articles/new-expensify/billing-and-plan-types/Referral-Program',
@@ -2829,12 +2830,26 @@ const CONST = {
BACK_BUTTON_NATIVE_ID: 'backButton',
+ /**
+ * The maximum count of items per page for OptionsSelector.
+ * When paginate, it multiplies by page number.
+ */
+ MAX_OPTIONS_SELECTOR_PAGE_LENGTH: 500,
+
/**
* Performance test setup - run the same test multiple times to get a more accurate result
*/
PERFORMANCE_TESTS: {
RUNS: 20,
},
+
+ /**
+ * Constants for maxToRenderPerBatch parameter that is used for FlatList or SectionList. This controls the amount of items rendered per batch, which is the next chunk of items rendered on every scroll.
+ */
+ MAX_TO_RENDER_PER_BATCH: {
+ DEFAULT: 5,
+ CAROUSEL: 3,
+ },
} as const;
export default CONST;
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 75c284fb9546..5576eb64736d 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -84,6 +84,9 @@ const ONYXKEYS = {
/** Contains all the users settings for the Settings page and sub pages */
USER: 'user',
+ /** Contains latitude and longitude of user's last known location */
+ USER_LOCATION: 'userLocation',
+
/** Contains metadata (partner, login, validation date) for all of the user's logins */
LOGIN_LIST: 'loginList',
@@ -246,6 +249,7 @@ const ONYXKEYS = {
POLICY_TAGS: 'policyTags_',
POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_',
WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_',
+ WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_',
REPORT: 'report_',
// REPORT_METADATA is a perf optimization used to hold loading states (isLoadingInitialReportActions, isLoadingOlderReportActions, isLoadingNewerReportActions).
// A lot of components are connected to the Report entity and do not care about the actions. Setting the loading state
@@ -371,6 +375,7 @@ type OnyxValues = {
[ONYXKEYS.COUNTRY_CODE]: number;
[ONYXKEYS.COUNTRY]: string;
[ONYXKEYS.USER]: OnyxTypes.User;
+ [ONYXKEYS.USER_LOCATION]: OnyxTypes.UserLocation;
[ONYXKEYS.LOGIN_LIST]: Record;
[ONYXKEYS.SESSION]: OnyxTypes.Session;
[ONYXKEYS.BETAS]: OnyxTypes.Beta[];
diff --git a/src/components/AddPaymentMethodMenu.js b/src/components/AddPaymentMethodMenu.js
index ce26985932d6..4d01fa108e2a 100644
--- a/src/components/AddPaymentMethodMenu.js
+++ b/src/components/AddPaymentMethodMenu.js
@@ -64,6 +64,12 @@ const defaultProps = {
function AddPaymentMethodMenu({isVisible, onClose, anchorPosition, anchorAlignment, anchorRef, iouReport, onItemSelected, session}) {
const {translate} = useLocalize();
+ // Users can choose to pay with business bank account in case of Expense reports or in case of P2P IOU report
+ // which then starts a bottom up flow and creates a Collect workspace where the payer is an admin and payee is an employee.
+ const canUseBusinessBankAccount =
+ ReportUtils.isExpenseReport(iouReport) ||
+ (ReportUtils.isIOUReport(iouReport) && !ReportActionsUtils.hasRequestFromCurrentAccount(lodashGet(iouReport, 'reportID', 0), lodashGet(session, 'accountID', 0)));
+
return (
({
- language: props.preferredLocale,
- types: props.resultTypes,
- components: props.isLimitedToUSA ? 'country:us' : undefined,
+ language: preferredLocale,
+ types: resultTypes,
+ components: isLimitedToUSA ? 'country:us' : undefined,
}),
- [props.preferredLocale, props.resultTypes, props.isLimitedToUSA],
+ [preferredLocale, resultTypes, isLimitedToUSA],
);
- const shouldShowCurrentLocationButton = props.canUseCurrentLocation && searchValue.trim().length === 0 && isFocused;
+ const shouldShowCurrentLocationButton = canUseCurrentLocation && searchValue.trim().length === 0 && isFocused;
const saveLocationDetails = (autocompleteData, details) => {
const addressComponents = details.address_components;
@@ -171,7 +190,7 @@ function AddressSearch(props) {
// to this component which don't match the usual properties coming from auto-complete. In that case, only a limited
// amount of data massaging needs to happen for what the parent expects to get from this function.
if (_.size(details)) {
- props.onPress({
+ onPress({
address: lodashGet(details, 'description'),
lat: lodashGet(details, 'geometry.location.lat', 0),
lng: lodashGet(details, 'geometry.location.lng', 0),
@@ -269,7 +288,7 @@ function AddressSearch(props) {
// Not all pages define the Address Line 2 field, so in that case we append any additional address details
// (e.g. Apt #) to Address Line 1
- if (subpremise && typeof props.renamedInputKeys.street2 === 'undefined') {
+ if (subpremise && typeof renamedInputKeys.street2 === 'undefined') {
values.street += `, ${subpremise}`;
}
@@ -278,19 +297,19 @@ function AddressSearch(props) {
values.country = country;
}
- if (props.inputID) {
- _.each(values, (value, key) => {
- const inputKey = lodashGet(props.renamedInputKeys, key, key);
+ if (inputID) {
+ _.each(values, (inputValue, key) => {
+ const inputKey = lodashGet(renamedInputKeys, key, key);
if (!inputKey) {
return;
}
- props.onInputChange(value, inputKey);
+ onInputChange(inputValue, inputKey);
});
} else {
- props.onInputChange(values);
+ onInputChange(values);
}
- props.onPress(values);
+ onPress(values);
};
/** Gets the user's current location and registers success/error callbacks */
@@ -320,7 +339,7 @@ function AddressSearch(props) {
lng: successData.coords.longitude,
address: CONST.YOUR_LOCATION_TEXT,
};
- props.onPress(location);
+ onPress(location);
},
(errorData) => {
if (!shouldTriggerGeolocationCallbacks.current) {
@@ -338,16 +357,16 @@ function AddressSearch(props) {
};
const renderHeaderComponent = () =>
- props.predefinedPlaces.length > 0 && (
+ predefinedPlaces.length > 0 && (
<>
{/* This will show current location button in list if there are some recent destinations */}
{shouldShowCurrentLocationButton && (
)}
- {!props.value && {props.translate('common.recentDestinations')}}
+ {!value && {translate('common.recentDestinations')}}
>
);
@@ -359,6 +378,26 @@ function AddressSearch(props) {
};
}, []);
+ const listEmptyComponent = useCallback(
+ () =>
+ network.isOffline || !isTyping ? null : (
+ {translate('common.noResultsFound')}
+ ),
+ [network.isOffline, isTyping, styles, translate],
+ );
+
+ const listLoader = useCallback(
+ () => (
+
+
+
+ ),
+ [styles.pv4, theme.spinner],
+ );
+
return (
/*
* The GooglePlacesAutocomplete component uses a VirtualizedList internally,
@@ -385,20 +424,10 @@ function AddressSearch(props) {
fetchDetails
suppressDefaultStyles
enablePoweredByContainer={false}
- predefinedPlaces={props.predefinedPlaces}
- listEmptyComponent={
- props.network.isOffline || !isTyping ? null : (
- {props.translate('common.noResultsFound')}
- )
- }
- listLoaderComponent={
-
-
-
- }
+ predefinedPlaces={predefinedPlaces}
+ listEmptyComponent={listEmptyComponent}
+ listLoaderComponent={listLoader}
+ renderHeaderComponent={renderHeaderComponent}
renderRow={(data) => {
const title = data.isPredefinedPlace ? data.name : data.structured_formatting.main_text;
const subtitle = data.isPredefinedPlace ? data.description : data.structured_formatting.secondary_text;
@@ -409,7 +438,6 @@ function AddressSearch(props) {
);
}}
- renderHeaderComponent={renderHeaderComponent}
onPress={(data, details) => {
saveLocationDetails(data, details);
setIsTyping(false);
@@ -424,34 +452,31 @@ function AddressSearch(props) {
query={query}
requestUrl={{
useOnPlatform: 'all',
- url: props.network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}),
+ url: network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}),
}}
textInputProps={{
InputComp: TextInput,
ref: (node) => {
- if (!props.innerRef) {
+ if (!innerRef) {
return;
}
- if (_.isFunction(props.innerRef)) {
- props.innerRef(node);
+ if (_.isFunction(innerRef)) {
+ innerRef(node);
return;
}
// eslint-disable-next-line no-param-reassign
- props.innerRef.current = node;
+ 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,
+ label,
+ containerStyles,
+ errorText,
+ hint: displayListViewBorder || (predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (canUseCurrentLocation && isTyping) ? undefined : hint,
+ value,
+ defaultValue,
+ inputID,
+ shouldSaveDraft,
onFocus: () => {
setIsFocused(true);
},
@@ -461,24 +486,24 @@ function AddressSearch(props) {
setIsFocused(false);
setIsTyping(false);
}
- props.onBlur();
+ onBlur();
},
autoComplete: 'off',
onInputChange: (text) => {
setSearchValue(text);
setIsTyping(true);
- if (props.inputID) {
- props.onInputChange(text);
+ if (inputID) {
+ onInputChange(text);
} else {
- props.onInputChange({street: text});
+ 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)) {
+ if (_.isEmpty(text) && _.isEmpty(predefinedPlaces)) {
setDisplayListViewBorder(false);
}
},
- maxLength: props.maxInputLength,
+ maxLength: maxInputLength,
spellCheck: false,
selectTextOnFocus: true,
}}
@@ -500,17 +525,18 @@ function AddressSearch(props) {
}}
inbetweenCompo={
// We want to show the current location button even if there are no recent destinations
- props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? (
+ predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? (
) : (
<>>
)
}
+ placeholder=""
/>
setLocationErrorCode(null)}
diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js
index 4ab81ae462c9..d346f271b36d 100755
--- a/src/components/AttachmentModal.js
+++ b/src/components/AttachmentModal.js
@@ -128,6 +128,8 @@ function AttachmentModal(props) {
const [isDownloadButtonReadyToBeShown, setIsDownloadButtonReadyToBeShown] = React.useState(true);
const {windowWidth} = useWindowDimensions();
+ const isOverlayModalVisible = (isAttachmentReceipt && isDeleteReceiptConfirmModalVisible) || (!isAttachmentReceipt && isAttachmentInvalid);
+
const [file, setFile] = useState(
props.originalFileName
? {
@@ -406,7 +408,7 @@ function AttachmentModal(props) {
{
diff --git a/src/components/Attachments/AttachmentCarousel/index.js b/src/components/Attachments/AttachmentCarousel/index.js
index fa4ff50512d0..141e619e489e 100644
--- a/src/components/Attachments/AttachmentCarousel/index.js
+++ b/src/components/Attachments/AttachmentCarousel/index.js
@@ -12,6 +12,7 @@ import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import Navigation from '@libs/Navigation/Navigation';
import useThemeStyles from '@styles/useThemeStyles';
import variables from '@styles/variables';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import AttachmentCarouselCellRenderer from './AttachmentCarouselCellRenderer';
import {defaultProps, propTypes} from './attachmentCarouselPropTypes';
@@ -203,7 +204,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
initialScrollIndex={page}
initialNumToRender={3}
windowSize={5}
- maxToRenderPerBatch={3}
+ maxToRenderPerBatch={CONST.MAX_TO_RENDER_PER_BATCH.CAROUSEL}
data={attachments}
CellRendererComponent={AttachmentCarouselCellRenderer}
renderItem={renderItem}
diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx
similarity index 77%
rename from src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js
rename to src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx
index ec53507d4d8e..3e5e7b4fdd9a 100644
--- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js
+++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx
@@ -1,5 +1,6 @@
import {FlashList} from '@shopify/flash-list';
-import React, {useCallback, useEffect, useRef} from 'react';
+import React, {ForwardedRef, forwardRef, ReactElement, useCallback, useEffect, useRef} from 'react';
+import {View} from 'react-native';
// We take ScrollView from this package to properly handle the scrolling of AutoCompleteSuggestions in chats since one scroll is nested inside another
import {ScrollView} from 'react-native-gesture-handler';
import Animated, {Easing, FadeOutDown, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
@@ -7,14 +8,10 @@ import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import * as StyleUtils from '@styles/StyleUtils';
import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
-import {propTypes} from './autoCompleteSuggestionsPropTypes';
+import viewForwardedRef from '@src/types/utils/viewForwardedRef';
+import type {AutoCompleteSuggestionsProps, RenderSuggestionMenuItemProps} from './types';
-/**
- * @param {Number} numRows
- * @param {Boolean} isSuggestionPickerLarge
- * @returns {Number}
- */
-const measureHeightOfSuggestionRows = (numRows, isSuggestionPickerLarge) => {
+const measureHeightOfSuggestionRows = (numRows: number, isSuggestionPickerLarge: boolean): number => {
if (isSuggestionPickerLarge) {
if (numRows > CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER) {
// On large screens, if there are more than 5 suggestions, we display a scrollable window with a height of 5 items, indicating that there are more items available
@@ -29,28 +26,26 @@ const measureHeightOfSuggestionRows = (numRows, isSuggestionPickerLarge) => {
return numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT;
};
-function BaseAutoCompleteSuggestions({
- highlightedSuggestionIndex,
- onSelect,
- renderSuggestionMenuItem,
- suggestions,
- accessibilityLabelExtractor,
- keyExtractor,
- isSuggestionPickerLarge,
- forwardedRef,
-}) {
+function BaseAutoCompleteSuggestions(
+ {
+ highlightedSuggestionIndex,
+ onSelect,
+ accessibilityLabelExtractor,
+ renderSuggestionMenuItem,
+ suggestions,
+ isSuggestionPickerLarge,
+ keyExtractor,
+ }: AutoCompleteSuggestionsProps,
+ ref: ForwardedRef,
+) {
const styles = useThemeStyles();
const rowHeight = useSharedValue(0);
- const scrollRef = useRef(null);
+ const scrollRef = useRef>(null);
/**
* Render a suggestion menu item component.
- * @param {Object} params
- * @param {Object} params.item
- * @param {Number} params.index
- * @returns {JSX.Element}
*/
const renderItem = useCallback(
- ({item, index}) => (
+ ({item, index}: RenderSuggestionMenuItemProps): ReactElement => (
StyleUtils.getAutoCompleteSuggestionItemStyle(highlightedSuggestionIndex, CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, hovered, index)}
hoverDimmingValue={1}
@@ -84,7 +79,7 @@ function BaseAutoCompleteSuggestions({
return (
@@ -104,17 +99,6 @@ function BaseAutoCompleteSuggestions({
);
}
-BaseAutoCompleteSuggestions.propTypes = propTypes;
BaseAutoCompleteSuggestions.displayName = 'BaseAutoCompleteSuggestions';
-const BaseAutoCompleteSuggestionsWithRef = React.forwardRef((props, ref) => (
-
-));
-
-BaseAutoCompleteSuggestionsWithRef.displayName = 'BaseAutoCompleteSuggestionsWithRef';
-
-export default BaseAutoCompleteSuggestionsWithRef;
+export default forwardRef(BaseAutoCompleteSuggestions);
diff --git a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js
deleted file mode 100644
index 8c6dca1902c5..000000000000
--- a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import PropTypes from 'prop-types';
-
-const propTypes = {
- /** Array of suggestions */
- // eslint-disable-next-line react/forbid-prop-types
- suggestions: PropTypes.arrayOf(PropTypes.object).isRequired,
-
- /** Function used to render each suggestion, returned JSX will be enclosed inside a Pressable component */
- renderSuggestionMenuItem: PropTypes.func.isRequired,
-
- /** Create unique keys for each suggestion item */
- keyExtractor: PropTypes.func.isRequired,
-
- /** The index of the highlighted suggestion */
- highlightedSuggestionIndex: PropTypes.number.isRequired,
-
- /** Fired when the user selects a suggestion */
- onSelect: PropTypes.func.isRequired,
-
- /** Show that we can use large auto-complete suggestion picker.
- * Depending on available space and whether the input is expanded, we can have a small or large mention suggester.
- * When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */
- isSuggestionPickerLarge: PropTypes.bool.isRequired,
-
- /** create accessibility label for each item */
- accessibilityLabelExtractor: PropTypes.func.isRequired,
-
- /** Meaures the parent container's position and dimensions. */
- measureParentContainer: PropTypes.func,
-};
-
-const defaultProps = {
- measureParentContainer: () => {},
-};
-
-export {propTypes, defaultProps};
diff --git a/src/components/AutoCompleteSuggestions/index.native.js b/src/components/AutoCompleteSuggestions/index.native.tsx
similarity index 61%
rename from src/components/AutoCompleteSuggestions/index.native.js
rename to src/components/AutoCompleteSuggestions/index.native.tsx
index 439fa45eae78..fbfa7d953581 100644
--- a/src/components/AutoCompleteSuggestions/index.native.js
+++ b/src/components/AutoCompleteSuggestions/index.native.tsx
@@ -1,18 +1,17 @@
import {Portal} from '@gorhom/portal';
import React from 'react';
-import {propTypes} from './autoCompleteSuggestionsPropTypes';
import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions';
+import type {AutoCompleteSuggestionsProps} from './types';
-function AutoCompleteSuggestions({measureParentContainer, ...props}) {
+function AutoCompleteSuggestions({measureParentContainer, ...props}: AutoCompleteSuggestionsProps) {
return (
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
-
+ {...props} />
);
}
-AutoCompleteSuggestions.propTypes = propTypes;
AutoCompleteSuggestions.displayName = 'AutoCompleteSuggestions';
export default AutoCompleteSuggestions;
diff --git a/src/components/AutoCompleteSuggestions/index.js b/src/components/AutoCompleteSuggestions/index.tsx
similarity index 76%
rename from src/components/AutoCompleteSuggestions/index.js
rename to src/components/AutoCompleteSuggestions/index.tsx
index 30654caf5708..24b846c265a9 100644
--- a/src/components/AutoCompleteSuggestions/index.js
+++ b/src/components/AutoCompleteSuggestions/index.tsx
@@ -4,8 +4,8 @@ import {View} from 'react-native';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import * as StyleUtils from '@styles/StyleUtils';
-import {propTypes} from './autoCompleteSuggestionsPropTypes';
import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions';
+import type {AutoCompleteSuggestionsProps} from './types';
/**
* On the mobile-web platform, when long-pressing on auto-complete suggestions,
@@ -14,8 +14,8 @@ import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions';
* On the native platform, tapping on auto-complete suggestions will not blur the main input.
*/
-function AutoCompleteSuggestions({measureParentContainer, ...props}) {
- const containerRef = React.useRef(null);
+function AutoCompleteSuggestions({measureParentContainer = () => {}, ...props}: AutoCompleteSuggestionsProps) {
+ const containerRef = React.useRef(null);
const {windowHeight, windowWidth} = useWindowDimensions();
const [{width, left, bottom}, setContainerState] = React.useState({
width: 0,
@@ -25,7 +25,7 @@ function AutoCompleteSuggestions({measureParentContainer, ...props}) {
React.useEffect(() => {
const container = containerRef.current;
if (!container) {
- return;
+ return () => {};
}
container.onpointerdown = (e) => {
if (DeviceCapabilities.hasHoverSupport()) {
@@ -44,20 +44,20 @@ function AutoCompleteSuggestions({measureParentContainer, ...props}) {
}, [measureParentContainer, windowHeight, windowWidth]);
const componentToRender = (
-
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
ref={containerRef}
/>
);
+ const bodyElement = document.querySelector('body');
+
return (
- Boolean(width) &&
- ReactDOM.createPortal({componentToRender}, document.querySelector('body'))
+ !!width && bodyElement && ReactDOM.createPortal({componentToRender}, bodyElement)
);
}
-AutoCompleteSuggestions.propTypes = propTypes;
AutoCompleteSuggestions.displayName = 'AutoCompleteSuggestions';
export default AutoCompleteSuggestions;
diff --git a/src/components/AutoCompleteSuggestions/types.ts b/src/components/AutoCompleteSuggestions/types.ts
new file mode 100644
index 000000000000..9130f5139d71
--- /dev/null
+++ b/src/components/AutoCompleteSuggestions/types.ts
@@ -0,0 +1,38 @@
+import {ReactElement} from 'react';
+
+type MeasureParentContainerCallback = (x: number, y: number, width: number) => void;
+
+type RenderSuggestionMenuItemProps = {
+ item: TSuggestion;
+ index: number;
+};
+
+type AutoCompleteSuggestionsProps = {
+ /** Array of suggestions */
+ suggestions: TSuggestion[];
+
+ /** Function used to render each suggestion, returned JSX will be enclosed inside a Pressable component */
+ renderSuggestionMenuItem: (item: TSuggestion, index: number) => ReactElement;
+
+ /** Create unique keys for each suggestion item */
+ keyExtractor: (item: TSuggestion, index: number) => string;
+
+ /** The index of the highlighted suggestion */
+ highlightedSuggestionIndex: number;
+
+ /** Fired when the user selects a suggestion */
+ onSelect: (index: number) => void;
+
+ /** Show that we can use large auto-complete suggestion picker.
+ * Depending on available space and whether the input is expanded, we can have a small or large mention suggester.
+ * When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */
+ isSuggestionPickerLarge: boolean;
+
+ /** create accessibility label for each item */
+ accessibilityLabelExtractor: (item: TSuggestion, index: number) => string;
+
+ /** Meaures the parent container's position and dimensions. */
+ measureParentContainer?: (callback: MeasureParentContainerCallback) => void;
+};
+
+export type {AutoCompleteSuggestionsProps, RenderSuggestionMenuItemProps};
diff --git a/src/components/Button/index.js b/src/components/Button/index.tsx
similarity index 64%
rename from src/components/Button/index.js
rename to src/components/Button/index.tsx
index b9aaf8868924..71bce9777174 100644
--- a/src/components/Button/index.js
+++ b/src/components/Button/index.tsx
@@ -1,212 +1,167 @@
import {useIsFocused} from '@react-navigation/native';
-import PropTypes from 'prop-types';
-import React, {useCallback} from 'react';
-import {ActivityIndicator, View} from 'react-native';
+import React, {ForwardedRef, useCallback} from 'react';
+import {ActivityIndicator, GestureResponderEvent, StyleProp, TextStyle, View, ViewStyle} from 'react-native';
+import {SvgProps} from 'react-native-svg';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
-import refPropTypes from '@components/refPropTypes';
import Text from '@components/Text';
import withNavigationFallback from '@components/withNavigationFallback';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import HapticFeedback from '@libs/HapticFeedback';
-import * as StyleUtils from '@styles/StyleUtils';
+import themeColors from '@styles/themes/default';
import useTheme from '@styles/themes/useTheme';
import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
+import ChildrenProps from '@src/types/utils/ChildrenProps';
import validateSubmitShortcut from './validateSubmitShortcut';
-const propTypes = {
- /** Should the press event bubble across multiple instances when Enter key triggers it. */
- allowBubble: PropTypes.bool,
-
+type ButtonWithText = {
/** The text for the button label */
- text: PropTypes.string,
+ text: string;
/** Boolean whether to display the right icon */
- shouldShowRightIcon: PropTypes.bool,
+ shouldShowRightIcon?: boolean;
/** The icon asset to display to the left of the text */
- icon: PropTypes.func,
+ icon?: React.FC | null;
+};
+
+type ButtonProps = (ButtonWithText | ChildrenProps) & {
+ /** Should the press event bubble across multiple instances when Enter key triggers it. */
+ allowBubble?: boolean;
/** The icon asset to display to the right of the text */
- iconRight: PropTypes.func,
+ iconRight?: React.FC;
/** The fill color to pass into the icon. */
- iconFill: PropTypes.string,
+ iconFill?: string;
/** Any additional styles to pass to the left icon container. */
- // eslint-disable-next-line react/forbid-prop-types
- iconStyles: PropTypes.arrayOf(PropTypes.object),
+ iconStyles?: StyleProp;
/** Any additional styles to pass to the right icon container. */
- // eslint-disable-next-line react/forbid-prop-types
- iconRightStyles: PropTypes.arrayOf(PropTypes.object),
+ iconRightStyles?: StyleProp;
/** Small sized button */
- small: PropTypes.bool,
+ small?: boolean;
/** Large sized button */
- large: PropTypes.bool,
+ large?: boolean;
- /** medium sized button */
- medium: PropTypes.bool,
+ /** Medium sized button */
+ medium?: boolean;
/** Indicates whether the button should be disabled and in the loading state */
- isLoading: PropTypes.bool,
+ isLoading?: boolean;
/** Indicates whether the button should be disabled */
- isDisabled: PropTypes.bool,
+ isDisabled?: boolean;
/** A function that is called when the button is clicked on */
- onPress: PropTypes.func,
+ onPress?: (event?: GestureResponderEvent | KeyboardEvent) => void;
/** A function that is called when the button is long pressed */
- onLongPress: PropTypes.func,
+ onLongPress?: (event?: GestureResponderEvent) => void;
/** A function that is called when the button is pressed */
- onPressIn: PropTypes.func,
+ onPressIn?: () => void;
/** A function that is called when the button is released */
- onPressOut: PropTypes.func,
+ onPressOut?: () => void;
/** Callback that is called when mousedown is triggered. */
- onMouseDown: PropTypes.func,
+ onMouseDown?: () => void;
/** Call the onPress function when Enter key is pressed */
- pressOnEnter: PropTypes.bool,
+ pressOnEnter?: boolean;
/** The priority to assign the enter key event listener. 0 is the highest priority. */
- enterKeyEventListenerPriority: PropTypes.number,
+ enterKeyEventListenerPriority?: number;
/** Additional styles to add after local styles. Applied to Pressable portion of button */
- style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
+ style?: StyleProp;
- /** Additional button styles. Specific to the OpacityView of button */
- // eslint-disable-next-line react/forbid-prop-types
- innerStyles: PropTypes.arrayOf(PropTypes.object),
+ /** Additional button styles. Specific to the OpacityView of the button */
+ innerStyles?: StyleProp;
/** Additional text styles */
- // eslint-disable-next-line react/forbid-prop-types
- textStyles: PropTypes.arrayOf(PropTypes.object),
+ textStyles?: StyleProp;
/** Whether we should use the default hover style */
- shouldUseDefaultHover: PropTypes.bool,
+ shouldUseDefaultHover?: boolean;
/** Whether we should use the success theme color */
- success: PropTypes.bool,
+ success?: boolean;
/** Whether we should use the danger theme color */
- danger: PropTypes.bool,
-
- /** Children to replace all inner contents of button */
- children: PropTypes.node,
+ danger?: boolean;
/** Should we remove the right border radius top + bottom? */
- shouldRemoveRightBorderRadius: PropTypes.bool,
+ shouldRemoveRightBorderRadius?: boolean;
/** Should we remove the left border radius top + bottom? */
- shouldRemoveLeftBorderRadius: PropTypes.bool,
+ shouldRemoveLeftBorderRadius?: boolean;
/** Should enable the haptic feedback? */
- shouldEnableHapticFeedback: PropTypes.bool,
+ shouldEnableHapticFeedback?: boolean;
/** Id to use for this button */
- id: PropTypes.string,
+ id?: string;
/** Accessibility label for the component */
- accessibilityLabel: PropTypes.string,
-
- /** A ref to forward the button */
- forwardedRef: refPropTypes,
-};
-
-const defaultProps = {
- allowBubble: false,
- text: '',
- shouldShowRightIcon: false,
- icon: null,
- iconRight: Expensicons.ArrowRight,
- iconFill: undefined,
- iconStyles: [],
- iconRightStyles: [],
- isLoading: false,
- isDisabled: false,
- small: false,
- large: false,
- medium: false,
- onPress: () => {},
- onLongPress: () => {},
- onPressIn: () => {},
- onPressOut: () => {},
- onMouseDown: undefined,
- pressOnEnter: false,
- enterKeyEventListenerPriority: 0,
- style: [],
- innerStyles: [],
- textStyles: [],
- shouldUseDefaultHover: true,
- success: false,
- danger: false,
- children: null,
- shouldRemoveRightBorderRadius: false,
- shouldRemoveLeftBorderRadius: false,
- shouldEnableHapticFeedback: false,
- id: '',
- accessibilityLabel: '',
- forwardedRef: undefined,
+ accessibilityLabel?: string;
};
-function Button({
- allowBubble,
- text,
- shouldShowRightIcon,
-
- icon,
- iconRight,
- iconFill,
- iconStyles,
- iconRightStyles,
-
- small,
- large,
- medium,
-
- isLoading,
- isDisabled,
-
- onPress,
- onLongPress,
- onPressIn,
- onPressOut,
- onMouseDown,
-
- pressOnEnter,
- enterKeyEventListenerPriority,
-
- style,
- innerStyles,
- textStyles,
-
- shouldUseDefaultHover,
- success,
- danger,
- children,
-
- shouldRemoveRightBorderRadius,
- shouldRemoveLeftBorderRadius,
- shouldEnableHapticFeedback,
-
- id,
- accessibilityLabel,
- forwardedRef,
-}) {
+function Button(
+ {
+ allowBubble = false,
+
+ iconRight = Expensicons.ArrowRight,
+ iconFill = themeColors.textLight,
+ iconStyles = [],
+ iconRightStyles = [],
+
+ small = false,
+ large = false,
+ medium = false,
+
+ isLoading = false,
+ isDisabled = false,
+
+ onPress = () => {},
+ onLongPress = () => {},
+ onPressIn = () => {},
+ onPressOut = () => {},
+ onMouseDown = undefined,
+
+ pressOnEnter = false,
+ enterKeyEventListenerPriority = 0,
+
+ style = [],
+ innerStyles = [],
+ textStyles = [],
+
+ shouldUseDefaultHover = true,
+ success = false,
+ danger = false,
+
+ shouldRemoveRightBorderRadius = false,
+ shouldRemoveLeftBorderRadius = false,
+ shouldEnableHapticFeedback = false,
+
+ id = '',
+ accessibilityLabel = '',
+ ...rest
+ }: ButtonProps,
+ ref: ForwardedRef,
+) {
const theme = useTheme();
const styles = useThemeStyles();
const isFocused = useIsFocused();
const keyboardShortcutCallback = useCallback(
- (event) => {
+ (event?: GestureResponderEvent | KeyboardEvent) => {
if (!validateSubmitShortcut(isFocused, isDisabled, isLoading, event)) {
return;
}
@@ -223,10 +178,12 @@ function Button({
});
const renderContent = () => {
- if (children) {
- return children;
+ if ('children' in rest) {
+ return rest.children;
}
+ const {text = '', icon = null, shouldShowRightIcon = false} = rest;
+
const textComponent = (
@@ -248,12 +205,13 @@ function Button({
);
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if (icon || shouldShowRightIcon) {
return (
{icon && (
-
+
{shouldShowRightIcon && (
-
+ {
- if (event && event.type === 'click') {
- event.currentTarget.blur();
+ if (event?.type === 'click') {
+ const currentTarget = event?.currentTarget as HTMLElement;
+ currentTarget?.blur();
}
if (shouldEnableHapticFeedback) {
@@ -307,7 +266,7 @@ function Button({
styles.buttonContainer,
shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
- ...StyleUtils.parseStyleAsArray(style),
+ style,
]}
style={[
styles.button,
@@ -320,8 +279,9 @@ function Button({
isDisabled && !danger && !success ? styles.buttonDisabled : undefined,
shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
- icon || shouldShowRightIcon ? styles.alignItemsStretch : undefined,
- ...innerStyles,
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ 'text' in rest && (rest?.icon || rest?.shouldShowRightIcon) ? styles.alignItemsStretch : undefined,
+ innerStyles,
]}
hoverStyle={[
shouldUseDefaultHover && !isDisabled ? styles.buttonDefaultHovered : undefined,
@@ -344,18 +304,6 @@ function Button({
);
}
-Button.propTypes = propTypes;
-Button.defaultProps = defaultProps;
Button.displayName = 'Button';
-const ButtonWithRef = React.forwardRef((props, ref) => (
-
-));
-
-ButtonWithRef.displayName = 'ButtonWithRef';
-
-export default withNavigationFallback(ButtonWithRef);
+export default withNavigationFallback(React.forwardRef(Button));
diff --git a/src/components/Button/validateSubmitShortcut/index.js b/src/components/Button/validateSubmitShortcut/index.js
deleted file mode 100644
index bfe5c79483fa..000000000000
--- a/src/components/Button/validateSubmitShortcut/index.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * Validate if the submit shortcut should be triggered depending on the button state
- *
- * @param {boolean} isFocused Whether Button is on active screen
- * @param {boolean} isDisabled Indicates whether the button should be disabled
- * @param {boolean} isLoading Indicates whether the button should be disabled and in the loading state
- * @param {Object} event Focused input event
- * @returns {boolean} Returns `true` if the shortcut should be triggered
- */
-function validateSubmitShortcut(isFocused, isDisabled, isLoading, event) {
- if (!isFocused || isDisabled || isLoading || (event && event.target.nodeName === 'TEXTAREA')) {
- return false;
- }
-
- event.preventDefault();
- return true;
-}
-
-export default validateSubmitShortcut;
diff --git a/src/components/Button/validateSubmitShortcut/index.native.js b/src/components/Button/validateSubmitShortcut/index.native.js
deleted file mode 100644
index 2822fa56d590..000000000000
--- a/src/components/Button/validateSubmitShortcut/index.native.js
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * Validate if the submit shortcut should be triggered depending on the button state
- *
- * @param {boolean} isFocused Whether Button is on active screen
- * @param {boolean} isDisabled Indicates whether the button should be disabled
- * @param {boolean} isLoading Indicates whether the button should be disabled and in the loading state
- * @returns {boolean} Returns `true` if the shortcut should be triggered
- */
-function validateSubmitShortcut(isFocused, isDisabled, isLoading) {
- if (!isFocused || isDisabled || isLoading) {
- return false;
- }
-
- return true;
-}
-
-export default validateSubmitShortcut;
diff --git a/src/components/Button/validateSubmitShortcut/index.native.ts b/src/components/Button/validateSubmitShortcut/index.native.ts
new file mode 100644
index 000000000000..7687855f109b
--- /dev/null
+++ b/src/components/Button/validateSubmitShortcut/index.native.ts
@@ -0,0 +1,20 @@
+import ValidateSubmitShortcut from './types';
+
+/**
+ * Validate if the submit shortcut should be triggered depending on the button state
+ *
+ * @param isFocused Whether Button is on active screen
+ * @param isDisabled Indicates whether the button should be disabled
+ * @param isLoading Indicates whether the button should be disabled and in the loading state
+ * @return Returns `true` if the shortcut should be triggered
+ */
+
+const validateSubmitShortcut: ValidateSubmitShortcut = (isFocused, isDisabled, isLoading) => {
+ if (!isFocused || isDisabled || isLoading) {
+ return false;
+ }
+
+ return true;
+};
+
+export default validateSubmitShortcut;
diff --git a/src/components/Button/validateSubmitShortcut/index.ts b/src/components/Button/validateSubmitShortcut/index.ts
new file mode 100644
index 000000000000..55b3e44192e4
--- /dev/null
+++ b/src/components/Button/validateSubmitShortcut/index.ts
@@ -0,0 +1,23 @@
+import ValidateSubmitShortcut from './types';
+
+/**
+ * Validate if the submit shortcut should be triggered depending on the button state
+ *
+ * @param isFocused Whether Button is on active screen
+ * @param isDisabled Indicates whether the button should be disabled
+ * @param isLoading Indicates whether the button should be disabled and in the loading state
+ * @param event Focused input event
+ * @returns Returns `true` if the shortcut should be triggered
+ */
+
+const validateSubmitShortcut: ValidateSubmitShortcut = (isFocused, isDisabled, isLoading, event) => {
+ const eventTarget = event?.target as HTMLElement;
+ if (!isFocused || isDisabled || isLoading || eventTarget.nodeName === 'TEXTAREA') {
+ return false;
+ }
+
+ event?.preventDefault();
+ return true;
+};
+
+export default validateSubmitShortcut;
diff --git a/src/components/Button/validateSubmitShortcut/types.ts b/src/components/Button/validateSubmitShortcut/types.ts
new file mode 100644
index 000000000000..9970e1478a4c
--- /dev/null
+++ b/src/components/Button/validateSubmitShortcut/types.ts
@@ -0,0 +1,5 @@
+import {GestureResponderEvent} from 'react-native';
+
+type ValidateSubmitShortcut = (isFocused: boolean, isDisabled: boolean, isLoading: boolean, event?: GestureResponderEvent | KeyboardEvent) => boolean;
+
+export default ValidateSubmitShortcut;
diff --git a/src/components/DistanceRequest/DistanceRequestFooter.js b/src/components/DistanceRequest/DistanceRequestFooter.js
index f53fadb8ab87..b212dae615e4 100644
--- a/src/components/DistanceRequest/DistanceRequestFooter.js
+++ b/src/components/DistanceRequest/DistanceRequestFooter.js
@@ -8,10 +8,8 @@ import _ from 'underscore';
import Button from '@components/Button';
import DistanceMapView from '@components/DistanceMapView';
import * as Expensicons from '@components/Icon/Expensicons';
-import PendingMapView from '@components/MapView/PendingMapView';
import transactionPropTypes from '@components/transactionPropTypes';
import useLocalize from '@hooks/useLocalize';
-import useNetwork from '@hooks/useNetwork';
import * as TransactionUtils from '@libs/TransactionUtils';
import useTheme from '@styles/themes/useTheme';
import useThemeStyles from '@styles/useThemeStyles';
@@ -57,7 +55,6 @@ const defaultProps = {
function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navigateToWaypointEditPage}) {
const theme = useTheme();
const styles = useThemeStyles();
- const {isOffline} = useNetwork();
const {translate} = useLocalize();
const numberOfWaypoints = _.size(waypoints);
@@ -114,28 +111,20 @@ function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navig
)}
- {!isOffline && Boolean(mapboxAccessToken.token) ? (
-
- ) : (
-
- )}
+
>
);
diff --git a/src/components/FixedFooter.tsx b/src/components/FixedFooter.tsx
index 34bce2133a89..475de82fac35 100644
--- a/src/components/FixedFooter.tsx
+++ b/src/components/FixedFooter.tsx
@@ -7,12 +7,12 @@ type FixedFooterProps = {
children: ReactNode;
/** Styles to be assigned to Container */
- style: Array>;
+ style?: StyleProp;
};
function FixedFooter({style = [], children}: FixedFooterProps) {
const styles = useThemeStyles();
- return {children};
+ return {children};
}
FixedFooter.displayName = 'FixedFooter';
diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js
index 31ef75611d03..6908faaf3a01 100644
--- a/src/components/Form/FormProvider.js
+++ b/src/components/Form/FormProvider.js
@@ -56,9 +56,7 @@ const propTypes = {
/** Whether the form submit action is dangerous */
isSubmitActionDangerous: PropTypes.bool,
- /** Whether ScrollWithContext should be used instead of regular ScrollView.
- * Set to true when there's a nested Picker component in Form.
- */
+ /** Whether ScrollWithContext should be used instead of regular ScrollView. Set to true when there's a nested Picker component in Form. */
scrollContextEnabled: PropTypes.bool,
/** Container styles */
@@ -70,11 +68,18 @@ const propTypes = {
/** Information about the network */
network: networkPropTypes.isRequired,
+ /** Should validate function be called when input loose focus */
shouldValidateOnBlur: PropTypes.bool,
+ /** Should validate function be called when the value of the input is changed */
shouldValidateOnChange: PropTypes.bool,
};
+// In order to prevent Checkbox focus loss when the user are focusing a TextInput and proceeds to toggle a CheckBox in web and mobile web.
+// 200ms delay was chosen as a result of empirical testing.
+// More details: https://github.com/Expensify/App/pull/16444#issuecomment-1482983426
+const VALIDATE_DELAY = 200;
+
const defaultProps = {
isSubmitButtonVisible: true,
formState: {
@@ -251,19 +256,34 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
// as this is already happening by the value prop.
defaultValue: undefined,
onTouched: (event) => {
- setTouchedInput(inputID);
+ if (!propsToParse.shouldSetTouchedOnBlurOnly) {
+ setTimeout(() => {
+ setTouchedInput(inputID);
+ }, VALIDATE_DELAY);
+ }
if (_.isFunction(propsToParse.onTouched)) {
propsToParse.onTouched(event);
}
},
onPress: (event) => {
- setTouchedInput(inputID);
+ if (!propsToParse.shouldSetTouchedOnBlurOnly) {
+ setTimeout(() => {
+ setTouchedInput(inputID);
+ }, VALIDATE_DELAY);
+ }
if (_.isFunction(propsToParse.onPress)) {
propsToParse.onPress(event);
}
},
- onPressIn: (event) => {
- setTouchedInput(inputID);
+ onPressOut: (event) => {
+ // To prevent validating just pressed inputs, we need to set the touched input right after
+ // onValidate and to do so, we need to delays setTouchedInput of the same amount of time
+ // as the onValidate is delayed
+ if (!propsToParse.shouldSetTouchedOnBlurOnly) {
+ setTimeout(() => {
+ setTouchedInput(inputID);
+ }, VALIDATE_DELAY);
+ }
if (_.isFunction(propsToParse.onPressIn)) {
propsToParse.onPressIn(event);
}
@@ -284,7 +304,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
if (shouldValidateOnBlur) {
onValidate(inputValues, !hasServerError);
}
- }, 200);
+ }, VALIDATE_DELAY);
}
if (_.isFunction(propsToParse.onBlur)) {
diff --git a/src/components/Form/FormWrapper.js b/src/components/Form/FormWrapper.js
index 4f7346a94a2d..638b6e5f8d19 100644
--- a/src/components/Form/FormWrapper.js
+++ b/src/components/Form/FormWrapper.js
@@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import FormSubmit from '@components/FormSubmit';
+import refPropTypes from '@components/refPropTypes';
import SafeAreaConsumer from '@components/SafeAreaConsumer';
import ScrollViewWithContext from '@components/ScrollViewWithContext';
import * as ErrorUtils from '@libs/ErrorUtils';
@@ -64,7 +65,7 @@ const propTypes = {
errors: errorsPropType.isRequired,
- inputRefs: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object])).isRequired,
+ inputRefs: PropTypes.objectOf(refPropTypes).isRequired,
};
const defaultProps = {
diff --git a/src/components/Form/InputWrapper.js b/src/components/Form/InputWrapper.js
index 36dca6ffee61..11e74d2759b1 100644
--- a/src/components/Form/InputWrapper.js
+++ b/src/components/Form/InputWrapper.js
@@ -1,6 +1,7 @@
import PropTypes from 'prop-types';
import React, {forwardRef, useContext} from 'react';
import refPropTypes from '@components/refPropTypes';
+import TextInput from '@components/TextInput';
import FormContext from './FormContext';
const propTypes = {
@@ -22,8 +23,13 @@ const defaultProps = {
function InputWrapper(props) {
const {InputComponent, inputID, forwardedRef, ...rest} = props;
const {registerInput} = useContext(FormContext);
+ // There are inputs that dont have onBlur methods, to simulate the behavior of onBlur in e.g. checkbox, we had to
+ // use different methods like onPress. This introduced a problem that inputs that have the onBlur method were
+ // calling some methods too early or twice, so we had to add this check to prevent that side effect.
+ // For now this side effect happened only in `TextInput` components.
+ const shouldSetTouchedOnBlurOnly = InputComponent === TextInput;
// eslint-disable-next-line react/jsx-props-no-spreading
- return ;
+ return ;
}
InputWrapper.propTypes = propTypes;
diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx
index c91dc63a3bd1..db3e076eacca 100644
--- a/src/components/MapView/MapView.tsx
+++ b/src/components/MapView/MapView.tsx
@@ -2,36 +2,99 @@ import {useFocusEffect, useNavigation} from '@react-navigation/native';
import Mapbox, {MapState, MarkerView, setAccessToken} from '@rnmapbox/maps';
import {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import setUserLocation from '@libs/actions/UserLocation';
+import compose from '@libs/compose';
+import getCurrentPosition from '@libs/getCurrentPosition';
import styles from '@styles/styles';
import CONST from '@src/CONST';
+import useLocalize from '@src/hooks/useLocalize';
+import useNetwork from '@src/hooks/useNetwork';
+import ONYXKEYS from '@src/ONYXKEYS';
import Direction from './Direction';
-import {MapViewHandle, MapViewProps} from './MapViewTypes';
+import {MapViewHandle} from './MapViewTypes';
+import PendingMapView from './PendingMapView';
import responder from './responder';
+import {ComponentProps, MapViewOnyxProps} from './types';
import utils from './utils';
-const MapView = forwardRef(({accessToken, style, mapPadding, styleURL, pitchEnabled, initialState, waypoints, directionCoordinates, onMapReady}, ref) => {
- const cameraRef = useRef(null);
- const [isIdle, setIsIdle] = useState(false);
- const navigation = useNavigation();
-
- useImperativeHandle(
- ref,
- () => ({
- flyTo: (location: [number, number], zoomLevel: number = CONST.MAPBOX.DEFAULT_ZOOM, animationDuration?: number) =>
- cameraRef.current?.setCamera({zoomLevel, centerCoordinate: location, animationDuration}),
- fitBounds: (northEast: [number, number], southWest: [number, number], paddingConfig?: number | number[] | undefined, animationDuration?: number | undefined) =>
- cameraRef.current?.fitBounds(northEast, southWest, paddingConfig, animationDuration),
- }),
- [],
- );
-
- // When the page loses focus, we temporarily set the "idled" state to false.
- // When the page regains focus, the onIdled method of the map will set the actual "idled" state,
- // which in turn triggers the callback.
- useFocusEffect(
- // eslint-disable-next-line rulesdir/prefer-early-return
- useCallback(() => {
- if (waypoints?.length && isIdle) {
+const MapView = forwardRef(
+ ({accessToken, style, mapPadding, userLocation: cachedUserLocation, styleURL, pitchEnabled, initialState, waypoints, directionCoordinates, onMapReady}, ref) => {
+ const navigation = useNavigation();
+ const {isOffline} = useNetwork();
+ const {translate} = useLocalize();
+
+ const cameraRef = useRef(null);
+ const [isIdle, setIsIdle] = useState(false);
+ const [currentPosition, setCurrentPosition] = useState(cachedUserLocation);
+ const [userInteractedWithMap, setUserInteractedWithMap] = useState(false);
+
+ useFocusEffect(
+ useCallback(() => {
+ if (isOffline) {
+ return;
+ }
+
+ getCurrentPosition(
+ (params) => {
+ const currentCoords = {longitude: params.coords.longitude, latitude: params.coords.latitude};
+ setCurrentPosition(currentCoords);
+ setUserLocation(currentCoords);
+ },
+ () => {
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ if (cachedUserLocation || !initialState) {
+ return;
+ }
+
+ setCurrentPosition({longitude: initialState.location[0], latitude: initialState.location[1]});
+ },
+ );
+ }, [cachedUserLocation, initialState, isOffline]),
+ );
+
+ // Determines if map can be panned to user's detected
+ // location without bothering the user. It will return
+ // false if user has already started dragging the map or
+ // if there are one or more waypoints present.
+ const shouldPanMapToCurrentPosition = useCallback(() => !userInteractedWithMap && (!waypoints || waypoints.length === 0), [userInteractedWithMap, waypoints]);
+
+ useEffect(() => {
+ if (!currentPosition || !cameraRef.current) {
+ return;
+ }
+
+ if (!shouldPanMapToCurrentPosition()) {
+ return;
+ }
+
+ cameraRef.current.setCamera({
+ zoomLevel: CONST.MAPBOX.DEFAULT_ZOOM,
+ animationDuration: 1500,
+ centerCoordinate: [currentPosition.longitude, currentPosition.latitude],
+ });
+ }, [currentPosition, shouldPanMapToCurrentPosition]);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ flyTo: (location: [number, number], zoomLevel: number = CONST.MAPBOX.DEFAULT_ZOOM, animationDuration?: number) =>
+ cameraRef.current?.setCamera({zoomLevel, centerCoordinate: location, animationDuration}),
+ fitBounds: (northEast: [number, number], southWest: [number, number], paddingConfig?: number | number[] | undefined, animationDuration?: number | undefined) =>
+ cameraRef.current?.fitBounds(northEast, southWest, paddingConfig, animationDuration),
+ }),
+ [],
+ );
+
+ // When the page loses focus, we temporarily set the "idled" state to false.
+ // When the page regains focus, the onIdled method of the map will set the actual "idled" state,
+ // which in turn triggers the callback.
+ useFocusEffect(
+ useCallback(() => {
+ if (!waypoints || waypoints.length === 0 || !isIdle) {
+ return;
+ }
+
if (waypoints.length === 1) {
cameraRef.current?.setCamera({
zoomLevel: 15,
@@ -45,69 +108,87 @@ const MapView = forwardRef(({accessToken, style, ma
);
cameraRef.current?.fitBounds(northEast, southWest, mapPadding, 1000);
}
+ }, [mapPadding, waypoints, isIdle, directionCoordinates]),
+ );
+
+ useEffect(() => {
+ const unsubscribe = navigation.addListener('blur', () => {
+ setIsIdle(false);
+ });
+ return unsubscribe;
+ }, [navigation]);
+
+ useEffect(() => {
+ setAccessToken(accessToken);
+ }, [accessToken]);
+
+ const setMapIdle = (e: MapState) => {
+ if (e.gestures.isGestureActive) {
+ return;
+ }
+ setIsIdle(true);
+ if (onMapReady) {
+ onMapReady();
}
- }, [mapPadding, waypoints, isIdle, directionCoordinates]),
- );
-
- useEffect(() => {
- const unsubscribe = navigation.addListener('blur', () => {
- setIsIdle(false);
- });
- return unsubscribe;
- }, [navigation]);
-
- useEffect(() => {
- setAccessToken(accessToken);
- }, [accessToken]);
-
- const setMapIdle = (e: MapState) => {
- if (e.gestures.isGestureActive) {
- return;
- }
- setIsIdle(true);
- if (onMapReady) {
- onMapReady();
- }
- };
-
- return (
-
-
-
-
- {waypoints?.map(({coordinate, markerComponent, id}) => {
- const MarkerComponent = markerComponent;
- return (
-
+ {!isOffline && Boolean(accessToken) && Boolean(currentPosition) ? (
+
+ setUserInteractedWithMap(true)}
+ pitchEnabled={pitchEnabled}
+ attributionPosition={{...styles.r2, ...styles.b2}}
+ scaleBarEnabled={false}
+ logoPosition={{...styles.l2, ...styles.b2}}
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {...responder.panHandlers}
>
-
-
- );
- })}
+
+
+ {waypoints?.map(({coordinate, markerComponent, id}) => {
+ const MarkerComponent = markerComponent;
+ return (
+
+
+
+ );
+ })}
- {directionCoordinates && }
-
-
- );
-});
+ {directionCoordinates && }
+
+
+ ) : (
+
+ )}
+ >
+ );
+ },
+);
-export default memo(MapView);
+export default compose(
+ withOnyx({
+ userLocation: {
+ key: ONYXKEYS.USER_LOCATION,
+ },
+ }),
+ memo,
+)(MapView);
diff --git a/src/components/MapView/MapView.web.tsx b/src/components/MapView/MapView.web.tsx
index 110d24f0c087..1880049b3542 100644
--- a/src/components/MapView/MapView.web.tsx
+++ b/src/components/MapView/MapView.web.tsx
@@ -2,26 +2,97 @@
// This is why we have separate components for web and native to handle the specific implementations.
// For the web version, we use the Mapbox Web library called react-map-gl, while for the native mobile version,
// we utilize a different Mapbox library @rnmapbox/maps tailored for mobile development.
+import {useFocusEffect} from '@react-navigation/native';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useState} from 'react';
import Map, {MapRef, Marker} from 'react-map-gl';
import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
import * as StyleUtils from '@styles/StyleUtils';
import themeColors from '@styles/themes/default';
+import setUserLocation from '@userActions/UserLocation';
import CONST from '@src/CONST';
+import useLocalize from '@src/hooks/useLocalize';
+import useNetwork from '@src/hooks/useNetwork';
+import getCurrentPosition from '@src/libs/getCurrentPosition';
+import ONYXKEYS from '@src/ONYXKEYS';
+import styles from '@src/styles/styles';
import Direction from './Direction';
import './mapbox.css';
-import {MapViewHandle, MapViewProps} from './MapViewTypes';
+import {MapViewHandle} from './MapViewTypes';
+import PendingMapView from './PendingMapView';
import responder from './responder';
+import {ComponentProps, MapViewOnyxProps} from './types';
import utils from './utils';
-const MapView = forwardRef(
- ({style, styleURL, waypoints, mapPadding, accessToken, directionCoordinates, initialState = {location: CONST.MAPBOX.DEFAULT_COORDINATE, zoom: CONST.MAPBOX.DEFAULT_ZOOM}}, ref) => {
+const MapView = forwardRef(
+ (
+ {
+ style,
+ styleURL,
+ waypoints,
+ mapPadding,
+ accessToken,
+ userLocation: cachedUserLocation,
+ directionCoordinates,
+ initialState = {location: CONST.MAPBOX.DEFAULT_COORDINATE, zoom: CONST.MAPBOX.DEFAULT_ZOOM},
+ },
+ ref,
+ ) => {
+ const {isOffline} = useNetwork();
+ const {translate} = useLocalize();
+
const [mapRef, setMapRef] = useState(null);
+ const [currentPosition, setCurrentPosition] = useState(cachedUserLocation);
+ const [userInteractedWithMap, setUserInteractedWithMap] = useState(false);
const [shouldResetBoundaries, setShouldResetBoundaries] = useState(false);
const setRef = useCallback((newRef: MapRef | null) => setMapRef(newRef), []);
+ useFocusEffect(
+ useCallback(() => {
+ if (isOffline) {
+ return;
+ }
+
+ getCurrentPosition(
+ (params) => {
+ const currentCoords = {longitude: params.coords.longitude, latitude: params.coords.latitude};
+ setCurrentPosition(currentCoords);
+ setUserLocation(currentCoords);
+ },
+ () => {
+ if (cachedUserLocation) {
+ return;
+ }
+
+ setCurrentPosition({longitude: initialState.location[0], latitude: initialState.location[1]});
+ },
+ );
+ }, [cachedUserLocation, isOffline, initialState.location]),
+ );
+
+ // Determines if map can be panned to user's detected
+ // location without bothering the user. It will return
+ // false if user has already started dragging the map or
+ // if there are one or more waypoints present.
+ const shouldPanMapToCurrentPosition = useCallback(() => !userInteractedWithMap && (!waypoints || waypoints.length === 0), [userInteractedWithMap, waypoints]);
+
+ useEffect(() => {
+ if (!currentPosition || !mapRef) {
+ return;
+ }
+
+ if (!shouldPanMapToCurrentPosition()) {
+ return;
+ }
+
+ mapRef.flyTo({
+ center: [currentPosition.longitude, currentPosition.latitude],
+ zoom: CONST.MAPBOX.DEFAULT_ZOOM,
+ });
+ }, [currentPosition, userInteractedWithMap, mapRef, shouldPanMapToCurrentPosition]);
+
const resetBoundaries = useCallback(() => {
if (!waypoints || waypoints.length === 0) {
return;
@@ -34,7 +105,7 @@ const MapView = forwardRef(
if (waypoints.length === 1) {
mapRef.flyTo({
center: waypoints[0].coordinate,
- zoom: 15,
+ zoom: CONST.MAPBOX.DEFAULT_ZOOM,
});
return;
}
@@ -91,40 +162,55 @@ const MapView = forwardRef(
);
return (
-
-
-
+ <>
+ {!isOffline && Boolean(accessToken) && Boolean(currentPosition) ? (
+
+
+
+ ) : (
+
+ )}
+ >
);
},
);
-export default MapView;
+export default withOnyx({
+ userLocation: {
+ key: ONYXKEYS.USER_LOCATION,
+ },
+})(MapView);
diff --git a/src/components/MapView/types.ts b/src/components/MapView/types.ts
new file mode 100644
index 000000000000..2c8b9240c445
--- /dev/null
+++ b/src/components/MapView/types.ts
@@ -0,0 +1,11 @@
+import {OnyxEntry} from 'react-native-onyx';
+import * as OnyxTypes from '@src/types/onyx';
+import {MapViewProps} from './MapViewTypes';
+
+type MapViewOnyxProps = {
+ userLocation: OnyxEntry;
+};
+
+type ComponentProps = MapViewProps & MapViewOnyxProps;
+
+export type {MapViewOnyxProps, ComponentProps};
diff --git a/src/components/MentionSuggestions.tsx b/src/components/MentionSuggestions.tsx
index f1ac13f58d16..f04212ae113b 100644
--- a/src/components/MentionSuggestions.tsx
+++ b/src/components/MentionSuggestions.tsx
@@ -14,9 +14,12 @@ type Mention = {
/** Display name of the user */
text: string;
- /** Email/phone number of the user */
+ /** The formatted email/phone number of the user */
alternateText: string;
+ /** Email/phone number of the user */
+ login: string;
+
/** Array of icons of the user. We use the first element of this array */
icons: Icon[];
};
diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx
index 54a178db1cdd..95a7f3adc279 100644
--- a/src/components/Modal/BaseModal.tsx
+++ b/src/components/Modal/BaseModal.tsx
@@ -56,6 +56,7 @@ function BaseModal(
*/
const hideModal = useCallback(
(callHideCallback = true) => {
+ Modal.willAlertModalBecomeVisible(false);
if (shouldSetModalVisibility) {
Modal.setModalVisibility(false);
}
@@ -77,8 +78,6 @@ function BaseModal(
Modal.willAlertModalBecomeVisible(true);
// To handle closing any modal already visible when this modal is mounted, i.e. PopoverReportActionContextMenu
removeOnCloseListener = Modal.setCloseModal(onClose);
- } else if (wasVisible && !isVisible) {
- Modal.willAlertModalBecomeVisible(false);
}
return () => {
@@ -96,7 +95,6 @@ function BaseModal(
return;
}
hideModal(true);
- Modal.willAlertModalBecomeVisible(false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index efa9c5a49cec..f203154ab3db 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -28,17 +28,16 @@ import * as IOU from '@userActions/IOU';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import Button from './Button';
import ButtonWithDropdownMenu from './ButtonWithDropdownMenu';
import categoryPropTypes from './categoryPropTypes';
import ConfirmedRoute from './ConfirmedRoute';
import FormHelpMessage from './FormHelpMessage';
-import * as Expensicons from './Icon/Expensicons';
import Image from './Image';
import MenuItemWithTopDescription from './MenuItemWithTopDescription';
import optionPropTypes from './optionPropTypes';
import OptionsSelector from './OptionsSelector';
import SettlementButton from './SettlementButton';
+import ShowMoreButton from './ShowMoreButton';
import Switch from './Switch';
import tagPropTypes from './tagPropTypes';
import Text from './Text';
@@ -635,20 +634,10 @@ function MoneyRequestConfirmationList(props) {
numberOfLinesTitle={2}
/>
{!shouldShowAllFields && (
-
-
-
-
-
-
+
)}
{shouldShowAllFields && (
<>
diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js
index 8afda6c375bb..cb670f3cf6ce 100644
--- a/src/components/OptionRow.js
+++ b/src/components/OptionRow.js
@@ -186,6 +186,7 @@ function OptionRow(props) {
styles.alignItemsCenter,
styles.justifyContentBetween,
styles.sidebarLink,
+ !props.isDisabled && styles.cursorPointer,
props.shouldDisableRowInnerPadding ? null : styles.sidebarLinkInner,
props.optionIsFocused ? styles.sidebarLinkActive : null,
props.shouldHaveOptionSeparator && styles.borderTop,
diff --git a/src/components/OptionsList/BaseOptionsList.js b/src/components/OptionsList/BaseOptionsList.js
index 2f81e6d80e7d..b2bc4b182a60 100644
--- a/src/components/OptionsList/BaseOptionsList.js
+++ b/src/components/OptionsList/BaseOptionsList.js
@@ -9,6 +9,7 @@ import Text from '@components/Text';
import usePrevious from '@hooks/usePrevious';
import useThemeStyles from '@styles/useThemeStyles';
import variables from '@styles/variables';
+import CONST from '@src/CONST';
import {defaultProps as optionsListDefaultProps, propTypes as optionsListPropTypes} from './optionsListPropTypes';
const propTypes = {
@@ -70,6 +71,7 @@ function BaseOptionsList({
isLoadingNewOptions,
nestedScrollEnabled,
bounces,
+ renderFooterContent,
}) {
const styles = useThemeStyles();
const flattenedData = useRef();
@@ -281,11 +283,12 @@ function BaseOptionsList({
renderSectionHeader={renderSectionHeader}
extraData={focusedIndex}
initialNumToRender={12}
- maxToRenderPerBatch={5}
+ maxToRenderPerBatch={CONST.MAX_TO_RENDER_PER_BATCH.DEFAULT}
windowSize={5}
viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}}
onViewableItemsChanged={onViewableItemsChanged}
bounces={bounces}
+ ListFooterComponent={renderFooterContent}
/>
>
)}
diff --git a/src/components/OptionsList/optionsListPropTypes.js b/src/components/OptionsList/optionsListPropTypes.js
index b841943f2402..2485ad1558ac 100644
--- a/src/components/OptionsList/optionsListPropTypes.js
+++ b/src/components/OptionsList/optionsListPropTypes.js
@@ -100,6 +100,9 @@ const propTypes = {
/** Whether the list should have a bounce effect on iOS */
bounces: PropTypes.bool,
+
+ /** Custom content to display in the floating footer */
+ renderFooterContent: PropTypes.func,
};
const defaultProps = {
@@ -130,6 +133,7 @@ const defaultProps = {
isLoadingNewOptions: false,
nestedScrollEnabled: true,
bounces: true,
+ renderFooterContent: undefined,
};
export {propTypes, defaultProps};
diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js
index 1702f66605f7..be89132a0731 100755
--- a/src/components/OptionsSelector/BaseOptionsSelector.js
+++ b/src/components/OptionsSelector/BaseOptionsSelector.js
@@ -11,6 +11,7 @@ import Icon from '@components/Icon';
import {Info} from '@components/Icon/Expensicons';
import OptionsList from '@components/OptionsList';
import {PressableWithoutFeedback} from '@components/Pressable';
+import ShowMoreButton from '@components/ShowMoreButton';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
@@ -74,17 +75,23 @@ class BaseOptionsSelector extends Component {
this.selectFocusedOption = this.selectFocusedOption.bind(this);
this.addToSelection = this.addToSelection.bind(this);
this.updateSearchValue = this.updateSearchValue.bind(this);
+ this.incrementPage = this.incrementPage.bind(this);
+ this.sliceSections = this.sliceSections.bind(this);
+ this.calculateAllVisibleOptionsCount = this.calculateAllVisibleOptionsCount.bind(this);
this.relatedTarget = null;
const allOptions = this.flattenSections();
+ const sections = this.sliceSections();
const focusedIndex = this.getInitiallyFocusedIndex(allOptions);
this.state = {
+ sections,
allOptions,
focusedIndex,
shouldDisableRowSelection: false,
shouldShowReferralModal: false,
errorMessage: '',
+ paginationPage: 1,
};
}
@@ -100,7 +107,7 @@ class BaseOptionsSelector extends Component {
this.scrollToIndex(this.props.selectedOptions.length ? 0 : this.state.focusedIndex, false);
}
- componentDidUpdate(prevProps) {
+ componentDidUpdate(prevProps, prevState) {
if (prevProps.isFocused !== this.props.isFocused) {
if (this.props.isFocused) {
this.subscribeToKeyboardShortcut();
@@ -118,14 +125,24 @@ class BaseOptionsSelector extends Component {
}, CONST.ANIMATED_TRANSITION);
}
+ if (prevState.paginationPage !== this.state.paginationPage) {
+ const newSections = this.sliceSections();
+
+ this.setState({
+ sections: newSections,
+ });
+ }
+
if (_.isEqual(this.props.sections, prevProps.sections)) {
return;
}
+ const newSections = this.sliceSections();
const newOptions = this.flattenSections();
if (prevProps.preferredLocale !== this.props.preferredLocale) {
this.setState({
+ sections: newSections,
allOptions: newOptions,
});
return;
@@ -136,6 +153,7 @@ class BaseOptionsSelector extends Component {
// eslint-disable-next-line react/no-did-update-set-state
this.setState(
{
+ sections: newSections,
allOptions: newOptions,
focusedIndex: _.isNumber(this.props.focusedIndex) ? this.props.focusedIndex : newFocusedIndex,
},
@@ -189,8 +207,43 @@ class BaseOptionsSelector extends Component {
return defaultIndex;
}
+ /**
+ * Maps sections to render only allowed count of them per section.
+ *
+ * @returns {Objects[]}
+ */
+ sliceSections() {
+ return _.map(this.props.sections, (section) => {
+ if (_.isEmpty(section.data)) {
+ return section;
+ }
+
+ return {
+ ...section,
+ data: section.data.slice(0, CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * lodashGet(this.state, 'paginationPage', 1)),
+ };
+ });
+ }
+
+ /**
+ * Calculates all currently visible options based on the sections that are currently being shown
+ * and the number of items of those sections.
+ *
+ * @returns {Number}
+ */
+ calculateAllVisibleOptionsCount() {
+ let count = 0;
+
+ _.forEach(this.state.sections, (section) => {
+ count += lodashGet(section, 'data.length', 0);
+ });
+
+ return count;
+ }
+
updateSearchValue(value) {
this.setState({
+ paginationPage: 1,
errorMessage: value.length > this.props.maxLength ? this.props.translate('common.error.characterLimitExceedCounter', {length: value.length, limit: this.props.maxLength}) : '',
});
@@ -328,12 +381,16 @@ class BaseOptionsSelector extends Component {
const itemIndex = option.index;
const sectionIndex = option.sectionIndex;
+ if (!lodashGet(this.state.sections, `[${sectionIndex}].data[${itemIndex}]`, null)) {
+ return;
+ }
+
// Note: react-native's SectionList automatically strips out any empty sections.
// So we need to reduce the sectionIndex to remove any empty sections in front of the one we're trying to scroll to.
// Otherwise, it will cause an index-out-of-bounds error and crash the app.
let adjustedSectionIndex = sectionIndex;
for (let i = 0; i < sectionIndex; i++) {
- if (_.isEmpty(lodashGet(this.props.sections, `[${i}].data`))) {
+ if (_.isEmpty(lodashGet(this.state.sections, `[${i}].data`))) {
adjustedSectionIndex--;
}
}
@@ -387,7 +444,17 @@ class BaseOptionsSelector extends Component {
this.props.onAddToSelection(option);
}
+ /**
+ * Increments a pagination page to show more items
+ */
+ incrementPage() {
+ this.setState((prev) => ({
+ paginationPage: prev.paginationPage + 1,
+ }));
+ }
+
render() {
+ const shouldShowShowMoreButton = this.state.allOptions.length > CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * this.state.paginationPage;
const shouldShowFooter =
!this.props.isReadOnly && (this.props.shouldShowConfirmButton || this.props.footerContent) && !(this.props.canSelectMultipleOptions && _.isEmpty(this.props.selectedOptions));
const defaultConfirmButtonText = _.isUndefined(this.props.confirmButtonText) ? this.props.translate('common.confirm') : this.props.confirmButtonText;
@@ -424,7 +491,7 @@ class BaseOptionsSelector extends Component {
ref={(el) => (this.list = el)}
optionHoveredStyle={this.props.optionHoveredStyle}
onSelectRow={this.props.onSelectRow ? this.selectRow : undefined}
- sections={this.props.sections}
+ sections={this.state.sections}
focusedIndex={this.state.focusedIndex}
selectedOptions={this.props.selectedOptions}
canSelectMultipleOptions={this.props.canSelectMultipleOptions}
@@ -458,6 +525,16 @@ class BaseOptionsSelector extends Component {
shouldPreventDefaultFocusOnSelectRow={this.props.shouldPreventDefaultFocusOnSelectRow}
nestedScrollEnabled={this.props.nestedScrollEnabled}
bounces={!this.props.shouldTextInputAppearBelowOptions || !this.props.shouldAllowScrollingChildren}
+ renderFooterContent={() =>
+ shouldShowShowMoreButton && (
+
+ )
+ }
/>
);
@@ -475,7 +552,7 @@ class BaseOptionsSelector extends Component {
{} : this.updateFocusedIndex}
shouldResetIndexOnEndReached={false}
>
diff --git a/src/components/RadioButton.js b/src/components/RadioButton.tsx
similarity index 62%
rename from src/components/RadioButton.js
rename to src/components/RadioButton.tsx
index 9a7b6d38095a..b5e0467d3f00 100644
--- a/src/components/RadioButton.js
+++ b/src/components/RadioButton.tsx
@@ -1,4 +1,3 @@
-import PropTypes from 'prop-types';
import React from 'react';
import {View} from 'react-native';
import useTheme from '@styles/themes/useTheme';
@@ -8,42 +7,38 @@ import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
import PressableWithFeedback from './Pressable/PressableWithFeedback';
-const propTypes = {
+type RadioButtonProps = {
/** Whether radioButton is checked */
- isChecked: PropTypes.bool.isRequired,
+ isChecked: boolean;
/** A function that is called when the box/label is pressed */
- onPress: PropTypes.func.isRequired,
+ onPress: () => void;
/** Specifies the accessibility label for the radio button */
- accessibilityLabel: PropTypes.string.isRequired,
+ accessibilityLabel: string;
/** Should the input be styled for errors */
- hasError: PropTypes.bool,
+ hasError?: boolean;
/** Should the input be disabled */
- disabled: PropTypes.bool,
+ disabled?: boolean;
};
-const defaultProps = {
- hasError: false,
- disabled: false,
-};
-
-function RadioButton(props) {
+function RadioButton({isChecked, onPress, accessibilityLabel, hasError = false, disabled = false}: RadioButtonProps) {
const theme = useTheme();
const styles = useThemeStyles();
+
return (
-
- {props.isChecked && (
+
+ {isChecked && (
+ <>
{items.map((item) => (
))}
-
+ >
);
}
RadioButtons.displayName = 'RadioButtons';
+export type {Choice};
export default RadioButtons;
diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js
index 07aba132be0e..466a5a6eec51 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview.js
+++ b/src/components/ReportActionItem/MoneyRequestPreview.js
@@ -358,15 +358,17 @@ function MoneyRequestPreview(props) {
return childContainer;
}
+ const shouldDisableOnPress = props.isBillSplit && _.isEmpty(props.transaction);
+
return (
DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()}
onPressOut={() => ControlSelection.unblock()}
onLongPress={showContextMenu}
accessibilityLabel={props.isBillSplit ? props.translate('iou.split') : props.translate('iou.cash')}
accessibilityHint={CurrencyUtils.convertToDisplayString(requestAmount, requestCurrency)}
- style={[styles.moneyRequestPreviewBox, ...props.containerStyles]}
+ style={[styles.moneyRequestPreviewBox, ...props.containerStyles, shouldDisableOnPress && styles.cursorDefault]}
>
{childContainer}
diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js
index 33ad99f32326..b10b8d87cabd 100644
--- a/src/components/ReportActionItem/MoneyRequestView.js
+++ b/src/components/ReportActionItem/MoneyRequestView.js
@@ -104,12 +104,13 @@ function MoneyRequestView({report, parentReport, policyCategories, shouldShowHor
formattedTransactionAmount = translate('common.tbd');
}
const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && CurrencyUtils.convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency);
- const isExpensifyCardTransaction = TransactionUtils.isExpensifyCardTransaction(transaction);
- const cardProgramName = isExpensifyCardTransaction ? CardUtils.getCardDescription(transactionCardID) : '';
+ const isCardTransaction = TransactionUtils.isCardTransaction(transaction);
+ const cardProgramName = isCardTransaction ? CardUtils.getCardDescription(transactionCardID) : '';
// Flags for allowing or disallowing editing a money request
const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID);
- const canEdit = ReportUtils.canEditMoneyRequest(parentReportAction) && !isExpensifyCardTransaction;
+ const canEdit = ReportUtils.canEditMoneyRequest(parentReportAction);
+ const canEditAmount = canEdit && !isSettled && !isCardTransaction;
// A flag for verifying that the current report is a sub-report of a workspace chat
const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(report)), [report]);
@@ -125,7 +126,7 @@ function MoneyRequestView({report, parentReport, policyCategories, shouldShowHor
let amountDescription = `${translate('iou.amount')}`;
- if (isExpensifyCardTransaction) {
+ if (isCardTransaction) {
if (formattedOriginalAmount) {
amountDescription += ` • ${translate('iou.original')} ${formattedOriginalAmount}`;
}
@@ -190,8 +191,8 @@ function MoneyRequestView({report, parentReport, policyCategories, shouldShowHor
titleIcon={Expensicons.Checkmark}
description={amountDescription}
titleStyle={styles.newKansasLarge}
- interactive={canEdit && !isSettled}
- shouldShowRightIcon={canEdit && !isSettled}
+ interactive={canEditAmount}
+ shouldShowRightIcon={canEditAmount}
onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))}
brickRoadIndicator={hasErrors && transactionAmount === 0 ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
error={hasErrors && transactionAmount === 0 ? translate('common.error.enterAmount') : ''}
@@ -271,13 +272,12 @@ function MoneyRequestView({report, parentReport, policyCategories, shouldShowHor
/>
)}
- {isExpensifyCardTransaction && (
+ {isCardTransaction && (
)}
diff --git a/src/components/SelectionList/BaseSelectionList.js b/src/components/SelectionList/BaseSelectionList.js
index 90ed7c707fe9..6f53679f28d3 100644
--- a/src/components/SelectionList/BaseSelectionList.js
+++ b/src/components/SelectionList/BaseSelectionList.js
@@ -59,6 +59,7 @@ function BaseSelectionList({
disableKeyboardShortcuts = false,
children,
shouldStopPropagation = false,
+ shouldUseDynamicMaxToRenderPerBatch = false,
}) {
const theme = useTheme();
const styles = useThemeStyles();
@@ -71,6 +72,8 @@ function BaseSelectionList({
const shouldShowSelectAll = Boolean(onSelectAll);
const activeElement = useActiveElement();
const isFocused = useIsFocused();
+ const [maxToRenderPerBatch, setMaxToRenderPerBatch] = useState(shouldUseDynamicMaxToRenderPerBatch ? 0 : CONST.MAX_TO_RENDER_PER_BATCH.DEFAULT);
+
/**
* Iterates through the sections and items inside each section, and builds 3 arrays along the way:
* - `allOptions`: Contains all the items in the list, flattened, regardless of section
@@ -301,6 +304,7 @@ function BaseSelectionList({
item={item}
isFocused={isItemFocused}
isDisabled={isDisabled}
+ isHide={!maxToRenderPerBatch}
showTooltip={showTooltip}
canSelectMultiple={canSelectMultiple}
onSelectRow={() => selectRow(item, true)}
@@ -310,13 +314,23 @@ function BaseSelectionList({
);
};
- const scrollToFocusedIndexOnFirstRender = useCallback(() => {
- if (!firstLayoutRef.current) {
- return;
- }
- scrollToIndex(focusedIndex, false);
- firstLayoutRef.current = false;
- }, [focusedIndex, scrollToIndex]);
+ const scrollToFocusedIndexOnFirstRender = useCallback(
+ ({nativeEvent}) => {
+ if (shouldUseDynamicMaxToRenderPerBatch) {
+ const listHeight = lodashGet(nativeEvent, 'layout.height', 0);
+ const itemHeight = lodashGet(nativeEvent, 'layout.y', 0);
+
+ setMaxToRenderPerBatch((Math.ceil(listHeight / itemHeight) || 0) + CONST.MAX_TO_RENDER_PER_BATCH.DEFAULT);
+ }
+
+ if (!firstLayoutRef.current) {
+ return;
+ }
+ scrollToIndex(focusedIndex, false);
+ firstLayoutRef.current = false;
+ },
+ [focusedIndex, scrollToIndex, shouldUseDynamicMaxToRenderPerBatch],
+ );
const updateAndScrollToFocusedIndex = useCallback(
(newFocusedIndex) => {
@@ -451,11 +465,12 @@ function BaseSelectionList({
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={showScrollIndicator}
initialNumToRender={12}
- maxToRenderPerBatch={5}
+ maxToRenderPerBatch={maxToRenderPerBatch}
windowSize={5}
viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}}
testID="selection-list"
onLayout={scrollToFocusedIndexOnFirstRender}
+ style={!maxToRenderPerBatch && styles.opacity0}
/>
{children}
>
diff --git a/src/components/SelectionList/selectionListPropTypes.js b/src/components/SelectionList/selectionListPropTypes.js
index 5b95f7dd0cbf..0c2fe83d025f 100644
--- a/src/components/SelectionList/selectionListPropTypes.js
+++ b/src/components/SelectionList/selectionListPropTypes.js
@@ -182,6 +182,9 @@ const propTypes = {
/** Custom content to display in the footer */
footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
+
+ /** Whether to use dynamic maxToRenderPerBatch depending on the visible number of elements */
+ shouldUseDynamicMaxToRenderPerBatch: PropTypes.bool,
};
export {propTypes, baseListItemPropTypes, radioListItemPropTypes, userListItemPropTypes};
diff --git a/src/components/SettlementButton.js b/src/components/SettlementButton.js
index 8cf9655d34dc..c7342b0d36ac 100644
--- a/src/components/SettlementButton.js
+++ b/src/components/SettlementButton.js
@@ -157,7 +157,9 @@ function SettlementButton({
if (canUseWallet) {
buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.EXPENSIFY]);
}
- buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.VBBA]);
+ if (isExpenseReport) {
+ buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.VBBA]);
+ }
buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.ELSEWHERE]);
// Put the preferred payment method to the front of the array so its shown as default
diff --git a/src/components/ShowMoreButton/index.js b/src/components/ShowMoreButton/index.js
new file mode 100644
index 000000000000..f983a468cc1c
--- /dev/null
+++ b/src/components/ShowMoreButton/index.js
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import {Text, View} from 'react-native';
+import _ from 'underscore';
+import Button from '@components/Button';
+import * as Expensicons from '@components/Icon/Expensicons';
+import useLocalize from '@hooks/useLocalize';
+import * as NumberFormatUtils from '@libs/NumberFormatUtils';
+import stylePropTypes from '@styles/stylePropTypes';
+import styles from '@styles/styles';
+import themeColors from '@styles/themes/default';
+
+const propTypes = {
+ /** Additional styles for container */
+ containerStyle: stylePropTypes,
+
+ /** The number of currently shown items */
+ currentCount: PropTypes.number,
+
+ /** The total number of items that could be shown */
+ totalCount: PropTypes.number,
+
+ /** A handler that fires when button has been pressed */
+ onPress: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ containerStyle: {},
+ currentCount: undefined,
+ totalCount: undefined,
+};
+
+function ShowMoreButton({containerStyle, currentCount, totalCount, onPress}) {
+ const {translate, preferredLocale} = useLocalize();
+
+ const shouldShowCounter = _.isNumber(currentCount) && _.isNumber(totalCount);
+
+ return (
+
+ {shouldShowCounter && (
+
+ {`${translate('common.showing')} `}
+ {currentCount}
+ {` ${translate('common.of')} `}
+ {NumberFormatUtils.format(preferredLocale, totalCount)}
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+ShowMoreButton.displayName = 'ShowMoreButton';
+ShowMoreButton.propTypes = propTypes;
+ShowMoreButton.defaultProps = defaultProps;
+
+export default ShowMoreButton;
diff --git a/src/components/SingleChoiceQuestion.tsx b/src/components/SingleChoiceQuestion.tsx
new file mode 100644
index 000000000000..07d4dfe817dd
--- /dev/null
+++ b/src/components/SingleChoiceQuestion.tsx
@@ -0,0 +1,39 @@
+import React, {ForwardedRef, forwardRef} from 'react';
+import {Text as RNText} from 'react-native';
+import useThemeStyles from '@styles/useThemeStyles';
+import FormHelpMessage from './FormHelpMessage';
+import RadioButtons, {Choice} from './RadioButtons';
+import Text from './Text';
+
+type SingleChoiceQuestionProps = {
+ prompt: string;
+ errorText?: string | string[];
+ possibleAnswers: Choice[];
+ currentQuestionIndex: number;
+ onInputChange: (value: string) => void;
+};
+
+function SingleChoiceQuestion({prompt, errorText, possibleAnswers, currentQuestionIndex, onInputChange}: SingleChoiceQuestionProps, ref: ForwardedRef) {
+ const styles = useThemeStyles();
+
+ return (
+ <>
+
+ {prompt}
+
+
+
+ >
+ );
+}
+
+SingleChoiceQuestion.displayName = 'SingleChoiceQuestion';
+
+export default forwardRef(SingleChoiceQuestion);
diff --git a/src/components/StatePicker/StateSelectorModal.js b/src/components/StatePicker/StateSelectorModal.js
index be899adec0a2..908bb5eb5b2a 100644
--- a/src/components/StatePicker/StateSelectorModal.js
+++ b/src/components/StatePicker/StateSelectorModal.js
@@ -101,6 +101,7 @@ function StateSelectorModal({currentState, isVisible, onClose, onStateSelected,
onChangeText={setSearchValue}
initiallyFocusedOptionKey={currentState}
shouldStopPropagation
+ shouldUseDynamicMaxToRenderPerBatch
/>
diff --git a/src/components/TagPicker/index.js b/src/components/TagPicker/index.js
index f9071aa5267d..78724718b2af 100644
--- a/src/components/TagPicker/index.js
+++ b/src/components/TagPicker/index.js
@@ -6,12 +6,13 @@ import OptionsSelector from '@components/OptionsSelector';
import useLocalize from '@hooks/useLocalize';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
+import * as StyleUtils from '@styles/StyleUtils';
import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {defaultProps, propTypes} from './tagPickerPropTypes';
-function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubmit, shouldShowDisabledAndSelectedOption}) {
+function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, shouldShowDisabledAndSelectedOption, insets, onSubmit}) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [searchValue, setSearchValue] = useState('');
@@ -57,6 +58,7 @@ function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubm
return (
void, config: KeyboardShortcutConfig | Record = {}) {
+export default function useKeyboardShortcut(shortcut: Shortcut, callback: (e?: GestureResponderEvent | KeyboardEvent) => void, config: KeyboardShortcutConfig | Record = {}) {
const {
captureOnInputs = true,
shouldBubble = false,
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 68af6ec2341d..d661ee1ad97b 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -270,6 +270,8 @@ export default {
selectCurrency: 'Select a currency',
card: 'Card',
required: 'Required',
+ showing: 'Showing',
+ of: 'of',
},
location: {
useCurrent: 'Use current location',
@@ -561,19 +563,19 @@ export default {
splitAmount: ({amount}: SplitAmountParams) => `split ${amount}`,
didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? ` for ${comment}` : ''}`,
amountEach: ({amount}: AmountEachParams) => `${amount} each`,
- payerOwesAmount: ({payer, amount}: PayerOwesAmountParams) => `${payer ? `${payer} ` : ''}owes ${amount}`,
+ payerOwesAmount: ({payer, amount}: PayerOwesAmountParams) => `${payer} owes ${amount}`,
payerOwes: ({payer}: PayerOwesParams) => `${payer} owes: `,
- payerPaidAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer ? `${payer} ` : ''}paid ${amount}`,
+ payerPaidAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer} paid ${amount}`,
payerPaid: ({payer}: PayerPaidParams) => `${payer} paid: `,
- payerSpentAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer ? `${payer} ` : ''}spent ${amount}`,
+ payerSpentAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer} spent ${amount}`,
payerSpent: ({payer}: PayerPaidParams) => `${payer} spent: `,
managerApproved: ({manager}: ManagerApprovedParams) => `${manager} approved:`,
payerSettled: ({amount}: PayerSettledParams) => `paid ${amount}`,
waitingOnBankAccount: ({submitterDisplayName}: WaitingOnBankAccountParams) => `started settling up, payment is held until ${submitterDisplayName} adds a bank account`,
settledAfterAddedBankAccount: ({submitterDisplayName, amount}: SettledAfterAddedBankAccountParams) =>
`${submitterDisplayName} added a bank account. The ${amount} payment has been made.`,
- paidElsewhereWithAmount: ({payer, amount}: PaidElsewhereWithAmountParams) => `${payer ? `${payer} ` : ''}paid ${amount} elsewhere`,
- paidWithExpensifyWithAmount: ({payer, amount}: PaidWithExpensifyWithAmountParams) => `${payer ? `${payer} ` : ''}paid ${amount} using Expensify`,
+ paidElsewhereWithAmount: ({payer, amount}: PaidElsewhereWithAmountParams) => `${payer} paid ${amount} elsewhere`,
+ paidWithExpensifyWithAmount: ({payer, amount}: PaidWithExpensifyWithAmountParams) => `${payer} paid ${amount} using Expensify`,
noReimbursableExpenses: 'This report has an invalid amount',
pendingConversionMessage: "Total will update when you're back online",
changedTheRequest: 'changed the request',
@@ -586,8 +588,8 @@ export default {
`changed the distance to ${newDistanceToDisplay} (previously ${oldDistanceToDisplay}), which updated the amount to ${newAmountToDisplay} (previously ${oldAmountToDisplay})`,
threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${formattedAmount} request${comment ? ` for ${comment}` : ''}`,
threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} sent${comment ? ` for ${comment}` : ''}`,
- tagSelection: ({tagName}: TagSelectionParams) => `Select a ${tagName} to add additional organization to your money`,
- categorySelection: 'Select a category to add additional organization to your money',
+ tagSelection: ({tagName}: TagSelectionParams) => `Select a ${tagName} to add additional organization to your money.`,
+ categorySelection: 'Select a category to add additional organization to your money.',
error: {
invalidAmount: 'Please enter a valid amount before continuing.',
invalidSplit: 'Split amounts do not equal total amount',
@@ -1944,25 +1946,31 @@ export default {
buttonText1: 'Start a chat, ',
buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`,
header: `Start a chat, get $${CONST.REFERRAL_PROGRAM.REVENUE}`,
- body: `Start a chat with a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`,
+ body1: `Start a chat with a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`,
},
[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST]: {
buttonText1: 'Request money, ',
buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`,
header: `Request money, get $${CONST.REFERRAL_PROGRAM.REVENUE}`,
- body: `Request money from a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`,
+ body1: `Request money from a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`,
},
[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]: {
buttonText1: 'Send money, ',
buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`,
header: `Send money, get $${CONST.REFERRAL_PROGRAM.REVENUE}`,
- body: `Send money to a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`,
+ body1: `Send money to a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`,
},
[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]: {
buttonText1: 'Refer a friend, ',
buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`,
header: `Refer a friend, get $${CONST.REFERRAL_PROGRAM.REVENUE}`,
- body: `Send your Expensify referral link to a friend or anyone else you know who spends too much time on expenses. When they start an annual subscription, you'll get $${CONST.REFERRAL_PROGRAM.REVENUE}.`,
+ body1: `Send your Expensify referral link to a friend or anyone else you know who spends too much time on expenses. When they start an annual subscription, you'll get $${CONST.REFERRAL_PROGRAM.REVENUE}.`,
+ },
+ [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE]: {
+ buttonText1: `Get $${CONST.REFERRAL_PROGRAM.REVENUE}`,
+ header: `Get $${CONST.REFERRAL_PROGRAM.REVENUE} for every referral`,
+ body1: 'If you know anyone who’s spending too much time on expenses (literally anyone – your neighbor, your boss, your friend in accounting), send them your Expensify referral link:',
+ body2: `When they start an annual subscription, you’ll get $${CONST.REFERRAL_PROGRAM.REVENUE}. Easy as that.`,
},
copyReferralLink: 'Copy referral link',
},
diff --git a/src/languages/es.ts b/src/languages/es.ts
index f298839b05b8..6ea01dc4bd14 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -260,6 +260,8 @@ export default {
selectCurrency: 'Selecciona una moneda',
card: 'Tarjeta',
required: 'Obligatorio',
+ showing: 'Mostrando',
+ of: 'de',
},
location: {
useCurrent: 'Usar ubicación actual',
@@ -553,19 +555,19 @@ export default {
splitAmount: ({amount}: SplitAmountParams) => `dividir ${amount}`,
didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `dividió ${formattedAmount}${comment ? ` para ${comment}` : ''}`,
amountEach: ({amount}: AmountEachParams) => `${amount} cada uno`,
- payerOwesAmount: ({payer, amount}: PayerOwesAmountParams) => `${payer ? `${payer} ` : ''}debe ${amount}`,
+ payerOwesAmount: ({payer, amount}: PayerOwesAmountParams) => `${payer} debe ${amount}`,
payerOwes: ({payer}: PayerOwesParams) => `${payer} debe: `,
- payerPaidAmount: ({payer, amount}: PayerPaidAmountParams) => `${payer ? `${payer} ` : ''}pagó ${amount}`,
+ payerPaidAmount: ({payer, amount}: PayerPaidAmountParams) => `${payer} pagó ${amount}`,
payerPaid: ({payer}: PayerPaidParams) => `${payer} pagó: `,
- payerSpentAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer ? `${payer} ` : ''}gastó ${amount}`,
+ payerSpentAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer} gastó ${amount}`,
payerSpent: ({payer}: PayerPaidParams) => `${payer} gastó: `,
managerApproved: ({manager}: ManagerApprovedParams) => `${manager} aprobó:`,
payerSettled: ({amount}: PayerSettledParams) => `pagó ${amount}`,
waitingOnBankAccount: ({submitterDisplayName}: WaitingOnBankAccountParams) => `inicio el pago, pero no se procesará hasta que ${submitterDisplayName} añada una cuenta bancaria`,
settledAfterAddedBankAccount: ({submitterDisplayName, amount}: SettledAfterAddedBankAccountParams) =>
`${submitterDisplayName} añadió una cuenta bancaria. El pago de ${amount} se ha realizado.`,
- paidElsewhereWithAmount: ({payer, amount}: PaidElsewhereWithAmountParams) => `${payer ? `${payer} ` : ''}pagó ${amount} de otra forma`,
- paidWithExpensifyWithAmount: ({payer, amount}: PaidWithExpensifyWithAmountParams) => `${payer ? `${payer} ` : ''}pagó ${amount} con Expensify`,
+ paidElsewhereWithAmount: ({payer, amount}: PaidElsewhereWithAmountParams) => `${payer} pagó ${amount} de otra forma`,
+ paidWithExpensifyWithAmount: ({payer, amount}: PaidWithExpensifyWithAmountParams) => `${payer} pagó ${amount} con Expensify`,
noReimbursableExpenses: 'El importe de este informe no es válido',
pendingConversionMessage: 'El total se actualizará cuando estés online',
changedTheRequest: 'cambió la solicitud',
@@ -580,8 +582,8 @@ export default {
`cambió la distancia a ${newDistanceToDisplay} (previamente ${oldDistanceToDisplay}), lo que cambió el importe a ${newAmountToDisplay} (previamente ${oldAmountToDisplay})`,
threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `Solicitud de ${formattedAmount}${comment ? ` para ${comment}` : ''}`,
threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} enviado${comment ? ` para ${comment}` : ''}`,
- tagSelection: ({tagName}: TagSelectionParams) => `Seleccione una ${tagName} para organizar mejor tu dinero`,
- categorySelection: 'Seleccione una categoría para organizar mejor tu dinero',
+ tagSelection: ({tagName}: TagSelectionParams) => `Seleccione una ${tagName} para organizar mejor tu dinero.`,
+ categorySelection: 'Seleccione una categoría para organizar mejor tu dinero.',
error: {
invalidAmount: 'Por favor ingresa un monto válido antes de continuar.',
invalidSplit: 'La suma de las partes no equivale al monto total',
@@ -2429,25 +2431,31 @@ export default {
buttonText1: 'Inicia un chat y ',
buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
header: `Inicia un chat y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
- body: `Inicia un chat con una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`,
+ body1: `Inicia un chat con una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`,
},
[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST]: {
buttonText1: 'Pide dinero, ',
buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
header: `Pide dinero y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
- body: `Pide dinero a una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`,
+ body1: `Pide dinero a una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`,
},
[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]: {
buttonText1: 'Envía dinero, ',
buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
header: `Envía dinero y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
- body: `Envía dinero a una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`,
+ body1: `Envía dinero a una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`,
},
[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]: {
buttonText1: 'Recomienda a un amigo y ',
buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
header: `Recomienda a un amigo y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
- body: `Envía tu enlace de invitación de Expensify a un amigo o a cualquier otra persona que conozcas que dedique demasiado tiempo a los gastos. Cuando comiencen una suscripción anual, obtendrás $${CONST.REFERRAL_PROGRAM.REVENUE}.`,
+ body1: `Envía tu enlace de invitación de Expensify a un amigo o a cualquier otra persona que conozcas que dedique demasiado tiempo a los gastos. Cuando comiencen una suscripción anual, obtendrás $${CONST.REFERRAL_PROGRAM.REVENUE}.`,
+ },
+ [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE]: {
+ buttonText1: `Recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
+ header: `Recibe $${CONST.REFERRAL_PROGRAM.REVENUE} por cada recomendación`,
+ body1: 'Si conoces a alguien que dedique demasiado tiempo a los gastos (literalmente cualquiera: tu vecino, tu jefe, tu amigo de contabilidad), envíale tu enlace de invitación de Expensify:',
+ body2: `Cuando comiencen una suscripción anual, obtendrás $${CONST.REFERRAL_PROGRAM.REVENUE}. Así de fácil.`,
},
copyReferralLink: 'Copiar enlace de invitación',
},
diff --git a/src/languages/types.ts b/src/languages/types.ts
index e2af3222a98f..a012ebdfb95b 100644
--- a/src/languages/types.ts
+++ b/src/languages/types.ts
@@ -111,17 +111,17 @@ type DidSplitAmountMessageParams = {formattedAmount: string; comment: string};
type AmountEachParams = {amount: number};
-type PayerOwesAmountParams = {payer: string; amount: number};
+type PayerOwesAmountParams = {payer: string; amount: number | string};
type PayerOwesParams = {payer: string};
-type PayerPaidAmountParams = {payer: string; amount: number};
+type PayerPaidAmountParams = {payer: string; amount: number | string};
type ManagerApprovedParams = {manager: string};
type PayerPaidParams = {payer: string};
-type PayerSettledParams = {amount: number};
+type PayerSettledParams = {amount: number | string};
type WaitingOnBankAccountParams = {submitterDisplayName: string};
diff --git a/src/libs/GroupChatUtils.ts b/src/libs/GroupChatUtils.ts
index dcb2b13f092c..db64f6574824 100644
--- a/src/libs/GroupChatUtils.ts
+++ b/src/libs/GroupChatUtils.ts
@@ -13,10 +13,11 @@ Onyx.connect({
/**
* Returns the report name if the report is a group chat
*/
-function getGroupChatName(report: Report): string {
+function getGroupChatName(report: Report): string | undefined {
const participants = report.participantAccountIDs ?? [];
const isMultipleParticipantReport = participants.length > 1;
const participantPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(participants, allPersonalDetails ?? {});
+ // @ts-expect-error Error will gone when OptionsListUtils will be migrated to Typescript
const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(participantPersonalDetails, isMultipleParticipantReport);
return ReportUtils.getDisplayNamesStringFromTooltips(displayNamesWithTooltips);
}
diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts
index ff4f2aafc8a8..afbbcc2684a0 100644
--- a/src/libs/IOUUtils.ts
+++ b/src/libs/IOUUtils.ts
@@ -1,3 +1,4 @@
+import {OnyxEntry} from 'react-native-onyx';
import CONST from '@src/CONST';
import {Report, Transaction} from '@src/types/onyx';
import * as CurrencyUtils from './CurrencyUtils';
@@ -35,8 +36,8 @@ function calculateAmount(numberOfParticipants: number, total: number, currency:
*
* @param isDeleting - whether the user is deleting the request
*/
-function updateIOUOwnerAndTotal(iouReport: Report, actorAccountID: number, amount: number, currency: string, isDeleting = false): Report {
- if (currency !== iouReport.currency) {
+function updateIOUOwnerAndTotal(iouReport: OnyxEntry, actorAccountID: number, amount: number, currency: string, isDeleting = false): OnyxEntry {
+ if (currency !== iouReport?.currency) {
return iouReport;
}
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index 14bee6e79776..c616587c3983 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -9,7 +9,6 @@ import _ from 'underscore';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import * as CollectionUtils from './CollectionUtils';
-import * as CurrencyUtils from './CurrencyUtils';
import * as ErrorUtils from './ErrorUtils';
import * as LocalePhoneNumber from './LocalePhoneNumber';
import * as Localize from './Localize';
@@ -373,40 +372,6 @@ function getAllReportErrors(report, reportActions) {
return allReportErrors;
}
-/**
- * Get the preview message to be displayed in the option list.
- *
- * @param {Object} report
- * @param {Object} reportAction
- * @param {Boolean} [isPreviewMessageForParentChatReport]
- * @returns {String}
- */
-function getReportPreviewMessageForOptionList(report, reportAction, isPreviewMessageForParentChatReport = false) {
- // For the request action preview we want to show the requestor instead of the user who owes the money
- if (!isPreviewMessageForParentChatReport && reportAction.originalMessage && reportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE) {
- const amount = Math.abs(reportAction.originalMessage.amount);
- const formattedAmount = CurrencyUtils.convertToDisplayString(amount, report.currency);
- const shouldShowActorName = currentUserAccountID !== reportAction.actorAccountID;
- const actorDisplayName = shouldShowActorName ? `${ReportUtils.getDisplayNameForParticipant(reportAction.actorAccountID, true)}: ` : '';
-
- return `${actorDisplayName}${Localize.translateLocal('iou.requestedAmount', {formattedAmount})}`;
- }
-
- const shouldShowWorkspaceName = ReportUtils.isExpenseReport(report) && isPreviewMessageForParentChatReport;
- const actorID = report.managerID || reportAction.actorAccountID;
- const actor = ReportUtils.getActorNameForPreviewMessage({
- report,
- shouldShowWorkspaceName,
- actorID,
- shouldUseShortForm: !isPreviewMessageForParentChatReport,
- });
- const shouldShowActorName = shouldShowWorkspaceName || isPreviewMessageForParentChatReport || currentUserAccountID !== actorID;
- const actorDisplayName = shouldShowActorName && actor ? `${actor}${isPreviewMessageForParentChatReport ? ' ' : ': '}` : '';
- const message = ReportUtils.getReportPreviewMessage(report, reportAction, true, isPreviewMessageForParentChatReport, true);
-
- return `${actorDisplayName}${message}`;
-}
-
/**
* Get the last message text from the report directly or from other sources for special cases.
* @param {Object} report
@@ -418,7 +383,7 @@ function getLastMessageTextForReport(report) {
const lastActionName = lodashGet(lastReportAction, 'actionName', '');
if (ReportActionUtils.isMoneyRequestAction(lastReportAction)) {
- const properSchemaForMoneyRequestMessage = getReportPreviewMessageForOptionList(report, lastReportAction, false);
+ const properSchemaForMoneyRequestMessage = ReportUtils.getReportPreviewMessage(report, lastReportAction, true);
lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForMoneyRequestMessage);
} else if (ReportActionUtils.isReportPreviewAction(lastReportAction)) {
const iouReport = ReportUtils.getReport(ReportActionUtils.getIOUReportIDFromReportActionPreview(lastReportAction));
@@ -429,7 +394,7 @@ function getLastMessageTextForReport(report) {
reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE &&
ReportActionUtils.isMoneyRequestAction(reportAction),
);
- lastMessageTextFromReport = getReportPreviewMessageForOptionList(iouReport, lastIOUMoneyReport, ReportUtils.isChatReport(report));
+ lastMessageTextFromReport = ReportUtils.getReportPreviewMessage(iouReport, lastIOUMoneyReport, true, ReportUtils.isChatReport(report));
} else if (ReportActionUtils.isReimbursementQueuedAction(lastReportAction)) {
lastMessageTextFromReport = ReportUtils.getReimbursementQueuedActionMessage(lastReportAction, report);
} else if (ReportActionUtils.isDeletedParentAction(lastReportAction) && ReportUtils.isChatReport(report)) {
@@ -446,10 +411,7 @@ function getLastMessageTextForReport(report) {
) {
lastMessageTextFromReport = lodashGet(lastReportAction, 'message[0].text', '');
} else {
- const shouldShowLastActor =
- ReportUtils.isThread(report) && (ReportUtils.isExpenseReport(report) || ReportUtils.isIOUReport(report)) && currentUserAccountID !== report.lastActorAccountID;
- const lastActorDisplayName = shouldShowLastActor ? `${ReportUtils.getDisplayNameForParticipant(report.lastActorAccountID, true)}: ` : '';
- lastMessageTextFromReport = report ? `${lastActorDisplayName}${report.lastMessageText}` : '';
+ lastMessageTextFromReport = report ? report.lastMessageText || '' : '';
}
return lastMessageTextFromReport;
}
@@ -948,24 +910,19 @@ function getCategoryListSections(categories, recentlyUsedCategories, selectedOpt
* @returns {Array