diff --git a/.env.example b/.env.example
index bed835645756..944da2aa9296 100644
--- a/.env.example
+++ b/.env.example
@@ -11,7 +11,6 @@ USE_WEB_PROXY=false
USE_WDYR=false
CAPTURE_METRICS=false
ONYX_METRICS=false
-GOOGLE_GEOLOCATION_API_KEY=AIzaSyBqg6bMvQU7cPWDKhhzpYqJrTEnSorpiLI
EXPENSIFY_ACCOUNT_ID_ACCOUNTING=-1
EXPENSIFY_ACCOUNT_ID_ADMIN=-1
diff --git a/.env.production b/.env.production
index 4b0a98e77557..5e676134d681 100644
--- a/.env.production
+++ b/.env.production
@@ -7,4 +7,3 @@ PUSHER_APP_KEY=268df511a204fbb60884
USE_WEB_PROXY=false
ENVIRONMENT=production
SEND_CRASH_REPORTS=true
-GOOGLE_GEOLOCATION_API_KEY=AIzaSyBFKujMpzExz0_z2pAGfPUwkmlaUc-uw1Q
diff --git a/.env.staging b/.env.staging
index 1b3ec15fc172..17d82ac2d136 100644
--- a/.env.staging
+++ b/.env.staging
@@ -6,5 +6,4 @@ EXPENSIFY_PARTNER_PASSWORD=e21965746fd75f82bb66
PUSHER_APP_KEY=268df511a204fbb60884
USE_WEB_PROXY=false
ENVIRONMENT=staging
-SEND_CRASH_REPORTS=true
-GOOGLE_GEOLOCATION_API_KEY=AIzaSyD2T1mlByThbUN88O8OPOD8vKuMMwLD4-M
\ No newline at end of file
+SEND_CRASH_REPORTS=true
\ No newline at end of file
diff --git a/.eslintrc.js b/.eslintrc.js
index 135252825dcf..ed6f162ad8d4 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -235,8 +235,21 @@ module.exports = {
],
},
- // Remove once no JS files are left
overrides: [
+ // Enforces every Onyx type and its properties to have a comment explaining its purpose.
+ {
+ files: ['src/types/onyx/**/*.ts'],
+ rules: {
+ 'jsdoc/require-jsdoc': [
+ 'error',
+ {
+ contexts: ['TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSPropertySignature'],
+ },
+ ],
+ },
+ },
+
+ // Remove once no JS files are left
{
files: ['*.js', '*.jsx'],
rules: {
diff --git a/.github/scripts/printPodspec.rb b/.github/scripts/printPodspec.rb
new file mode 100755
index 000000000000..80012edbc0aa
--- /dev/null
+++ b/.github/scripts/printPodspec.rb
@@ -0,0 +1,35 @@
+#!/usr/bin/env ruby
+
+# This file is a lightweight port of the `pod ipc spec` command.
+# It was built from scratch to imports some 3rd party functions before reading podspecs
+
+require 'cocoapods'
+require 'json'
+
+# Require 3rd party functions needed to parse podspecs. This code is copied from ios/Podfile
+def node_require(script)
+ # Resolve script with node to allow for hoisting
+ require Pod::Executable.execute_command('node', ['-p',
+ "require.resolve(
+ '#{script}',
+ {paths: [process.argv[1]]},
+ )", __dir__]).strip
+end
+node_require('react-native/scripts/react_native_pods.rb')
+node_require('react-native-permissions/scripts/setup.rb')
+
+# Configure pod in silent mode
+Pod::Config.instance.silent = true
+
+# Process command-line arguments
+podspec_files = ARGV
+
+# Validate each podspec file
+podspec_files.each do |podspec_file|
+ begin
+ spec = Pod::Specification.from_file(podspec_file)
+ puts(spec.to_pretty_json)
+ rescue => e
+ STDERR.puts "Failed to validate #{podspec_file}: #{e.message}"
+ end
+end
diff --git a/.github/scripts/verifyPodfile.sh b/.github/scripts/verifyPodfile.sh
index cd94a49bb091..0d04d8f1b3ed 100755
--- a/.github/scripts/verifyPodfile.sh
+++ b/.github/scripts/verifyPodfile.sh
@@ -8,7 +8,12 @@ source scripts/shellUtils.sh
title "Verifying that Podfile.lock is synced with the project"
-declare EXIT_CODE=0
+# Cleanup and exit
+# param - status code
+function cleanupAndExit {
+ cd "$START_DIR" || exit 1
+ exit "$1"
+}
# Check Provisioning Style. If automatic signing is enabled, iOS builds will fail, so ensure we always have the proper profile specified
info "Verifying that automatic signing is not enabled"
@@ -16,7 +21,7 @@ if grep -q 'PROVISIONING_PROFILE_SPECIFIER = "(NewApp) AppStore"' ios/NewExpensi
success "Automatic signing not enabled"
else
error "Error: Automatic provisioning style is not allowed!"
- EXIT_CODE=1
+ cleanupAndExit 1
fi
PODFILE_SHA=$(openssl sha1 ios/Podfile | awk '{print $2}')
@@ -29,7 +34,7 @@ if [[ "$PODFILE_SHA" == "$PODFILE_LOCK_SHA" ]]; then
success "Podfile checksum verified!"
else
error "Podfile.lock checksum mismatch. Did you forget to run \`npx pod-install\`?"
- EXIT_CODE=1
+ cleanupAndExit 1
fi
info "Ensuring correct version of cocoapods is used..."
@@ -45,29 +50,36 @@ if [[ "$POD_VERSION_FROM_GEMFILE" == "$POD_VERSION_FROM_PODFILE_LOCK" ]]; then
success "Cocoapods version from Podfile.lock matches cocoapods version from Gemfile"
else
error "Cocoapods version from Podfile.lock does not match cocoapods version from Gemfile. Please use \`npm run pod-install\` or \`bundle exec pod install\` instead of \`pod install\` to install pods."
- EXIT_CODE=1
+ cleanupAndExit 1
fi
info "Comparing Podfile.lock with node packages..."
# Retrieve a list of podspec directories as listed in the Podfile.lock
-SPEC_DIRS=$(yq '.["EXTERNAL SOURCES"].[].":path" | select( . == "*node_modules*")' < ios/Podfile.lock)
+if ! SPEC_DIRS=$(yq '.["EXTERNAL SOURCES"].[].":path" | select( . == "*node_modules*")' < ios/Podfile.lock); then
+ error "Error: Could not parse podspec directories from Podfile.lock"
+ cleanupAndExit 1
+fi
+
+if ! read_lines_into_array PODSPEC_PATHS < <(npx react-native config | jq --raw-output '.dependencies[].platforms.ios.podspecPath | select ( . != null)'); then
+ error "Error: could not parse podspec paths from react-native config command"
+ cleanupAndExit 1
+fi
# Format a list of Pods based on the output of the config command
-FORMATTED_PODS=$( \
- jq --raw-output --slurp 'map((.name + " (" + .version + ")")) | .[]' <<< "$( \
- npx react-native config | \
- jq '.dependencies[].platforms.ios.podspecPath | select( . != null )' | \
- xargs -L 1 pod ipc spec --silent
- )"
-)
+if ! FORMATTED_PODS=$( \
+ jq --raw-output --slurp 'map((.name + " (" + .version + ")")) | .[]' <<< "$(./.github/scripts/printPodspec.rb "${PODSPEC_PATHS[@]}")" \
+); then
+ error "Error: could not parse podspecs at paths parsed from react-native config"
+ cleanupAndExit 1
+fi
# Check for uncommitted package removals
# If they are listed in Podfile.lock but the directories don't exist they have been removed
while read -r DIR; do
if [[ ! -d "${DIR#../}" ]]; then
error "Directory \`${DIR#../node_modules/}\` not found in node_modules. Did you forget to run \`npx pod-install\` after removing the package?"
- EXIT_CODE=1
+ cleanupAndExit 1
fi
done <<< "$SPEC_DIRS"
@@ -75,15 +87,9 @@ done <<< "$SPEC_DIRS"
while read -r POD; do
if ! grep -q "$POD" ./ios/Podfile.lock; then
error "$POD not found in Podfile.lock. Did you forget to run \`npx pod-install\`?"
- EXIT_CODE=1
+ cleanupAndExit 1
fi
done <<< "$FORMATTED_PODS"
-if [[ "$EXIT_CODE" == 0 ]]; then
- success "Podfile.lock is up to date."
-fi
-
-# Cleanup
-cd "$START_DIR" || exit 1
-
-exit $EXIT_CODE
+success "Podfile.lock is up to date."
+cleanupAndExit 0
diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml
index 353a898a941f..4ad6d54e2f24 100644
--- a/.github/workflows/platformDeploy.yml
+++ b/.github/workflows/platformDeploy.yml
@@ -366,6 +366,17 @@ jobs:
with:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
+ # Build a version of iOS and Android HybridApp if we are deploying to staging
+ hybridApp:
+ runs-on: ubuntu-latest
+ needs: validateActor
+ if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) && github.event_name == 'push' }}
+ steps:
+ - name: 'Deploy HybridApp'
+ run: gh workflow run --repo Expensify/Mobile-Deploy deploy.yml -f force_build=true
+ env:
+ GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
+
postSlackMessageOnSuccess:
name: Post a Slack message when all platforms deploy successfully
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index aeee5f730bfc..dcbec8a96e46 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,6 +37,11 @@ local.properties
android/app/src/main/java/com/expensify/chat/generated/
.cxx/
+# VIM
+*.swp
+*.swo
+*~
+
# Vscode
.vscode
diff --git a/.nvmrc b/.nvmrc
index 62d44807d084..48b14e6b2b56 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-20.13.0
+20.14.0
diff --git a/android/app/build.gradle b/android/app/build.gradle
index fb2791852d51..9343423c9f14 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -107,8 +107,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001047803
- versionName "1.4.78-3"
+ versionCode 1001048016
+ versionName "1.4.80-16"
// Supported language variants must be declared here to avoid from being removed during the compilation.
// This also helps us to not include unnecessary language variants in the APK.
resConfigs "en", "es"
diff --git a/assets/images/bed.svg b/assets/images/bed.svg
new file mode 100644
index 000000000000..fd654c036a7c
--- /dev/null
+++ b/assets/images/bed.svg
@@ -0,0 +1 @@
+
diff --git a/assets/images/car-with-key.svg b/assets/images/car-with-key.svg
new file mode 100644
index 000000000000..1586c0dfecfa
--- /dev/null
+++ b/assets/images/car-with-key.svg
@@ -0,0 +1 @@
+
diff --git a/assets/images/check-circle.svg b/assets/images/check-circle.svg
new file mode 100644
index 000000000000..c13b83cbf281
--- /dev/null
+++ b/assets/images/check-circle.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/assets/images/checkmark-circle.svg b/assets/images/checkmark-circle.svg
new file mode 100644
index 000000000000..3497548bc1bc
--- /dev/null
+++ b/assets/images/checkmark-circle.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/images/credit-card-exclamation.svg b/assets/images/credit-card-exclamation.svg
new file mode 100644
index 000000000000..67e686516baa
--- /dev/null
+++ b/assets/images/credit-card-exclamation.svg
@@ -0,0 +1,14 @@
+
+
+
diff --git a/assets/images/crosshair.svg b/assets/images/crosshair.svg
new file mode 100644
index 000000000000..357faab49178
--- /dev/null
+++ b/assets/images/crosshair.svg
@@ -0,0 +1,23 @@
+
+
+
diff --git a/assets/images/emptystate__routepending.svg b/assets/images/emptystate__routepending.svg
index 90f3296d37d6..aba08554d02f 100644
--- a/assets/images/emptystate__routepending.svg
+++ b/assets/images/emptystate__routepending.svg
@@ -1 +1,18 @@
-
\ No newline at end of file
+
+
+
diff --git a/assets/images/inbox.svg b/assets/images/inbox.svg
new file mode 100644
index 000000000000..f9059e78ec5a
--- /dev/null
+++ b/assets/images/inbox.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/assets/images/money-search.svg b/assets/images/money-search.svg
new file mode 100644
index 000000000000..90dedae0a2fb
--- /dev/null
+++ b/assets/images/money-search.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/assets/images/plane.svg b/assets/images/plane.svg
new file mode 100644
index 000000000000..bf4d56875239
--- /dev/null
+++ b/assets/images/plane.svg
@@ -0,0 +1 @@
+
diff --git a/assets/images/product-illustrations/emptystate__travel.svg b/assets/images/product-illustrations/emptystate__travel.svg
new file mode 100644
index 000000000000..416b27eb5bee
--- /dev/null
+++ b/assets/images/product-illustrations/emptystate__travel.svg
@@ -0,0 +1,575 @@
+
+
+
diff --git a/assets/images/receipt-slash.svg b/assets/images/receipt-slash.svg
new file mode 100644
index 000000000000..2af3fcbc60e6
--- /dev/null
+++ b/assets/images/receipt-slash.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/assets/images/simple-illustrations/simple-illustration__checkmarkcircle.svg b/assets/images/simple-illustrations/simple-illustration__checkmarkcircle.svg
new file mode 100644
index 000000000000..a96a7e5dc0af
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__checkmarkcircle.svg
@@ -0,0 +1,21 @@
+
\ No newline at end of file
diff --git a/assets/images/simple-illustrations/simple-illustration__sendmoney.svg b/assets/images/simple-illustrations/simple-illustration__sendmoney.svg
new file mode 100644
index 000000000000..80393e3c30cf
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__sendmoney.svg
@@ -0,0 +1,72 @@
+
\ No newline at end of file
diff --git a/assets/images/simple-illustrations/simple-illustration__subscription-annual.svg b/assets/images/simple-illustrations/simple-illustration__subscription-annual.svg
new file mode 100644
index 000000000000..e158bc5588cb
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__subscription-annual.svg
@@ -0,0 +1,82 @@
+
+
+
diff --git a/assets/images/simple-illustrations/simple-illustration__subscription-ppu.svg b/assets/images/simple-illustrations/simple-illustration__subscription-ppu.svg
new file mode 100644
index 000000000000..d70d2d1ef552
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__subscription-ppu.svg
@@ -0,0 +1,27 @@
+
+
+
diff --git a/assets/images/subscription-details__approvedlogo--light.svg b/assets/images/subscription-details__approvedlogo--light.svg
new file mode 100644
index 000000000000..580ee60c597c
--- /dev/null
+++ b/assets/images/subscription-details__approvedlogo--light.svg
@@ -0,0 +1,91 @@
+
+
+
diff --git a/assets/images/subscription-details__approvedlogo.svg b/assets/images/subscription-details__approvedlogo.svg
new file mode 100644
index 000000000000..7722e2526657
--- /dev/null
+++ b/assets/images/subscription-details__approvedlogo.svg
@@ -0,0 +1,94 @@
+
+
+
diff --git a/docs/README.md b/docs/README.md
index 224abe0554a5..235334c95732 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -186,9 +186,18 @@ Just update the content for each variable accordingly or remove it if the inform
Assume that we want to rename the article `The Free Plan` to `Freemium Features` for the hub `billing and plan types` in New Expensify platform.
1. Go to `docs/articles/new-expensify/billing-and-plan-types`
2. Rename `The-Free-Plan.md` to `Freemium-Features.md`. Use dashes for spaces in the file name.
+3. Add an entry in redirects.csv for the old article pointing to the new article.
Note: It is important that the file has `.md` extension.
+# How to hide an article temporarily
+Video demo available [here 🧵](https://expensify.slack.com/archives/C02NK2DQWUX/p1717772272605829?thread_ts=1717523271.137469&cid=C02NK2DQWUX).
+1. Open github's in built code editor by pressing `.` on your keyboard. ([instructions here](https://docs.github.com/en/codespaces/the-githubdev-web-based-editor#opening-the-githubdev-editor))
+2. Go to the article that you want to hide `docs/articles/...`.
+3. Drag and drop the article inside the hidden folder `docs/Hidden/`.
+4. Add a redirect for it in `docs/redirects.csv` to ensure that we don't have broken links. You can choose to point it to the home page or the article's hub.
+5. Commit the changes and raise a PR.
+
# How the site is deployed
This site is hosted on Cloudflare pages. Whenever code is merged to main, the github action `deployExpensifyHelp` will run.
diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss
index ae19775d75df..eb59388159bf 100644
--- a/docs/_sass/_main.scss
+++ b/docs/_sass/_main.scss
@@ -941,8 +941,8 @@ button {
}
#platform-tabs > .active {
- color: var(--color-button-text);
- background-color: var(--color-button-success-background);
+ color: var(--color-text);
+ background-color: var(--color-button-background);
}
.hidden {
diff --git a/docs/articles/expensify-classic/reports/Add-custom-fields-to-reports-and-invoices.md b/docs/articles/expensify-classic/reports/Add-custom-fields-to-reports-and-invoices.md
new file mode 100644
index 000000000000..ce0f60d3be56
--- /dev/null
+++ b/docs/articles/expensify-classic/reports/Add-custom-fields-to-reports-and-invoices.md
@@ -0,0 +1,27 @@
+---
+title: Add custom fields to reports and invoices
+description: Customize the fields that appear on a report or an invoice
+---
+
+
+Workspace Admins can add additional required fields to a report to include selections for project names, locations, trip information, and more.
+
+{% include info.html %}
+You cannot create these report fields directly in Expensify if you are connected to an accounting integration (QuickBooks Online, QuickBooks Desktop, Intacct, Xero, or NetSuite). Please refer to the relevant article for instructions on creating fields within that system.
+{% include end-info.html %}
+
+To create a custom field for a report,
+
+1. Hover over Settings and select **Workspaces**.
+2. Select the desired workspace.
+3. Click the **Reports** tab on the left.
+4. Scroll down to the Report and Invoice Fields section.
+5. Under Add New Field, enter a Field Title.
+6. Click the dropdown for the Type field and select the desired selection method:
+ - **Text**: Provides a text box to type in the requested information.
+ - **Dropdown**: Provides a dropdown of options to choose from.
+ - **Date**: Opens a calendar to select a date.
+7. Select the report type: **Expense Report** or **Invoice**.
+8. Click **Add**.
+
+
diff --git a/docs/articles/expensify-classic/reports/Set-default-report-title.md b/docs/articles/expensify-classic/reports/Set-default-report-title.md
new file mode 100644
index 000000000000..a103ad8d5e5a
--- /dev/null
+++ b/docs/articles/expensify-classic/reports/Set-default-report-title.md
@@ -0,0 +1,17 @@
+---
+title: Set default report title
+description: Set an automatic title for all reports
+---
+
+
+Workspace Admins can set a default report title for all reports created under a specific workspace. If desired, these titles can also be enforced to prevent employees from changing them.
+
+1. Hover over Settings and select **Workspaces**.
+2. Select the desired workspace.
+3. Click the **Reports** tab on the left.
+4. Scroll down to the Default Report Title section.
+5. Configure the formula. You can use the example provided on the page as a guide or choose from more [report formula options](https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates).
+ - Some formulas will automatically update the report title as changes are made to the report. For example, any formula related to dates, total amounts, workspace name, would adjust the title before the report is submitted for approval. Changes will not retroactively update report titles for reports which have been Approved or Reimbursed.
+6. If desired, enable the Enforce Default Report Title toggle. This will prevent employees from editing the default title.
+
+
diff --git a/docs/articles/expensify-classic/travel/Approve-travel-expenses.md b/docs/articles/expensify-classic/travel/Approve-travel-expenses.md
new file mode 100644
index 000000000000..ae0feb4efaa6
--- /dev/null
+++ b/docs/articles/expensify-classic/travel/Approve-travel-expenses.md
@@ -0,0 +1,37 @@
+---
+title: Approve travel expenses
+description: Determine how travel expenses are approved
+---
+
+
+Travel expenses follow the same approval workflow as other expenses. Admins can configure travel expenses to be approved as soft approval, hard approval or passive approval. The approval method for in-policy and out-of-policy bookings can be managed under the **Policies** section in the **Program** menu for Expensify Travel.
+
+- **Soft Approval**: Bookings are automatically approved as long as a manager does not decline them within 24 hours. However, this also means that if a manager does not decline the expenses, the arrangements will be booked even if they are out of policy. If a booking is declined, it is refunded based on the voiding/refund terms of the service provider.
+- **Hard Approval**: Bookings are automatically canceled/voided and refunded if a manager does not approve them within 24 hours.
+- **Passive Approval**: Managers are informed of out-of-policy travel, but there is no action to be taken.
+
+# Set approval method
+
+1. Click the **Travel** tab.
+2. Click **Book or manage travel**.
+3. Click the **Program** tab at the top and select **Policies**.
+4. Under General, select approval methods for Flights, Hotels, Cars and Rail.
+
+
+
+
+
+Travel expenses follow the same approval workflow as other expenses. Admins can configure travel expenses to be approved as soft approval, hard approval or passive approval. The approval method for in-policy and out-of-policy bookings can be managed under the **Policies** section in the **Program** menu for Expensify Travel.
+
+- **Soft Approval**: Bookings are automatically approved as long as a manager does not decline them within 24 hours. However, this also means that if a manager does not decline the expenses, the arrangements will be booked even if they are out of policy. If a booking is declined, it is refunded based on the voiding/refund terms of the service provider.
+- **Hard Approval**: Bookings are automatically canceled/voided and refunded if a manager does not approve them within 24 hours.
+- **Passive Approval**: Managers are informed of out-of-policy travel, but there is no action to be taken.
+
+# Set approval method
+
+1. Click the + icon in the bottom left menu and select **Book travel**.
+2. Click **Book or manage travel**.
+3. Click the **Program** tab at the top and select **Policies**.
+4. Under General, select approval methods for Flights, Hotels, Cars and Rail.
+
+
diff --git a/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md b/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md
new file mode 100644
index 000000000000..5d25670ac5ab
--- /dev/null
+++ b/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md
@@ -0,0 +1,89 @@
+---
+title: Book with Expensify Travel
+description: Book flights, hotels, cars, trains, and more with Expensify Travel
+---
+
+
+Expensify Travel allows members to search and book flights, hotels, cars, and trains globally at the most competitive rates available.
+
+With Expensify Travel, you can:
+- Search and book travel arrangements all in one place
+- Book travel for yourself or for someone else
+- Get real-time support by chat or phone
+- Manage all your T&E expenses in Expensify
+- Create specific rules for booking travel
+- Enable approvals for out-of-policy trips
+- Book with any credit card on the market
+- Book with the Expensify Card to get cash back and automatically reconcile transactions
+
+There is a flat fee of $15 per trip booked. A single trip can include multiple bookings, such as a flight, a hotel, and a car rental.
+
+# Book travel
+
+To book travel from the Expensify web app,
+
+1. Click the **Travel** tab.
+2. Click **Book or manage travel**.
+3. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains.
+4. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.).
+5. Select all the details for the arrangement you want to book.
+6. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking.
+
+The traveler is emailed an itinerary of the booking. Additionally,
+- Their travel details are added to a Trip chat room under their primary workspace.
+- An expense report for the trip is created.
+- If booked with an Expensify Card, the trip is automatically reconciled.
+
+{% include info.html %}
+The travel itinerary is also emailed to the traveler’s [copilots](https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot), if applicable.
+{% include end-info.html %}
+
+
+
+
+Expensify Travel allows members to search and book flights, hotels, cars, and trains globally at the most competitive rates available.
+
+With Expensify Travel, you can:
+- Search and book travel arrangements all in one place
+- Book travel for yourself or for someone else
+- Get real-time support by chat or phone
+- Manage all your T&E expenses in Expensify
+- Create specific rules for booking travel
+- Enable approvals for out-of-policy trips
+- Book with any credit card on the market
+- Book with the Expensify Card to get cash back and automatically reconcile transactions
+
+There is a flat fee of $15 per trip booked. A single trip can include multiple bookings, such as a flight, a hotel, and a car rental.
+
+# Book travel
+
+{% include selector.html values="desktop, mobile" %}
+
+{% include option.html value="desktop" %}
+1. Click the + icon in the bottom left menu and select **Book travel**.
+2. Click **Book or manage travel**.
+3. Agree to the terms and conditions and click **Continue**.
+4. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains.
+5. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.).
+6. Select all the details for the arrangement you want to book.
+7. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking.
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+1. Tap the + icon in the bottom menu and select **Book travel**.
+2. Tap **Book or manage travel**.
+3. Agree to the terms and conditions and tap **Continue**.
+4. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains.
+5. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.).
+6. Select all the details for the arrangement you want to book.
+7. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking.
+{% include end-option.html %}
+
+{% include end-selector.html %}
+
+The traveler is emailed an itinerary of the booking. Additionally,
+- Their travel details are added to a Trip chat room under their primary workspace.
+- An expense report for the trip is created.
+- If booked with an Expensify Card, the trip is automatically reconciled.
+
+
diff --git a/docs/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md b/docs/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md
new file mode 100644
index 000000000000..2e17af06773c
--- /dev/null
+++ b/docs/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md
@@ -0,0 +1,65 @@
+---
+title: Configure travel policy and preferences
+description: Set and update travel policies and preferences for your Expensify Workspace
+---
+
+
+As a Workspace Admin, you can set travel policies for all travel booked under your workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees.
+
+# Create a travel policy
+
+When using Expensify Travel for the first time, you will need to create a new Travel Policy.
+
+To create a travel policy from the Expensify web app,
+
+1. Click the **Travel** tab.
+2. Click **Book or manage travel**.
+3. Click the **Program** tab at the top and select **Policies**.
+4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy.
+5. Use the **Edit members** section to select the group of employees that belong to this policy. A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace.
+6. Select which travel preferences you want to modify: General, flight, hotel, car, or rail.
+7. Click the paperclip icon next to each setting to de-couple it from your default policy.
+8. Update the desired settings.
+
+# Update travel policy preferences
+
+1. Click the **Travel** tab.
+2. Click **Book or manage travel**.
+3. Click the **Program** tab at the top and select **Policies**.
+4. Select the appropriate policy in the left menu.
+5. Select which travel preferences you want to modify: General, flight, hotel, car, or rail.
+6. Click the paperclip icon next to each setting to de-couple it from your default policy.
+7. Update the desired policies.
+
+
+
+
+
+As a Workspace Admin, you can set travel policies for all travel booked under your workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees.
+
+# Create a travel policy
+
+When using Expensify Travel for the first time, you will need to create a new Travel Policy.
+
+To create a travel policy from the Expensify web app,
+
+1. Click the + icon in the bottom left menu and select **Book travel**.
+2. Click **Book or manage travel**.
+3. Click the **Program** tab at the top and select **Policies**.
+4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy.
+5. Use the **Edit members** section to select the group of employees that belong to this policy. A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace.
+6. Select which travel preferences you want to modify: General, flight, hotel, car, or rail.
+7. Click the paperclip icon next to each setting to de-couple it from your default policy.
+8. Update the desired settings.
+
+# Update travel policy preferences
+
+1. Click the + icon in the bottom left menu and select **Book travel**.
+2. Click **Book or manage travel**.
+3. Click the **Program** tab at the top and select **Policies**.
+4. Select the appropriate policy in the left menu.
+5. Select which travel preferences you want to modify: General, flight, hotel, car, or rail.
+6. Click the paperclip icon next to each setting to de-couple it from your default policy.
+7. Update the desired policies.
+
+
diff --git a/docs/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements.md b/docs/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements.md
new file mode 100644
index 000000000000..7dc71c3220ca
--- /dev/null
+++ b/docs/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements.md
@@ -0,0 +1,28 @@
+---
+title: Edit or cancel travel arrangements
+description: Modify travel arrangements booked with Expensify Travel
+---
+
+
+Click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee.
+
+
+
+
+
+You can review your travel arrangements any time by opening the Trip chat in your inbox. For example, if you booked a flight to San Francisco, a “Trip to San Francisco” chat will be automatically added to your chat inbox.
+
+To edit or cancel a travel arrangement,
+1. Click your profile image or icon in the bottom left menu.
+2. Scroll down and click **Workspaces** in the left menu.
+3. Select the workspace the travel is booked under.
+4. Tap into the booking to see more details.
+5. Click **Trip Support**.
+
+If there is an unexpected change to the itinerary (for example, a flight cancellation), Expensify’s travel partner **Spotnana** will reach out to the traveler to provide updates on those changes.
+
+{% include info.html %}
+You can click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee.
+{% include end-info.html %}
+
+
diff --git a/docs/articles/expensify-classic/workspaces/reports/Report-Fields-And-Titles.md b/docs/articles/expensify-classic/workspaces/reports/Report-Fields-And-Titles.md
deleted file mode 100644
index e79e30ce42c9..000000000000
--- a/docs/articles/expensify-classic/workspaces/reports/Report-Fields-And-Titles.md
+++ /dev/null
@@ -1,43 +0,0 @@
----
-title: Report Fields & Titles
-description: This article is about managing Report Fields and Report Titles in Expensify
----
-# Overview
-
-In this article, we'll go over how to use Report Titles and Report Fields.
-
-## How to use Report Titles
-
-Default report titles enable group workspace admins or individual workspace users to establish a standardized title format for reports associated with a specific workspace. Additionally, admins have the choice to enforce these report titles, preventing employees from altering them. This ensures uniformity in report naming conventions during the review process, eliminating the need for employees to manually input titles for each report they generate.
-
-- Group workspace admins can set the Default Report Title from **Settings > Workspaces > Group > *[Workspace Name]* > Reports**.
-- Individual users can set the Default Report Title from **Settings > Workspaces > Individual > *[Workspace Name]* > Reports**.
-
-You can configure the title by using the formulas that we provide to populate the Report Title. Take a look at the help article on Custom Formulas to find all eligible formulas for your Report Titles.
-
-## Deep Dive on Report Titles
-
-Some formulas will automatically update the report title as changes are made to the report. For example, any formula related to dates, total amounts, workspace name, would adjust the title before the report is submitted for approval. Changes will not retroactively update report titles for reports which have been Approved or Reimbursed.
-
-To prevent report title editing by employees, simply enable "Enforce Default Report Title."
-
-## How to use Report Fields
-
-Report fields let you specify header-level details, distinct from tags which pertain to expenses on individual line items. These details can encompass specific project names, business trip information, locations, and more. Customize them according to your workspace's requirements.
-
-To set up Report Fields, follow these steps:
-- Workspace Admins can create report fields for group workspaces from **Settings > Workspaces > Group > *[Workspace Name]* > Reports > Report and Invoice Fields**. For individual workspaces, follow **Settings > Workspaces > Individual > *[Workspace Name]* > Reports > Report and Invoice Fields**.
-- Under "Add New Field," enter the desired field name in the "Field Title" to describe the type of information to be selected.
-- Choose the appropriate input method under "Type":
- - Text: Provides users with a free-text box to enter the requested information.
- - Dropdown: Creates a selection of options for users to choose from.
- - Date: Displays a clickable box that opens a calendar for users to select a date.
-
-## Deep Dive on Report Fields
-
-You cannot create these report fields directly in Expensify if you are connected to an accounting integration (QuickBooks Online, QuickBooks Desktop, Intacct, Xero, or NetSuite). Please refer to the relevant article for instructions on creating fields within that system.
-
-When report fields are configured on a workspace, they become mandatory information for associated reports. Leaving a report field empty or unselected will trigger a report violation, potentially blocking report submission or export.
-
-Report fields are "sticky," which means that any changes made by an employee will persist and be reflected in subsequent reports they create.
-
diff --git a/docs/articles/expensify-classic/workspaces/reports/Scheduled-Submit.md b/docs/articles/expensify-classic/workspaces/reports/Scheduled-Submit.md
deleted file mode 100644
index 18ad693a1c56..000000000000
--- a/docs/articles/expensify-classic/workspaces/reports/Scheduled-Submit.md
+++ /dev/null
@@ -1,43 +0,0 @@
----
-title: Scheduled Submit
-description: How to use the Scheduled Submit feature
----
-# Overview
-
-Scheduled Submit reduces the delay between the time an employee creates an expense to when it is submitted to the admin. This gives admins significantly faster visibility into employee spend. Without Scheduled Submit enabled, expenses can be left Unreported giving workspace admins no visibility into employee spend.
-
-The biggest delay in expense management is the time it takes for an employee to actually submit the expense after it is incurred. Scheduled Submit allows you to automatically collect employee expenses on a schedule of your choosing without delaying the process while you wait for employees to submit them.
-
-It works like this: Employee expenses are automatically gathered onto a report. If there is not an existing report, a new one will be created. This report is submitted automatically at the cadence you choose (daily, weekly, monthly, twice month, by trip).
-
-# How to enable Scheduled Submit
-
-**For workspace admins**: To enable Scheduled Submit on your group workspace, follow **Settings > Workspaces > Group > *[Workspace Name]* > Reports > Scheduled Submit**. From there, toggle Scheduled Submit to Enabled. Then, choose your desired frequency from the dropdown menu.
-For individuals or employees: To enable Scheduled Submit on your individual workspace, follow **Settings > Workspaces > Individual > *[Workspace Name]* > Reports > Scheduled Submit**. From there, toggle Scheduled Submit to Enabled. Then, choose your desired frequency from the dropdown menu.
-
-## Scheduled Submit frequency options
-
-**Daily**: Each night, expenses without violations will be submitted. Expenses with violations will remain on an open report until the violations are corrected, after which they will be submitted in the evening (PDT).
-
-**Weekly**: Expenses that are free of violations will be submitted on a weekly basis. However, expenses with violations will be held in a new open report and combined with any new expenses. They will then be submitted at the end of the following weekly cycle, specifically on Sunday evening (PDT).
-
-**Twice a month**: Expenses that are violation-free will be submitted on both the 15th and the last day of each month, in the evening (PDT). Expenses with violations will not be submitted, but moved on to a new open report so the employee can resolve the violations and then will be submitted at the conclusion of the next cycle.
-
-**Monthly**: Expenses that are free from violations will be submitted on a monthly basis. Expenses with violations will be held back and moved to a new Open report so the violations can be resolved, and they will be submitted on the evening (PDT) of the specified date.
-
-**By trip**: Expenses are grouped by trip. This is calculated by grouping all expenses together that occur in a similar time frame. If two full days pass without any new expenses being created, the trip report will be submitted on the evening of the second day. Any expenses generated after this submission will initiate a new trip report. Please note that the "2-day" period refers to a date-based interval, not a 48-hour time frame.
-
-**Manually**: An open report will be created, and expenses will be added to it automatically. However, it's important to note that the report will not be submitted automatically; manual submission of reports will be required.This is a great option for automatically gathering all an employee’s expenses on a report for the employee’s convenience, but they will still need to review and submit the report.
-
-**Instantly**: Expenses are automatically added to a report in the Processing state, and all expenses will continue to accumulate on one report until it is Approved or Reimbursed. This removes the need to submit expenses, and Processing reports can either be Reimbursed right away (if Submit and Close is enabled), or Approved and then Reimbursed (if Submit and Approve is enabled) by a workspace admin.
-
-# Deep Dive
-
-## Schedule Submit Override
-If Scheduled Submit is disabled at the group workspace level or configured the frequency as "Manually," the individual workspace settings of a user will take precedence and be applied. This means an employee can still set up Scheduled Submit for themselves even if the admin has not enabled it. We highly recommend Scheduled Submit as it helps put your expenses on auto-pilot!
-
-## Personal Card Transactions
-Personal card transactions are handled differently compared to other expenses. If a user has added a card through Settings > Account > Credit Card Import, they need to make sure it is set as non-reimbursable and transactions must be automatically merged with a SmartScanned receipt. If transactions are set to come in as reimbursable or they aren’t merged with a SmartScanned receipt, Scheduled Submit settings will not apply.
-
-## A note on Instantly
-Setting Scheduled Submit frequency to Instantly will limit some employee actions on reports, such as the ability to retract or self-close reports, or create multiple reports. When Instantly is selected, expenses are automatically added to a Processing expense report, and new expenses will continue to accumulate on a single report until the report is Closed or Reimbursed by a workspace admin.
diff --git a/docs/articles/new-expensify/expenses/Set-up-your-wallet.md b/docs/articles/new-expensify/expenses/Set-up-your-wallet.md
new file mode 100644
index 000000000000..de1ee61066b0
--- /dev/null
+++ b/docs/articles/new-expensify/expenses/Set-up-your-wallet.md
@@ -0,0 +1,52 @@
+---
+title: Set up your wallet
+description: Send and receive payments by adding your payment account
+---
+
+To send and receive money using Expensify, you’ll first need to set up your Expensify Wallet by adding your payment account.
+
+{% include selector.html values="desktop, mobile" %}
+
+{% include option.html value="desktop" %}
+1. Click your profile image or icon in the bottom left menu.
+2. Click **Wallet** in the left menu.
+3. Click **Enable wallet**.
+4. If you haven’t already added your bank account, click **Continue** and follow the prompts to add your bank account details with Plaid. If you have already connected your bank account, you’ll skip to the next step.
+
+{% include info.html %}
+Plaid is an encrypted third-party financial data platform that Expensify uses to securely verify your banking information.
+{% include end-info.html %}
+
+{:start="5"}
+5. Enter your personal details (including your name, address, date of birth, phone number, and the last 4 digits of your social security number).
+6. Click **Save & continue**.
+7. Review the Onfido terms and click **Accept**.
+8. Use the prompts to continue the next steps on your mobile device where you will select which option you want to use to verify your device: a QR code, a link, or a text message.
+9. Follow the prompts on your mobile device to submit your ID with Onfido.
+
+When your ID is uploaded successfully, Onfido closes automatically. You can return to your Expensify Wallet to verify that it is now enabled. Once enabled, you are ready to send and receive payments.
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+1. Tap your profile image or icon in the bottom menu.
+2. Tap **Wallet**.
+3. Tap **Enable wallet**.
+4. If you haven’t already added your bank account, tap **Continue** and follow the prompts to add your bank account details with Plaid. If you have already connected your bank account, you’ll skip to the next step.
+
+{% include info.html %}
+Plaid is an encrypted third-party financial data platform that Expensify uses to securely verify your banking information.
+{% include end-info.html %}
+
+{:start="5"}
+5. Enter your personal details (including your name, address, date of birth, phone number, and the last 4 digits of your social security number).
+6. Tap **Save & continue**.
+7. Review the Onfido terms and tap **Accept**.
+8. Follow the prompts to submit your ID with Onfido. When your ID is uploaded successfully, Onfido closes automatically.
+9. Tap **Enable wallet** again to enable payments for the wallet.
+
+Once enabled, you are ready to send and receive payments.
+{% include end-option.html %}
+
+{% include end-selector.html %}
+
+
diff --git a/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md b/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md
index 24f178db9f12..56e456eb1256 100644
--- a/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md
+++ b/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md
@@ -31,7 +31,7 @@ Before completing this process, you’ll want to:
New cards will have the same limit as the existing cards. Each cardholder’s current physical and virtual cards will remain active until a Domain Admin or the cardholder deactivates it.
{% include info.html %}
-Cards won’t be issued to any employees who don’t currently have them. In this case, you’ll need to issue a new card.
+Cards won’t be issued to any employees who don’t currently have them. In this case, you’ll need to [issue a new card](https://help.expensify.com/articles/expensify-classic/expensify-card/Set-Up-the-Expensify-Visa%C2%AE-Commercial-Card-for-your-Company)
{% include end-info.html %}
{% include faq-begin.md %}
diff --git a/docs/articles/new-expensify/travel/Approve-travel-expenses.md b/docs/articles/new-expensify/travel/Approve-travel-expenses.md
new file mode 100644
index 000000000000..ae0feb4efaa6
--- /dev/null
+++ b/docs/articles/new-expensify/travel/Approve-travel-expenses.md
@@ -0,0 +1,37 @@
+---
+title: Approve travel expenses
+description: Determine how travel expenses are approved
+---
+
+
+Travel expenses follow the same approval workflow as other expenses. Admins can configure travel expenses to be approved as soft approval, hard approval or passive approval. The approval method for in-policy and out-of-policy bookings can be managed under the **Policies** section in the **Program** menu for Expensify Travel.
+
+- **Soft Approval**: Bookings are automatically approved as long as a manager does not decline them within 24 hours. However, this also means that if a manager does not decline the expenses, the arrangements will be booked even if they are out of policy. If a booking is declined, it is refunded based on the voiding/refund terms of the service provider.
+- **Hard Approval**: Bookings are automatically canceled/voided and refunded if a manager does not approve them within 24 hours.
+- **Passive Approval**: Managers are informed of out-of-policy travel, but there is no action to be taken.
+
+# Set approval method
+
+1. Click the **Travel** tab.
+2. Click **Book or manage travel**.
+3. Click the **Program** tab at the top and select **Policies**.
+4. Under General, select approval methods for Flights, Hotels, Cars and Rail.
+
+
+
+
+
+Travel expenses follow the same approval workflow as other expenses. Admins can configure travel expenses to be approved as soft approval, hard approval or passive approval. The approval method for in-policy and out-of-policy bookings can be managed under the **Policies** section in the **Program** menu for Expensify Travel.
+
+- **Soft Approval**: Bookings are automatically approved as long as a manager does not decline them within 24 hours. However, this also means that if a manager does not decline the expenses, the arrangements will be booked even if they are out of policy. If a booking is declined, it is refunded based on the voiding/refund terms of the service provider.
+- **Hard Approval**: Bookings are automatically canceled/voided and refunded if a manager does not approve them within 24 hours.
+- **Passive Approval**: Managers are informed of out-of-policy travel, but there is no action to be taken.
+
+# Set approval method
+
+1. Click the + icon in the bottom left menu and select **Book travel**.
+2. Click **Book or manage travel**.
+3. Click the **Program** tab at the top and select **Policies**.
+4. Under General, select approval methods for Flights, Hotels, Cars and Rail.
+
+
diff --git a/docs/articles/new-expensify/travel/Coming-Soon.md b/docs/articles/new-expensify/travel/Coming-Soon.md
deleted file mode 100644
index 4d32487a14b5..000000000000
--- a/docs/articles/new-expensify/travel/Coming-Soon.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Coming soon
-description: Coming soon
----
-
-# Coming soon
\ No newline at end of file
diff --git a/docs/articles/new-expensify/travel/Configure-travel-policy-and-preferences.md b/docs/articles/new-expensify/travel/Configure-travel-policy-and-preferences.md
new file mode 100644
index 000000000000..2e17af06773c
--- /dev/null
+++ b/docs/articles/new-expensify/travel/Configure-travel-policy-and-preferences.md
@@ -0,0 +1,65 @@
+---
+title: Configure travel policy and preferences
+description: Set and update travel policies and preferences for your Expensify Workspace
+---
+
+
+As a Workspace Admin, you can set travel policies for all travel booked under your workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees.
+
+# Create a travel policy
+
+When using Expensify Travel for the first time, you will need to create a new Travel Policy.
+
+To create a travel policy from the Expensify web app,
+
+1. Click the **Travel** tab.
+2. Click **Book or manage travel**.
+3. Click the **Program** tab at the top and select **Policies**.
+4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy.
+5. Use the **Edit members** section to select the group of employees that belong to this policy. A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace.
+6. Select which travel preferences you want to modify: General, flight, hotel, car, or rail.
+7. Click the paperclip icon next to each setting to de-couple it from your default policy.
+8. Update the desired settings.
+
+# Update travel policy preferences
+
+1. Click the **Travel** tab.
+2. Click **Book or manage travel**.
+3. Click the **Program** tab at the top and select **Policies**.
+4. Select the appropriate policy in the left menu.
+5. Select which travel preferences you want to modify: General, flight, hotel, car, or rail.
+6. Click the paperclip icon next to each setting to de-couple it from your default policy.
+7. Update the desired policies.
+
+
+
+
+
+As a Workspace Admin, you can set travel policies for all travel booked under your workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees.
+
+# Create a travel policy
+
+When using Expensify Travel for the first time, you will need to create a new Travel Policy.
+
+To create a travel policy from the Expensify web app,
+
+1. Click the + icon in the bottom left menu and select **Book travel**.
+2. Click **Book or manage travel**.
+3. Click the **Program** tab at the top and select **Policies**.
+4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy.
+5. Use the **Edit members** section to select the group of employees that belong to this policy. A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace.
+6. Select which travel preferences you want to modify: General, flight, hotel, car, or rail.
+7. Click the paperclip icon next to each setting to de-couple it from your default policy.
+8. Update the desired settings.
+
+# Update travel policy preferences
+
+1. Click the + icon in the bottom left menu and select **Book travel**.
+2. Click **Book or manage travel**.
+3. Click the **Program** tab at the top and select **Policies**.
+4. Select the appropriate policy in the left menu.
+5. Select which travel preferences you want to modify: General, flight, hotel, car, or rail.
+6. Click the paperclip icon next to each setting to de-couple it from your default policy.
+7. Update the desired policies.
+
+
diff --git a/docs/articles/new-expensify/travel/Edit-or-cancel-travel-arrangements.md b/docs/articles/new-expensify/travel/Edit-or-cancel-travel-arrangements.md
new file mode 100644
index 000000000000..7dc71c3220ca
--- /dev/null
+++ b/docs/articles/new-expensify/travel/Edit-or-cancel-travel-arrangements.md
@@ -0,0 +1,28 @@
+---
+title: Edit or cancel travel arrangements
+description: Modify travel arrangements booked with Expensify Travel
+---
+
+
+Click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee.
+
+
+
+
+
+You can review your travel arrangements any time by opening the Trip chat in your inbox. For example, if you booked a flight to San Francisco, a “Trip to San Francisco” chat will be automatically added to your chat inbox.
+
+To edit or cancel a travel arrangement,
+1. Click your profile image or icon in the bottom left menu.
+2. Scroll down and click **Workspaces** in the left menu.
+3. Select the workspace the travel is booked under.
+4. Tap into the booking to see more details.
+5. Click **Trip Support**.
+
+If there is an unexpected change to the itinerary (for example, a flight cancellation), Expensify’s travel partner **Spotnana** will reach out to the traveler to provide updates on those changes.
+
+{% include info.html %}
+You can click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee.
+{% include end-info.html %}
+
+
diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js
index 6b3390148ff0..9e4880780e91 100644
--- a/docs/assets/js/main.js
+++ b/docs/assets/js/main.js
@@ -196,6 +196,35 @@ const tocbotOptions = {
scrollContainer: 'content-area',
};
+function selectNewExpensify(newExpensifyTab, newExpensifyContent, expensifyClassicTab, expensifyClassicContent) {
+ newExpensifyTab.classList.add('active');
+ newExpensifyContent.classList.remove('hidden');
+
+ if (expensifyClassicTab && expensifyClassicContent) {
+ expensifyClassicTab.classList.remove('active');
+ expensifyClassicContent.classList.add('hidden');
+ }
+ window.tocbot.refresh({
+ ...tocbotOptions,
+ contentSelector: '#new-expensify',
+ });
+}
+
+function selectExpensifyClassic(newExpensifyTab, newExpensifyContent, expensifyClassicTab, expensifyClassicContent) {
+ expensifyClassicTab.classList.add('active');
+ expensifyClassicContent.classList.remove('hidden');
+
+ if (newExpensifyTab && newExpensifyContent) {
+ newExpensifyTab.classList.remove('active');
+ newExpensifyContent.classList.add('hidden');
+ }
+
+ window.tocbot.refresh({
+ ...tocbotOptions,
+ contentSelector: '#expensify-classic',
+ });
+}
+
window.addEventListener('DOMContentLoaded', () => {
injectFooterCopywrite();
@@ -219,8 +248,10 @@ window.addEventListener('DOMContentLoaded', () => {
let contentSelector = '.article-toc-content';
if (expensifyClassicContent) {
contentSelector = '#expensify-classic';
+ selectExpensifyClassic(newExpensifyTab, newExpensifyContent, expensifyClassicTab, expensifyClassicContent);
} else if (newExpensifyContent) {
contentSelector = '#new-expensify';
+ selectNewExpensify(newExpensifyTab, newExpensifyContent, expensifyClassicTab, expensifyClassicContent);
}
if (window.tocbot) {
@@ -232,28 +263,12 @@ window.addEventListener('DOMContentLoaded', () => {
// eslint-disable-next-line es/no-optional-chaining
expensifyClassicTab?.addEventListener('click', () => {
- expensifyClassicTab.classList.add('active');
- expensifyClassicContent.classList.remove('hidden');
-
- newExpensifyTab.classList.remove('active');
- newExpensifyContent.classList.add('hidden');
- window.tocbot.refresh({
- ...tocbotOptions,
- contentSelector: '#expensify-classic',
- });
+ selectExpensifyClassic(newExpensifyTab, newExpensifyContent, expensifyClassicTab, expensifyClassicContent);
});
// eslint-disable-next-line es/no-optional-chaining
newExpensifyTab?.addEventListener('click', () => {
- newExpensifyTab.classList.add('active');
- newExpensifyContent.classList.remove('hidden');
-
- expensifyClassicTab.classList.remove('active');
- expensifyClassicContent.classList.add('hidden');
- window.tocbot.refresh({
- ...tocbotOptions,
- contentSelector: '#new-expensify',
- });
+ selectNewExpensify(newExpensifyTab, newExpensifyContent, expensifyClassicTab, expensifyClassicContent);
});
document.getElementById('header-button').addEventListener('click', toggleHeaderMenu);
diff --git a/docs/redirects.csv b/docs/redirects.csv
index 3042dc79085c..13463327d06d 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -197,5 +197,7 @@ https://help.expensify.com/articles/new-expensify/workspaces/The-Free-Plan,https
https://help.expensify.com/new-expensify/hubs/expenses/Connect-a-Bank-Account,https://help.expensify.com/articles/new-expensify/expenses/Connect-a-Business-Bank-Account
https://help.expensify.com/articles/new-expensify/settings/Security,https://help.expensify.com/articles/new-expensify/settings/Encryption-and-Data-Security
https://help.expensify.com/articles/expensify-classic/workspaces/reports/Currency,https://help.expensify.com/articles/expensify-classic/workspaces/Currency
+https://help.expensify.com/articles/expensify-classic/workspaces/reports/Report-Fields-And-Titles,https://help.expensify.com/expensify-classic/hubs/workspaces/
+https://help.expensify.com/articles/expensify-classic/workspaces/reports/Scheduled-Submit,https://help.expensify.com/articles/expensify-classic/reports/Automatically-submit-employee-reports
https://help.expensify.com/articles/new-expensify/chat/Expensify-Chat-For-Admins,https://help.expensify.com/new-expensify/hubs/chat/
https://help.expensify.com/articles/new-expensify/bank-accounts-and-payments/Connect-a-Bank-Account.html,https://help.expensify.com/articles/new-expensify/expenses/Connect-a-Business-Bank-Account
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 8337041077be..ad1d743d778d 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 1.4.78
+ 1.4.80CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.78.3
+ 1.4.80.16FullStoryOrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 7b076baeb35a..23bdf74a3648 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 1.4.78
+ 1.4.80CFBundleSignature????CFBundleVersion
- 1.4.78.3
+ 1.4.80.16
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 014ab9966e82..9f8cc7b1612e 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName$(PRODUCT_NAME)CFBundleShortVersionString
- 1.4.78
+ 1.4.80CFBundleVersion
- 1.4.78.3
+ 1.4.80.16NSExtensionNSExtensionPointIdentifier
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 1ea6b65a58b7..c138d1b27f61 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -1852,7 +1852,7 @@ PODS:
- RNGoogleSignin (10.0.1):
- GoogleSignIn (~> 7.0)
- React-Core
- - RNLiveMarkdown (0.1.70):
+ - RNLiveMarkdown (0.1.82):
- glog
- hermes-engine
- RCT-Folly (= 2022.05.16.00)
@@ -1870,9 +1870,9 @@ PODS:
- React-utils
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- - RNLiveMarkdown/common (= 0.1.70)
+ - RNLiveMarkdown/common (= 0.1.82)
- Yoga
- - RNLiveMarkdown/common (0.1.70):
+ - RNLiveMarkdown/common (0.1.82):
- glog
- hermes-engine
- RCT-Folly (= 2022.05.16.00)
@@ -1942,7 +1942,7 @@ PODS:
- React-Codegen
- React-Core
- ReactCommon/turbomodule/core
- - RNReanimated (3.7.2):
+ - RNReanimated (3.8.1):
- glog
- hermes-engine
- RCT-Folly (= 2022.05.16.00)
@@ -2589,12 +2589,12 @@ SPEC CHECKSUMS:
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNGestureHandler: 74b7b3d06d667ba0bbf41da7718f2607ae0dfe8f
RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0
- RNLiveMarkdown: 23250f3d64c9d5f82ff36c4733c03544af0222d2
+ RNLiveMarkdown: d160a948e52282067439585c89a3962582c082ce
RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81
rnmapbox-maps: df8fe93dbd251f25022f4023d31bc04160d4d65c
RNPermissions: 0b61d30d21acbeafe25baaa47d9bae40a0c65216
RNReactNativeHapticFeedback: 616c35bdec7d20d4c524a7949ca9829c09e35f37
- RNReanimated: 51db0fff543694d931bd3b7cab1a3b36bd86c738
+ RNReanimated: 323436b1a5364dca3b5f8b1a13458455e0de9efe
RNScreens: 9ec969a95987a6caae170ef09313138abf3331e1
RNShare: 2a4cdfc0626ad56b0ef583d424f2038f772afe58
RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852
diff --git a/package-lock.json b/package-lock.json
index 2829937478da..aaef29c6fad9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,19 +1,19 @@
{
"name": "new.expensify",
- "version": "1.4.78-3",
+ "version": "1.4.80-16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.78-3",
+ "version": "1.4.80-16",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@dotlottie/react-player": "^1.6.3",
- "@expensify/react-native-live-markdown": "0.1.70",
+ "@expensify/react-native-live-markdown": "0.1.82",
"@expo/metro-runtime": "~3.1.1",
"@formatjs/intl-datetimeformat": "^6.10.0",
"@formatjs/intl-listformat": "^7.2.2",
@@ -59,7 +59,7 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#1713f28214f0e7176c4fd13433fb0ea15491ebf9",
+ "expensify-common": "^2.0.10",
"expo": "^50.0.3",
"expo-av": "~13.10.4",
"expo-image": "1.11.0",
@@ -207,7 +207,7 @@
"electron-builder": "24.13.2",
"eslint": "^7.6.0",
"eslint-config-airbnb-typescript": "^17.1.0",
- "eslint-config-expensify": "^2.0.49",
+ "eslint-config-expensify": "^2.0.50",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jest": "^24.1.0",
@@ -250,8 +250,8 @@
"yaml": "^2.2.1"
},
"engines": {
- "node": "20.13.0",
- "npm": "10.5.2"
+ "node": "20.14.0",
+ "npm": "10.7.0"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -3558,9 +3558,14 @@
}
},
"node_modules/@expensify/react-native-live-markdown": {
- "version": "0.1.70",
- "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.70.tgz",
- "integrity": "sha512-HyqBtZyvuJFB4gIUECKIMxWCnTPlPj+GPWmw80VzMBRFV9QiFRKUKRWefNEJ1cXV5hl8a6oOWDQla+dCnjCzOQ==",
+ "version": "0.1.82",
+ "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.82.tgz",
+ "integrity": "sha512-w/K2+0d1sAYvyLVpPv1ufDOTaj4y96Z362N3JDN+SDUmPQN2MvVGwsTL0ltzdw78yd62azFcQl6th7P6l62THQ==",
+ "workspaces": [
+ "parser",
+ "example",
+ "WebExample"
+ ],
"engines": {
"node": ">= 18.0.0"
},
@@ -19365,9 +19370,9 @@
}
},
"node_modules/eslint-config-expensify": {
- "version": "2.0.49",
- "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.49.tgz",
- "integrity": "sha512-3yGQuOsjvtWh/jYSJKIJgmwULhrVMCiYkWGzLOKpm/wCzdiP4l0T/gJMWOkvGhTtyqxsP7ZUTwPODgcE3extxA==",
+ "version": "2.0.50",
+ "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.50.tgz",
+ "integrity": "sha512-I+OMkEprqEWlSCZGJBJxpt2Wg4HQ41/QqpKVfcADiQ3xJ76bZ1mBueqz6DR4jfph1xC6XVRl4dqGNlwbeU/2Rg==",
"dev": true,
"dependencies": {
"@lwc/eslint-plugin-lwc": "^1.7.2",
@@ -19842,8 +19847,9 @@
},
"node_modules/eslint-plugin-you-dont-need-lodash-underscore": {
"version": "6.12.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-you-dont-need-lodash-underscore/-/eslint-plugin-you-dont-need-lodash-underscore-6.12.0.tgz",
+ "integrity": "sha512-WF4mNp+k2532iswT6iUd1BX6qjd3AV4cFy/09VC82GY9SsRtvkxhUIx7JNGSe0/bLyd57oTr4inPFiIaENXhGw==",
"dev": true,
- "license": "MIT",
"dependencies": {
"kebab-case": "^1.0.0"
},
@@ -20335,11 +20341,11 @@
}
},
"node_modules/expensify-common": {
- "version": "1.0.0",
- "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#1713f28214f0e7176c4fd13433fb0ea15491ebf9",
- "integrity": "sha512-uy1+axUTTuPKwAR06xNG/tGIJ+uaavmSQgKiNU7pQVR94ibNzDD2WESn2E7OEP9/QrHa61lfFlluTjFvvz5I8Q==",
- "license": "MIT",
+ "version": "2.0.10",
+ "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.10.tgz",
+ "integrity": "sha512-+8LCtnR+VxmCjKKkfeR6XGAhVxvwZtQAw3386c1EDGNK1C0bvz3I1kLVMFbulSeibZv6/G33aO6SiW/kwum6Nw==",
"dependencies": {
+ "awesome-phonenumber": "^5.4.0",
"classnames": "2.5.0",
"clipboard": "2.0.11",
"html-entities": "^2.5.2",
diff --git a/package.json b/package.json
index 13a2f5eabf03..ee1ad32a0067 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.78-3",
+ "version": "1.4.80-16",
"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.",
@@ -65,7 +65,7 @@
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@dotlottie/react-player": "^1.6.3",
- "@expensify/react-native-live-markdown": "0.1.70",
+ "@expensify/react-native-live-markdown": "0.1.82",
"@expo/metro-runtime": "~3.1.1",
"@formatjs/intl-datetimeformat": "^6.10.0",
"@formatjs/intl-listformat": "^7.2.2",
@@ -111,7 +111,7 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#1713f28214f0e7176c4fd13433fb0ea15491ebf9",
+ "expensify-common": "^2.0.10",
"expo": "^50.0.3",
"expo-av": "~13.10.4",
"expo-image": "1.11.0",
@@ -259,7 +259,7 @@
"electron-builder": "24.13.2",
"eslint": "^7.6.0",
"eslint-config-airbnb-typescript": "^17.1.0",
- "eslint-config-expensify": "^2.0.49",
+ "eslint-config-expensify": "^2.0.50",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jest": "^24.1.0",
@@ -329,7 +329,7 @@
]
},
"engines": {
- "node": "20.13.0",
- "npm": "10.5.2"
+ "node": "20.14.0",
+ "npm": "10.7.0"
}
}
diff --git a/patches/@react-navigation+native+6.1.12.patch b/patches/@react-navigation+native+6.1.12.patch
index d451d89d687c..d53f8677d225 100644
--- a/patches/@react-navigation+native+6.1.12.patch
+++ b/patches/@react-navigation+native+6.1.12.patch
@@ -1,5 +1,5 @@
diff --git a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js
-index 16fdbef..bc2c96a 100644
+index 16fdbef..e660dd6 100644
--- a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js
+++ b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js
@@ -1,8 +1,23 @@
@@ -63,6 +63,15 @@ index 16fdbef..bc2c96a 100644
replace(_ref3) {
var _window$history$state2;
let {
+@@ -80,7 +101,7 @@ export default function createMemoryHistory() {
+
+ // Need to keep the hash part of the path if there was no previous history entry
+ // or the previous history entry had the same path
+- let pathWithHash = path;
++ let pathWithHash = path.replace(/(\/{2,})|(\/$)/g, (match, p1) => (p1 ? '/' : ''));
+ if (!items.length || items.findIndex(item => item.id === id) < 0) {
+ // There are two scenarios for creating an array with only one history record:
+ // - When loaded id not found in the items array, this function by default will replace
@@ -108,7 +129,9 @@ export default function createMemoryHistory() {
window.history.replaceState({
id
diff --git a/scripts/shellUtils.sh b/scripts/shellUtils.sh
index 848e6d238254..fa44f2ee7d3a 100644
--- a/scripts/shellUtils.sh
+++ b/scripts/shellUtils.sh
@@ -102,4 +102,18 @@ get_abs_path() {
abs_path=${abs_path/#\/\//\/}
echo "$abs_path"
-}
\ No newline at end of file
+}
+
+# Function to read lines from standard input into an array using a temporary file.
+# This is a bash 3 polyfill for readarray.
+# Arguments:
+# $1: Name of the array variable to store the lines
+# Usage:
+# read_lines_into_array array_name
+read_lines_into_array() {
+ local array_name="$1"
+ local line
+ while IFS= read -r line || [ -n "$line" ]; do
+ eval "$array_name+=(\"$line\")"
+ done
+}
diff --git a/src/CONST.ts b/src/CONST.ts
index de4e3305eddc..b0e3ab8c3af4 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -50,6 +50,7 @@ const KEYBOARD_SHORTCUT_NAVIGATION_TYPE = 'NAVIGATION_SHORTCUT';
const chatTypes = {
POLICY_ANNOUNCE: 'policyAnnounce',
POLICY_ADMINS: 'policyAdmins',
+ TRIP_ROOM: 'tripRoom',
GROUP: 'group',
DOMAIN_ALL: 'domainAll',
POLICY_ROOM: 'policyRoom',
@@ -574,6 +575,7 @@ const CONST = {
ADD_SECONDARY_LOGIN_URL: encodeURI('settings?param={"section":"account","openModal":"secondaryLogin"}'),
MANAGE_CARDS_URL: 'domain_companycards',
FEES_URL: `${USE_EXPENSIFY_URL}/fees`,
+ SAVE_WITH_EXPENSIFY_URL: `${USE_EXPENSIFY_URL}/savings-calculator`,
CFPB_PREPAID_URL: 'https://cfpb.gov/prepaid',
STAGING_NEW_EXPENSIFY_URL: 'https://staging.new.expensify.com',
NEWHELP_URL: 'https://help.expensify.com',
@@ -697,6 +699,7 @@ const CONST = {
TASK_COMPLETED: 'TASKCOMPLETED',
TASK_EDITED: 'TASKEDITED',
TASK_REOPENED: 'TASKREOPENED',
+ TRIPPREVIEW: 'TRIPPREVIEW',
UNAPPROVED: 'UNAPPROVED', // OldDot Action
UNHOLD: 'UNHOLD',
UNSHARE: 'UNSHARE', // OldDot Action
@@ -937,7 +940,7 @@ const CONST = {
TO: 'to',
CATEGORY: 'category',
TAG: 'tag',
- TOTAL: 'total',
+ TOTAL_AMOUNT: 'amount',
TYPE: 'type',
ACTION: 'action',
TAX_AMOUNT: 'taxAmount',
@@ -1577,7 +1580,7 @@ const CONST = {
APPROVE: 'approve',
TRACK: 'track',
},
- AMOUNT_MAX_LENGTH: 10,
+ AMOUNT_MAX_LENGTH: 8,
RECEIPT_STATE: {
SCANREADY: 'SCANREADY',
OPEN: 'OPEN',
@@ -1814,6 +1817,7 @@ const CONST = {
XERO_CHECK_CONNECTION: 'xeroCheckConnection',
XERO_SYNC_TITLE: 'xeroSyncTitle',
},
+ SYNC_STAGE_TIMEOUT_MINUTES: 20,
},
ACCESS_VARIANTS: {
PAID: 'paid',
@@ -1897,6 +1901,12 @@ const CONST = {
COMPACT: 'compact',
DEFAULT: 'default',
},
+ SUBSCRIPTION: {
+ TYPE: {
+ ANNUAL: 'yearly2018',
+ PAYPERUSE: 'monthly2018',
+ },
+ },
REGEX: {
SPECIAL_CHARS_WITHOUT_NEWLINE: /((?!\n)[()-\s\t])/g,
DIGITS_AND_PLUS: /^\+?[0-9]*$/,
@@ -3296,6 +3306,7 @@ const CONST = {
},
CONCIERGE_TRAVEL_URL: 'https://community.expensify.com/discussion/7066/introducing-concierge-travel',
+ BOOK_TRAVEL_DEMO_URL: 'https://calendly.com/d/ck2z-xsh-q97/expensify-travel-demo-travel-page',
SCREEN_READER_STATES: {
ALL: 'all',
ACTIVE: 'active',
@@ -3486,6 +3497,9 @@ const CONST = {
},
TAB_SEARCH: {
ALL: 'all',
+ SHARED: 'shared',
+ DRAFTS: 'drafts',
+ FINISHED: 'finished',
},
STATUS_TEXT_MAX_LENGTH: 100,
@@ -3518,10 +3532,12 @@ const CONST = {
COLON: ':',
MAPBOX: {
PADDING: 50,
- DEFAULT_ZOOM: 10,
+ DEFAULT_ZOOM: 15,
SINGLE_MARKER_ZOOM: 15,
DEFAULT_COORDINATE: [-122.4021, 37.7911],
STYLE_URL: 'mapbox://styles/expensify/cllcoiqds00cs01r80kp34tmq',
+ ANIMATION_DURATION_ON_CENTER_ME: 1000,
+ CENTER_BUTTON_FADE_DURATION: 300,
},
ONYX_UPDATE_TYPES: {
HTTPS: 'https',
@@ -3857,10 +3873,10 @@ const CONST = {
type: 'meetGuide',
autoCompleted: false,
title: 'Meet your setup specialist',
- description: ({adminsRoomLink, guideCalendarLink}: {adminsRoomLink: string; guideCalendarLink: string}) =>
+ description: ({adminsRoomLink}: {adminsRoomLink: string}) =>
`Meet your setup specialist, who can answer any questions as you get started with Expensify. Yes, a real human!\n` +
'\n' +
- `Chat with the specialist in your [#admins room](${adminsRoomLink}) or [schedule a call](${guideCalendarLink}) today.`,
+ `Chat with the specialist in your [#admins room](${adminsRoomLink}).`,
},
{
type: 'setupCategories',
@@ -4739,6 +4755,12 @@ const CONST = {
INITIAL_URL: 'INITIAL_URL',
},
+ RESERVATION_TYPE: {
+ CAR: 'car',
+ HOTEL: 'hotel',
+ FLIGHT: 'flight',
+ },
+
DOT_SEPARATOR: '•',
DEFAULT_TAX: {
@@ -4791,6 +4813,30 @@ const CONST = {
ASC: 'asc',
DESC: 'desc',
},
+
+ SUBSCRIPTION_SIZE_LIMIT: 20000,
+ SUBSCRIPTION_POSSIBLE_COST_SAVINGS: {
+ COLLECT_PLAN: 10,
+ CONTROL_PLAN: 18,
+ },
+ FEEDBACK_SURVEY_OPTIONS: {
+ TOO_LIMITED: {
+ ID: 'tooLimited',
+ TRANSLATION_KEY: 'feedbackSurvey.tooLimited',
+ },
+ TOO_EXPENSIVE: {
+ ID: 'tooExpensive',
+ TRANSLATION_KEY: 'feedbackSurvey.tooExpensive',
+ },
+ INADEQUATE_SUPPORT: {
+ ID: 'inadequateSupport',
+ TRANSLATION_KEY: 'feedbackSurvey.inadequateSupport',
+ },
+ BUSINESS_CLOSING: {
+ ID: 'businessClosing',
+ TRANSLATION_KEY: 'feedbackSurvey.businessClosing',
+ },
+ },
} as const;
type Country = keyof typeof CONST.ALL_COUNTRIES;
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 86a4a0a31716..0d22d3714fe6 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -154,6 +154,9 @@ const ONYXKEYS = {
/** Whether the user has been shown the hold educational interstitial yet */
NVP_HOLD_USE_EXPLAINED: 'holdUseExplained',
+ /** Store the state of the subscription */
+ NVP_PRIVATE_SUBSCRIPTION: 'nvp_private_subscription',
+
/** Store preferred skintone for emoji */
PREFERRED_EMOJI_SKIN_TONE: 'nvp_expensify_preferredEmojiSkinTone',
@@ -394,6 +397,8 @@ const ONYXKEYS = {
POLICY_CREATE_DISTANCE_RATE_FORM: 'policyCreateDistanceRateForm',
POLICY_CREATE_DISTANCE_RATE_FORM_DRAFT: 'policyCreateDistanceRateFormDraft',
POLICY_DISTANCE_RATE_EDIT_FORM: 'policyDistanceRateEditForm',
+ POLICY_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT_FORM: 'policyDistanceRateTaxReclaimableOnEditForm',
+ POLICY_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT_FORM_DRAFT: 'policyDistanceRateTaxReclaimableOnEditFormDraft',
POLICY_DISTANCE_RATE_EDIT_FORM_DRAFT: 'policyDistanceRateEditFormDraft',
CLOSE_ACCOUNT_FORM: 'closeAccount',
CLOSE_ACCOUNT_FORM_DRAFT: 'closeAccountDraft',
@@ -479,6 +484,8 @@ const ONYXKEYS = {
WORKSPACE_TAX_VALUE_FORM_DRAFT: 'workspaceTaxValueFormDraft',
NEW_CHAT_NAME_FORM: 'newChatNameForm',
NEW_CHAT_NAME_FORM_DRAFT: 'newChatNameFormDraft',
+ SUBSCRIPTION_SIZE_FORM: 'subscriptionSizeForm',
+ SUBSCRIPTION_SIZE_FORM_DRAFT: 'subscriptionSizeFormDraft',
},
} as const;
@@ -533,9 +540,11 @@ type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.WORKSPACE_NEW_TAX_FORM]: FormTypes.WorkspaceNewTaxForm;
[ONYXKEYS.FORMS.POLICY_CREATE_DISTANCE_RATE_FORM]: FormTypes.PolicyCreateDistanceRateForm;
[ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_EDIT_FORM]: FormTypes.PolicyDistanceRateEditForm;
+ [ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT_FORM]: FormTypes.PolicyDistanceRateTaxReclaimableOnEditForm;
[ONYXKEYS.FORMS.WORKSPACE_TAX_NAME_FORM]: FormTypes.WorkspaceTaxNameForm;
[ONYXKEYS.FORMS.WORKSPACE_TAX_VALUE_FORM]: FormTypes.WorkspaceTaxValueForm;
[ONYXKEYS.FORMS.NEW_CHAT_NAME_FORM]: FormTypes.NewChatNameForm;
+ [ONYXKEYS.FORMS.SUBSCRIPTION_SIZE_FORM]: FormTypes.SubscriptionSizeForm;
};
type OnyxFormDraftValuesMapping = {
@@ -621,6 +630,8 @@ type OnyxValuesMapping = {
[ONYXKEYS.BETAS]: OnyxTypes.Beta[];
[ONYXKEYS.NVP_PRIORITY_MODE]: ValueOf;
[ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE]: OnyxTypes.BlockedFromConcierge;
+
+ // The value of this nvp is a string representation of the date when the block expires, or an empty string if the user is not blocked
[ONYXKEYS.NVP_BLOCKED_FROM_CHAT]: string;
[ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID]: string;
[ONYXKEYS.NVP_TRY_FOCUS_MODE]: boolean;
@@ -640,6 +651,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.NVP_ACTIVE_POLICY_ID]: string;
[ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS]: OnyxTypes.DismissedReferralBanners;
[ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING]: boolean;
+ [ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION]: OnyxTypes.PrivateSubscription;
[ONYXKEYS.USER_WALLET]: OnyxTypes.UserWallet;
[ONYXKEYS.WALLET_ONFIDO]: OnyxTypes.WalletOnfido;
[ONYXKEYS.WALLET_ADDITIONAL_DETAILS]: OnyxTypes.WalletAdditionalDetails;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 82acc26e3100..ed20d388bb87 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -60,13 +60,13 @@ const ROUTES = {
getRoute: (reportID: string, reportActionID: string) => `flag/${reportID}/${reportActionID}` as const,
},
CHAT_FINDER: 'chat-finder',
- DETAILS: {
- route: 'details',
- getRoute: (login: string) => `details?login=${encodeURIComponent(login)}` as const,
- },
PROFILE: {
route: 'a/:accountID',
- getRoute: (accountID: string | number, backTo?: string) => getUrlWithBackToParam(`a/${accountID}`, backTo),
+ getRoute: (accountID?: string | number, backTo?: string, login?: string) => {
+ const baseRoute = getUrlWithBackToParam(`a/${accountID}`, backTo);
+ const loginParam = login ? `?login=${encodeURIComponent(login)}` : '';
+ return `${baseRoute}${loginParam}` as const;
+ },
},
PROFILE_AVATAR: {
route: 'a/:accountID/avatar',
@@ -102,6 +102,9 @@ const ROUTES = {
SETTINGS_PRONOUNS: 'settings/profile/pronouns',
SETTINGS_PREFERENCES: 'settings/preferences',
SETTINGS_SUBSCRIPTION: 'settings/subscription',
+ SETTINGS_SUBSCRIPTION_SIZE: 'settings/subscription/subscription-size',
+ SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD: 'settings/subscription/add-payment-card',
+ SETTINGS_SUBSCRIPTION_DISABLE_AUTO_RENEW_SURVEY: 'settings/subscription/disable-auto-renew-survey',
SETTINGS_PRIORITY_MODE: 'settings/preferences/priority-mode',
SETTINGS_LANGUAGE: 'settings/preferences/language',
SETTINGS_THEME: 'settings/preferences/theme',
@@ -795,6 +798,14 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/distance-rates/:rateID/edit',
getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/edit` as const,
},
+ WORKSPACE_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT: {
+ route: 'settings/workspaces/:policyID/distance-rates/:rateID/tax-reclaimable/edit',
+ getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/tax-reclaimable/edit` as const,
+ },
+ WORKSPACE_DISTANCE_RATE_TAX_RATE_EDIT: {
+ route: 'settings/workspaces/:policyID/distance-rates/:rateID/tax-rate/edit',
+ getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/tax-rate/edit` as const,
+ },
// Referral program promotion
REFERRAL_DETAILS_MODAL: {
route: 'referral/:contentType',
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 1ec1462da32c..9f7277a0ad0f 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -106,6 +106,9 @@ const SCREENS = {
SUBSCRIPTION: {
ROOT: 'Settings_Subscription',
+ SIZE: 'Settings_Subscription_Size',
+ ADD_PAYMENT_CARD: 'Settings_Subscription_Add_Payment_Card',
+ DISABLE_AUTO_RENEW_SURVEY: 'Settings_Subscription_DisableAutoRenewSurvey',
},
},
SAVE_THE_WORLD: {
@@ -320,6 +323,8 @@ const SCREENS = {
DISTANCE_RATES_SETTINGS: 'Distance_Rates_Settings',
DISTANCE_RATE_DETAILS: 'Distance_Rate_Details',
DISTANCE_RATE_EDIT: 'Distance_Rate_Edit',
+ DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT: 'Distance_Rate_Tax_Reclaimable_On_Edit',
+ DISTANCE_RATE_TAX_RATE_EDIT: 'Distance_Rate_Tax_Rate_Edit',
},
EDIT_REQUEST: {
diff --git a/src/pages/workspace/members/WorkspaceOwnerPaymentCardCurrencyModal.tsx b/src/components/AddPaymentCard/PaymentCardCurrencyModal.tsx
similarity index 82%
rename from src/pages/workspace/members/WorkspaceOwnerPaymentCardCurrencyModal.tsx
rename to src/components/AddPaymentCard/PaymentCardCurrencyModal.tsx
index fcbbbbd4af3f..60fa838b0577 100644
--- a/src/pages/workspace/members/WorkspaceOwnerPaymentCardCurrencyModal.tsx
+++ b/src/components/AddPaymentCard/PaymentCardCurrencyModal.tsx
@@ -6,9 +6,10 @@ import SelectionList from '@components/SelectionList';
import RadioListItem from '@components/SelectionList/RadioListItem';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
import CONST from '@src/CONST';
-type WorkspaceOwnerPaymentCardCurrencyModalProps = {
+type PaymentCardCurrencyModalProps = {
/** Whether the modal is visible */
isVisible: boolean;
@@ -25,7 +26,8 @@ type WorkspaceOwnerPaymentCardCurrencyModalProps = {
onClose?: () => void;
};
-function WorkspaceOwnerPaymentCardCurrencyModal({isVisible, currencies, currentCurrency = CONST.CURRENCY.USD, onCurrencyChange, onClose}: WorkspaceOwnerPaymentCardCurrencyModalProps) {
+function PaymentCardCurrencyModal({isVisible, currencies, currentCurrency = CONST.CURRENCY.USD, onCurrencyChange, onClose}: PaymentCardCurrencyModalProps) {
+ const {isSmallScreenWidth} = useWindowDimensions();
const styles = useThemeStyles();
const {translate} = useLocalize();
const {sections} = useMemo(
@@ -51,13 +53,14 @@ function WorkspaceOwnerPaymentCardCurrencyModal({isVisible, currencies, currentC
onClose={() => onClose?.()}
onModalHide={onClose}
hideModalContentWhileAnimating
+ innerContainerStyle={styles.RHPNavigatorContainer(isSmallScreenWidth)}
useNativeDriver
>
, currency?: ValueOf) => void;
+ submitButtonText: string;
+ /** Custom content to display in the footer after card form */
+ footerContent?: ReactNode;
+ /** Custom content to display in the header before card form */
+ headerContent?: ReactNode;
+};
+
+function IAcceptTheLabel() {
+ const {translate} = useLocalize();
+
+ return (
+
+ {`${translate('common.iAcceptThe')}`}
+ {`${translate('common.addCardTermsOfService')}`} {`${translate('common.and')}`}
+ {` ${translate('common.privacyPolicy')} `}
+
+ );
+}
+
+const REQUIRED_FIELDS = [
+ INPUT_IDS.NAME_ON_CARD,
+ INPUT_IDS.CARD_NUMBER,
+ INPUT_IDS.EXPIRATION_DATE,
+ INPUT_IDS.ADDRESS_STREET,
+ INPUT_IDS.SECURITY_CODE,
+ INPUT_IDS.ADDRESS_ZIP_CODE,
+ INPUT_IDS.ADDRESS_STATE,
+];
+
+const CARD_TYPES = {
+ DEBIT_CARD: 'debit',
+ PAYMENT_CARD: 'payment',
+};
+
+const CARD_TYPE_SECTIONS = {
+ DEFAULTS: 'defaults',
+ ERROR: 'error',
+};
+type CartTypesMap = (typeof CARD_TYPES)[keyof typeof CARD_TYPES];
+type CartTypeSectionsMap = (typeof CARD_TYPE_SECTIONS)[keyof typeof CARD_TYPE_SECTIONS];
+
+type CardLabels = Record>>;
+
+const CARD_LABELS: CardLabels = {
+ [CARD_TYPES.DEBIT_CARD]: {
+ [CARD_TYPE_SECTIONS.DEFAULTS]: {
+ cardNumber: 'addDebitCardPage.debitCardNumber',
+ nameOnCard: 'addDebitCardPage.nameOnCard',
+ expirationDate: 'addDebitCardPage.expirationDate',
+ expiration: 'addDebitCardPage.expiration',
+ securityCode: 'addDebitCardPage.cvv',
+ billingAddress: 'addDebitCardPage.billingAddress',
+ },
+ [CARD_TYPE_SECTIONS.ERROR]: {
+ nameOnCard: 'addDebitCardPage.error.invalidName',
+ cardNumber: 'addDebitCardPage.error.debitCardNumber',
+ expirationDate: 'addDebitCardPage.error.expirationDate',
+ securityCode: 'addDebitCardPage.error.securityCode',
+ addressStreet: 'addDebitCardPage.error.addressStreet',
+ addressZipCode: 'addDebitCardPage.error.addressZipCode',
+ },
+ },
+ [CARD_TYPES.PAYMENT_CARD]: {
+ defaults: {
+ cardNumber: 'addPaymentCardPage.paymentCardNumber',
+ nameOnCard: 'addPaymentCardPage.nameOnCard',
+ expirationDate: 'addPaymentCardPage.expirationDate',
+ expiration: 'addPaymentCardPage.expiration',
+ securityCode: 'addPaymentCardPage.cvv',
+ billingAddress: 'addPaymentCardPage.billingAddress',
+ },
+ error: {
+ nameOnCard: 'addPaymentCardPage.error.invalidName',
+ cardNumber: 'addPaymentCardPage.error.paymentCardNumber',
+ expirationDate: 'addPaymentCardPage.error.expirationDate',
+ securityCode: 'addPaymentCardPage.error.securityCode',
+ addressStreet: 'addPaymentCardPage.error.addressStreet',
+ addressZipCode: 'addPaymentCardPage.error.addressZipCode',
+ },
+ },
+};
+
+function PaymentCardForm({
+ shouldShowPaymentCardForm,
+ addPaymentCard,
+ showAcceptTerms,
+ showAddressField,
+ showCurrencyField,
+ isDebitCard,
+ submitButtonText,
+ showStateSelector,
+ footerContent,
+ headerContent,
+}: PaymentCardFormProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const route = useRoute();
+ const label = CARD_LABELS[isDebitCard ? CARD_TYPES.DEBIT_CARD : CARD_TYPES.PAYMENT_CARD];
+
+ const cardNumberRef = useRef(null);
+
+ const [isCurrencyModalVisible, setIsCurrencyModalVisible] = useState(false);
+ const [currency, setCurrency] = useState(CONST.CURRENCY.USD);
+
+ const validate = (formValues: FormOnyxValues): FormInputErrors => {
+ const errors = ValidationUtils.getFieldRequiredErrors(formValues, REQUIRED_FIELDS);
+
+ if (formValues.nameOnCard && !ValidationUtils.isValidLegalName(formValues.nameOnCard)) {
+ errors.nameOnCard = label.error.nameOnCard;
+ }
+
+ if (formValues.cardNumber && !ValidationUtils.isValidDebitCard(formValues.cardNumber.replace(/ /g, ''))) {
+ errors.cardNumber = label.error.cardNumber;
+ }
+
+ if (formValues.expirationDate && !ValidationUtils.isValidExpirationDate(formValues.expirationDate)) {
+ errors.expirationDate = label.error.expirationDate;
+ }
+
+ if (formValues.securityCode && !ValidationUtils.isValidSecurityCode(formValues.securityCode)) {
+ errors.securityCode = label.error.securityCode;
+ }
+
+ if (formValues.addressStreet && !ValidationUtils.isValidAddress(formValues.addressStreet)) {
+ errors.addressStreet = label.error.addressStreet;
+ }
+
+ if (formValues.addressZipCode && !ValidationUtils.isValidZipCode(formValues.addressZipCode)) {
+ errors.addressZipCode = label.error.addressZipCode;
+ }
+
+ if (!formValues.acceptTerms) {
+ errors.acceptTerms = 'common.error.acceptTerms';
+ }
+
+ return errors;
+ };
+
+ const showCurrenciesModal = useCallback(() => {
+ setIsCurrencyModalVisible(true);
+ }, []);
+
+ const changeCurrency = useCallback((newCurrency: keyof typeof CONST.CURRENCY) => {
+ setCurrency(newCurrency);
+ setIsCurrencyModalVisible(false);
+ }, []);
+
+ if (!shouldShowPaymentCardForm) {
+ return null;
+ }
+
+ return (
+ <>
+ {headerContent}
+ addPaymentCard(formData, currency)}
+ submitButtonText={submitButtonText}
+ scrollContextEnabled
+ style={[styles.mh5, styles.flexGrow1]}
+ >
+
+
+
+
+
+
+
+
+
+
+ {!!showAddressField && (
+
+
+
+ )}
+
+ {!!showStateSelector && (
+
+
+
+ )}
+ {!!showCurrencyField && (
+
+ {(isHovered) => (
+
+ )}
+
+ )}
+ {!!showAcceptTerms && (
+
+
+
+ )}
+
+ }
+ currentCurrency={currency}
+ onCurrencyChange={changeCurrency}
+ onClose={() => setIsCurrencyModalVisible(false)}
+ />
+ {footerContent}
+
+ >
+ );
+}
+
+PaymentCardForm.displayName = 'PaymentCardForm';
+
+export default PaymentCardForm;
diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx
index 2212e7460a2a..4c470858292c 100644
--- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx
+++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import React, {useEffect, useRef} from 'react';
// eslint-disable-next-line no-restricted-imports
import type {Text as RNText} from 'react-native';
diff --git a/src/components/ArchivedReportFooter.tsx b/src/components/ArchivedReportFooter.tsx
index 9713e40136a2..173f84d392e5 100644
--- a/src/components/ArchivedReportFooter.tsx
+++ b/src/components/ArchivedReportFooter.tsx
@@ -64,7 +64,7 @@ function ArchivedReportFooter({report, reportClosedAction, personalDetails = {}}
return (
= {
includeBase64: false,
saveToPhotos: false,
selectionLimit: 1,
includeExtra: false,
+ assetRepresentationMode: 'current',
};
/**
@@ -212,7 +213,7 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s
* An attachment error dialog when user selected malformed images
*/
const showImageCorruptionAlert = useCallback(() => {
- Alert.alert(translate('attachmentPicker.attachmentError'), translate('attachmentPicker.errorWhileSelectingCorruptedImage'));
+ Alert.alert(translate('attachmentPicker.attachmentError'), translate('attachmentPicker.errorWhileSelectingCorruptedAttachment'));
}, [translate]);
/**
diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts
index fcec07a327a0..f2325eda532d 100644
--- a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts
+++ b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts
@@ -37,7 +37,7 @@ function extractAttachments(
const splittedUrl = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE].split('/');
attachments.unshift({
source: tryResolveUrlFromApiRoot(attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]),
- isAuthTokenRequired: Boolean(attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]),
+ isAuthTokenRequired: !!attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE],
file: {name: splittedUrl[splittedUrl.length - 1]},
duration: Number(attribs[CONST.ATTACHMENT_DURATION_ATTRIBUTE]),
isReceipt: false,
@@ -69,7 +69,7 @@ function extractAttachments(
attachments.unshift({
reportActionID: attribs['data-id'],
source,
- isAuthTokenRequired: Boolean(expensifySource),
+ isAuthTokenRequired: !!expensifySource,
file: {name: fileName},
isReceipt: false,
hasBeenFlagged: attribs['data-flagged'] === 'true',
diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx
index cee2264894a7..a7409e57f846 100644
--- a/src/components/Attachments/AttachmentView/index.tsx
+++ b/src/components/Attachments/AttachmentView/index.tsx
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import React, {memo, useEffect, useState} from 'react';
import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
diff --git a/src/components/AutoEmailLink.tsx b/src/components/AutoEmailLink.tsx
index e1a9bdd2794b..d64c665a020f 100644
--- a/src/components/AutoEmailLink.tsx
+++ b/src/components/AutoEmailLink.tsx
@@ -1,4 +1,4 @@
-import {CONST} from 'expensify-common/lib/CONST';
+import {CONST as COMMON_CONST} from 'expensify-common';
import React from 'react';
import type {StyleProp, TextStyle} from 'react-native';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -20,8 +20,8 @@ function AutoEmailLink({text, style}: AutoEmailLinkProps) {
const styles = useThemeStyles();
return (
- {text.split(CONST.REG_EXP.EXTRACT_EMAIL).map((str, index) => {
- if (CONST.REG_EXP.EMAIL.test(str)) {
+ {text.split(COMMON_CONST.REG_EXP.EXTRACT_EMAIL).map((str, index) => {
+ if (COMMON_CONST.REG_EXP.EMAIL.test(str)) {
return (
;
+
/** Link message below the subtitle */
linkKey?: TranslationPaths;
@@ -79,6 +82,7 @@ function BlockingView({
iconColor,
title,
subtitle = '',
+ subtitleStyle,
linkKey = 'notFound.goBackHome',
shouldShowLink = false,
iconWidth = variables.iconSizeSuperLarge,
@@ -98,7 +102,7 @@ function BlockingView({
() => (
<>
{shouldShowLink ? (
@@ -111,7 +115,7 @@ function BlockingView({
) : null}
>
),
- [styles, subtitle, shouldShowLink, linkKey, onLinkPress, translate],
+ [styles, subtitle, shouldShowLink, linkKey, onLinkPress, translate, subtitleStyle],
);
const subtitleContent = useMemo(() => {
diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx
index b72cda0de011..88ae8d48a871 100644
--- a/src/components/Button/index.tsx
+++ b/src/components/Button/index.tsx
@@ -227,7 +227,7 @@ function Button(
large && styles.buttonLargeText,
success && styles.buttonSuccessText,
danger && styles.buttonDangerText,
- Boolean(icon) && styles.textAlignLeft,
+ !!icon && styles.textAlignLeft,
textStyles,
]}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
@@ -329,7 +329,7 @@ function Button(
]}
style={[
styles.button,
- StyleUtils.getButtonStyleWithIcon(styles, small, medium, large, Boolean(icon), Boolean(text?.length > 0), shouldShowRightIcon),
+ StyleUtils.getButtonStyleWithIcon(styles, small, medium, large, !!icon, !!(text?.length > 0), shouldShowRightIcon),
success ? styles.buttonSuccess : undefined,
danger ? styles.buttonDanger : undefined,
isDisabled ? styles.buttonOpacityDisabled : undefined,
diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx
index 51612078a88c..1403f6a7ed42 100755
--- a/src/components/ConfirmModal.tsx
+++ b/src/components/ConfirmModal.tsx
@@ -7,6 +7,7 @@ import CONST from '@src/CONST';
import type IconAsset from '@src/types/utils/IconAsset';
import ConfirmContent from './ConfirmContent';
import Modal from './Modal';
+import type BaseModalProps from './Modal/types';
type ConfirmModalProps = {
/** Title of the modal */
@@ -74,6 +75,9 @@ type ConfirmModalProps = {
* We are attempting to migrate to a new refocus manager, adding this property for gradual migration.
* */
shouldEnableNewFocusManagement?: boolean;
+
+ /** How to re-focus after the modal is dismissed */
+ restoreFocusType?: BaseModalProps['restoreFocusType'];
};
function ConfirmModal({
@@ -98,8 +102,9 @@ function ConfirmModal({
onConfirm,
image,
shouldEnableNewFocusManagement,
+ restoreFocusType,
}: ConfirmModalProps) {
- const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const {isSmallScreenWidth} = useResponsiveLayout();
const styles = useThemeStyles();
return (
@@ -109,9 +114,10 @@ function ConfirmModal({
isVisible={isVisible}
shouldSetModalVisibility={shouldSetModalVisibility}
onModalHide={onModalHide}
- type={shouldUseNarrowLayout ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM}
+ type={isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM}
innerContainerStyle={image ? styles.pt0 : {}}
shouldEnableNewFocusManagement={shouldEnableNewFocusManagement}
+ restoreFocusType={restoreFocusType}
>
;
+ /** Whether the size of the route pending icon is smaller. */
+ isSmallerIcon?: boolean;
+
+ /** Whether it should have border radius */
+ shouldHaveBorderRadius?: boolean;
+
+ /** Whether it should display the Mapbox map only when the route/coordinates exist otherwise
+ * it will display pending map icon */
+ requireRouteToDisplayMap?: boolean;
+
/** Whether the map is interactable or not */
interactive?: boolean;
};
-function ConfirmedRoute({mapboxAccessToken, transaction, interactive}: ConfirmedRouteProps) {
+function ConfirmedRoute({mapboxAccessToken, transaction, isSmallerIcon, shouldHaveBorderRadius = true, requireRouteToDisplayMap = false, interactive}: ConfirmedRouteProps) {
const {isOffline} = useNetwork();
const {route0: route} = transaction?.routes ?? {};
const waypoints = transaction?.comment?.waypoints ?? {};
const coordinates = route?.geometry?.coordinates ?? [];
const theme = useTheme();
const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
const getMarkerComponent = useCallback(
(icon: IconAsset): ReactNode => (
@@ -90,7 +102,9 @@ function ConfirmedRoute({mapboxAccessToken, transaction, interactive}: Confirmed
return MapboxToken.stop;
}, []);
- return !isOffline && Boolean(mapboxAccessToken?.token) ? (
+ const shouldDisplayMap = !requireRouteToDisplayMap || !!coordinates.length;
+
+ return !isOffline && !!mapboxAccessToken?.token && shouldDisplayMap ? (
}
- style={[styles.mapView, styles.br4]}
+ style={[styles.mapView, shouldHaveBorderRadius && styles.br4]}
waypoints={waypointMarkers}
styleURL={CONST.MAPBOX.STYLE_URL}
/>
) : (
-
+
);
}
diff --git a/src/components/CurrencySelectionList/index.tsx b/src/components/CurrencySelectionList/index.tsx
index 02ed11afa7db..bfe4578afd0f 100644
--- a/src/components/CurrencySelectionList/index.tsx
+++ b/src/components/CurrencySelectionList/index.tsx
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import React, {useMemo, useState} from 'react';
import {withOnyx} from 'react-native-onyx';
import SelectionList from '@components/SelectionList';
diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx
index 1b290aacd30d..533586d4bdbf 100644
--- a/src/components/DatePicker/CalendarPicker/index.tsx
+++ b/src/components/DatePicker/CalendarPicker/index.tsx
@@ -1,5 +1,5 @@
import {addMonths, endOfDay, endOfMonth, format, getYear, isSameDay, parseISO, setDate, setYear, startOfDay, startOfMonth, subMonths} from 'date-fns';
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import React, {useState} from 'react';
import {View} from 'react-native';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
@@ -235,7 +235,7 @@ function CalendarPicker({
style={themeStyles.calendarDayRoot}
accessibilityLabel={day?.toString() ?? ''}
tabIndex={day ? 0 : -1}
- accessible={Boolean(day)}
+ accessible={!!day}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
>
{({hovered, pressed}) => (
diff --git a/src/components/DeeplinkWrapper/index.website.tsx b/src/components/DeeplinkWrapper/index.website.tsx
index 765fbab03876..649e66ccefa8 100644
--- a/src/components/DeeplinkWrapper/index.website.tsx
+++ b/src/components/DeeplinkWrapper/index.website.tsx
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import {useEffect, useRef, useState} from 'react';
import * as Browser from '@libs/Browser';
import Navigation from '@libs/Navigation/Navigation';
diff --git a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx
index 91b8b0fc4483..0b0c3ddf27ca 100644
--- a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx
+++ b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx
@@ -74,7 +74,7 @@ function DisplayNamesWithToolTip({shouldUseFullTitle, fullTitle, displayNamesWit
))}
{renderAdditionalText?.()}
- {Boolean(isEllipsisActive) && (
+ {!!isEllipsisActive && (
{/* There is some Gap for real ellipsis so we are adding 4 `.` to cover */}
diff --git a/src/components/DistanceMapView/index.android.tsx b/src/components/DistanceMapView/index.android.tsx
index 168a480c6100..629b05d7bccf 100644
--- a/src/components/DistanceMapView/index.android.tsx
+++ b/src/components/DistanceMapView/index.android.tsx
@@ -5,6 +5,7 @@ import * as Expensicons from '@components/Icon/Expensicons';
import MapView from '@components/MapView';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
+import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import type DistanceMapViewProps from './types';
@@ -13,6 +14,7 @@ function DistanceMapView({overlayStyle, ...rest}: DistanceMapViewProps) {
const [isMapReady, setIsMapReady] = useState(false);
const {isOffline} = useNetwork();
const {translate} = useLocalize();
+ const theme = useTheme();
return (
<>
@@ -33,6 +35,7 @@ function DistanceMapView({overlayStyle, ...rest}: DistanceMapViewProps) {
title={translate('distance.mapPending.title')}
subtitle={isOffline ? translate('distance.mapPending.subtitle') : translate('distance.mapPending.onlineSubtitle')}
shouldShowLink={false}
+ iconColor={theme.border}
/>
)}
diff --git a/src/components/DragAndDrop/Provider/index.tsx b/src/components/DragAndDrop/Provider/index.tsx
index dc02eea2b12c..1011fa161312 100644
--- a/src/components/DragAndDrop/Provider/index.tsx
+++ b/src/components/DragAndDrop/Provider/index.tsx
@@ -1,5 +1,5 @@
import {PortalHost} from '@gorhom/portal';
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import {View} from 'react-native';
import useDragAndDrop from '@hooks/useDragAndDrop';
diff --git a/src/components/EReceipt.tsx b/src/components/EReceipt.tsx
index 40f5d242d005..bfb59dc748ab 100644
--- a/src/components/EReceipt.tsx
+++ b/src/components/EReceipt.tsx
@@ -36,13 +36,13 @@ function EReceipt({transaction, transactionID}: EReceiptProps) {
const {
amount: transactionAmount,
- currency: transactionCurrency = '',
+ currency: transactionCurrency,
merchant: transactionMerchant,
created: transactionDate,
cardID: transactionCardID,
} = ReportUtils.getTransactionDetails(transaction, CONST.DATE.MONTH_DAY_YEAR_FORMAT) ?? {};
const formattedAmount = CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency);
- const currency = CurrencyUtils.getCurrencySymbol(transactionCurrency);
+ const currency = CurrencyUtils.getCurrencySymbol(transactionCurrency ?? '');
const amount = currency ? formattedAmount.replace(currency, '') : formattedAmount;
const cardDescription = transactionCardID ? CardUtils.getCardDescription(transactionCardID) : '';
diff --git a/src/components/FeatureList.tsx b/src/components/FeatureList.tsx
index 5e4ab89cf150..37b798dcd66c 100644
--- a/src/components/FeatureList.tsx
+++ b/src/components/FeatureList.tsx
@@ -32,11 +32,20 @@ type FeatureListProps = {
/** Action to call on cta button press */
onCtaPress?: () => void;
+ /** Text of the secondary button button */
+ secondaryButtonText?: string;
+
+ /** Accessibility label for the secondary button */
+ secondaryButtonAccessibilityLabel?: string;
+
+ /** Action to call on secondary button press */
+ onSecondaryButtonPress?: () => void;
+
/** A list of menuItems representing the feature list. */
menuItems: FeatureListItem[];
- /** The illustration to display in the header. Can be a JSON object representing a Lottie animation. */
- illustration: DotLottieAnimation;
+ /** The illustration to display in the header. Can be an image or a JSON object representing a Lottie animation. */
+ illustration: DotLottieAnimation | IconAsset;
/** The style passed to the illustration */
illustrationStyle?: StyleProp;
@@ -57,6 +66,9 @@ function FeatureList({
ctaText = '',
ctaAccessibilityLabel = '',
onCtaPress,
+ secondaryButtonText = '',
+ secondaryButtonAccessibilityLabel = '',
+ onSecondaryButtonPress,
menuItems,
illustration,
illustrationStyle,
@@ -99,6 +111,15 @@ function FeatureList({
))}
+ {secondaryButtonText && (
+
+ )}
{shouldShowMarkAsCashButton && shouldUseNarrowLayout && (
@@ -240,6 +249,16 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow
/>
)}
+ {isDuplicate && shouldUseNarrowLayout && (
+
+
+
+ )}
{statusBarProps && (
Boolean(Object.keys(report?.participants ?? {}).includes(accountID)) || (ReportUtils.isSelfDM(report) && report?.ownerAccountID === Number(accountID)))
+ .filter((report) => !!Object.keys(report?.participants ?? {}).includes(accountID) || (ReportUtils.isSelfDM(report) && report?.ownerAccountID === Number(accountID)))
.forEach((report) => {
if (!report) {
return;
diff --git a/src/components/OptionsPicker/OptionItem.tsx b/src/components/OptionsPicker/OptionItem.tsx
new file mode 100644
index 000000000000..a787c20f515c
--- /dev/null
+++ b/src/components/OptionsPicker/OptionItem.tsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import type {StyleProp, ViewStyle} from 'react-native';
+import {View} from 'react-native';
+import Icon from '@components/Icon';
+import {PressableWithFeedback} from '@components/Pressable';
+import SelectCircle from '@components/SelectCircle';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
+import type IconAsset from '@src/types/utils/IconAsset';
+
+type OptionItemProps = {
+ /** Text to be rendered */
+ title: TranslationPaths;
+
+ /** Icon to be displayed above the title */
+ icon: IconAsset;
+
+ /** Press handler */
+ onPress?: () => void;
+
+ /** Indicates whether the option is currently selected (active) */
+ isSelected?: boolean;
+
+ /** Indicates whether the option is disabled */
+ isDisabled?: boolean;
+
+ /** Optional style prop */
+ style?: StyleProp;
+};
+
+function OptionItem({title, icon, onPress, isSelected = false, isDisabled, style}: OptionItemProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ return (
+
+
+
+
+
+ {!isDisabled && (
+
+
+
+ )}
+
+
+ {translate(title)}
+
+
+
+
+ );
+}
+
+OptionItem.displayName = 'OptionItem';
+
+export default OptionItem;
diff --git a/src/components/OptionsPicker/index.tsx b/src/components/OptionsPicker/index.tsx
new file mode 100644
index 000000000000..621b8465adba
--- /dev/null
+++ b/src/components/OptionsPicker/index.tsx
@@ -0,0 +1,61 @@
+import React, {Fragment} from 'react';
+import type {StyleProp, ViewStyle} from 'react-native';
+import {View} from 'react-native';
+import useThemeStyles from '@hooks/useThemeStyles';
+import type {TranslationPaths} from '@src/languages/types';
+import type IconAsset from '@src/types/utils/IconAsset';
+import OptionItem from './OptionItem';
+
+type OptionsPickerItem = {
+ /** A unique identifier for each option */
+ key: TKey;
+
+ /** Text to be displayed */
+ title: TranslationPaths;
+
+ /** Icon to be displayed above the title */
+ icon: IconAsset;
+};
+
+type OptionsPickerProps = {
+ /** Options list */
+ options: Array>;
+
+ /** Selected option's identifier */
+ selectedOption: TKey;
+
+ /** Option select handler */
+ onOptionSelected: (option: TKey) => void;
+
+ /** Indicates whether the picker is disabled */
+ isDisabled?: boolean;
+
+ /** Optional style */
+ style?: StyleProp;
+};
+
+function OptionsPicker({options, selectedOption, onOptionSelected, style, isDisabled}: OptionsPickerProps) {
+ const styles = useThemeStyles();
+
+ return (
+
+ {options.map((option, index) => (
+
+ onOptionSelected(option.key)}
+ />
+ {index < options.length - 1 && }
+
+ ))}
+
+ );
+}
+
+OptionsPicker.displayName = 'OptionsPicker';
+
+export default OptionsPicker;
+export type {OptionsPickerItem};
diff --git a/src/components/PDFThumbnail/PDFThumbnailError.tsx b/src/components/PDFThumbnail/PDFThumbnailError.tsx
new file mode 100644
index 000000000000..0598a995e030
--- /dev/null
+++ b/src/components/PDFThumbnail/PDFThumbnailError.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import {View} from 'react-native';
+import Icon from '@components/Icon';
+import * as Expensicons from '@components/Icon/Expensicons';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import variables from '@styles/variables';
+
+function PDFThumbnailError() {
+ const styles = useThemeStyles();
+ const theme = useTheme();
+
+ return (
+
+
+
+ );
+}
+
+export default PDFThumbnailError;
diff --git a/src/components/PDFThumbnail/index.native.tsx b/src/components/PDFThumbnail/index.native.tsx
index 0232dba99f05..27d41ede3263 100644
--- a/src/components/PDFThumbnail/index.native.tsx
+++ b/src/components/PDFThumbnail/index.native.tsx
@@ -1,19 +1,21 @@
-import React from 'react';
+import React, {useState} from 'react';
import {View} from 'react-native';
import Pdf from 'react-native-pdf';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import useThemeStyles from '@hooks/useThemeStyles';
import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL';
+import PDFThumbnailError from './PDFThumbnailError';
import type PDFThumbnailProps from './types';
-function PDFThumbnail({previewSourceURL, style, isAuthTokenRequired = false, enabled = true, onPassword}: PDFThumbnailProps) {
+function PDFThumbnail({previewSourceURL, style, isAuthTokenRequired = false, enabled = true, onPassword, onLoadError}: PDFThumbnailProps) {
const styles = useThemeStyles();
const sizeStyles = [styles.w100, styles.h100];
+ const [failedToLoad, setFailedToLoad] = useState(false);
return (
-
- {enabled && (
+
+ {enabled && !failedToLoad && (
{
- if (!('message' in error && typeof error.message === 'string' && error.message.match(/password/i))) {
- return;
+ if (onLoadError) {
+ onLoadError();
}
- if (!onPassword) {
+ if ('message' in error && typeof error.message === 'string' && error.message.match(/password/i) && onPassword) {
+ onPassword();
return;
}
- onPassword();
+ setFailedToLoad(true);
}}
/>
)}
+ {failedToLoad && }
);
diff --git a/src/components/PDFThumbnail/index.tsx b/src/components/PDFThumbnail/index.tsx
index ce631f3b611f..8e79c027cf03 100644
--- a/src/components/PDFThumbnail/index.tsx
+++ b/src/components/PDFThumbnail/index.tsx
@@ -1,18 +1,20 @@
import pdfWorkerSource from 'pdfjs-dist/legacy/build/pdf.worker';
-import React, {useMemo} from 'react';
+import React, {useMemo, useState} from 'react';
import {View} from 'react-native';
import {Document, pdfjs, Thumbnail} from 'react-pdf';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import useThemeStyles from '@hooks/useThemeStyles';
import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL';
+import PDFThumbnailError from './PDFThumbnailError';
import type PDFThumbnailProps from './types';
if (!pdfjs.GlobalWorkerOptions.workerSrc) {
pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(new Blob([pdfWorkerSource], {type: 'text/javascript'}));
}
-function PDFThumbnail({previewSourceURL, style, isAuthTokenRequired = false, enabled = true, onPassword}: PDFThumbnailProps) {
+function PDFThumbnail({previewSourceURL, style, isAuthTokenRequired = false, enabled = true, onPassword, onLoadError}: PDFThumbnailProps) {
const styles = useThemeStyles();
+ const [failedToLoad, setFailedToLoad] = useState(false);
const thumbnail = useMemo(
() => (
@@ -25,18 +27,31 @@ function PDFThumbnail({previewSourceURL, style, isAuthTokenRequired = false, ena
}}
externalLinkTarget="_blank"
onPassword={onPassword}
+ onLoad={() => {
+ setFailedToLoad(false);
+ }}
+ onLoadError={() => {
+ if (onLoadError) {
+ onLoadError();
+ }
+ setFailedToLoad(true);
+ }}
+ error={() => null}
>
),
- [isAuthTokenRequired, previewSourceURL, onPassword],
+ [isAuthTokenRequired, previewSourceURL, onPassword, onLoadError],
);
return (
-
- {enabled && thumbnail}
+
+
+ {enabled && !failedToLoad && thumbnail}
+ {failedToLoad && }
+
);
}
diff --git a/src/components/PDFThumbnail/types.ts b/src/components/PDFThumbnail/types.ts
index 11253e462aca..349669ecc33e 100644
--- a/src/components/PDFThumbnail/types.ts
+++ b/src/components/PDFThumbnail/types.ts
@@ -15,6 +15,9 @@ type PDFThumbnailProps = {
/** Callback to call if PDF is password protected */
onPassword?: () => void;
+
+ /** Callback to call if PDF can't be loaded(corrupted) */
+ onLoadError?: () => void;
};
export default PDFThumbnailProps;
diff --git a/src/components/ParentNavigationSubtitle.tsx b/src/components/ParentNavigationSubtitle.tsx
index fc21d3d8b780..b3b59fcb856a 100644
--- a/src/components/ParentNavigationSubtitle.tsx
+++ b/src/components/ParentNavigationSubtitle.tsx
@@ -55,13 +55,13 @@ function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportAct
style={[styles.optionAlternateText]}
numberOfLines={1}
>
- {Boolean(reportName) && (
+ {!!reportName && (
<>
{`${translate('threads.from')} `}{reportName}
>
)}
- {Boolean(workspaceName) && {` ${translate('threads.in')} ${workspaceName}`}}
+ {!!workspaceName && {` ${translate('threads.in')} ${workspaceName}`}}
);
diff --git a/src/components/Popover/index.tsx b/src/components/Popover/index.tsx
index 2af977caa969..3ef267292e90 100644
--- a/src/components/Popover/index.tsx
+++ b/src/components/Popover/index.tsx
@@ -28,7 +28,7 @@ function Popover(props: PopoverProps) {
animationOut = 'fadeOut',
} = props;
- const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const {isSmallScreenWidth} = useResponsiveLayout();
const withoutOverlayRef = useRef(null);
const {close, popover} = React.useContext(PopoverContext);
@@ -55,7 +55,7 @@ function Popover(props: PopoverProps) {
onClose();
};
- if (!fullscreen && !shouldUseNarrowLayout) {
+ if (!fullscreen && !isSmallScreenWidth) {
return createPortal(
(null);
const [currentMenuItems, setCurrentMenuItems] = useState(menuItems);
@@ -111,6 +117,7 @@ function PopoverMenu({
if (selectedItem?.subMenuItems) {
setCurrentMenuItems([...selectedItem.subMenuItems]);
setEnteredSubMenuIndexes([...enteredSubMenuIndexes, index]);
+ setFocusedIndex(-1);
} else {
selectedItemIndex.current = index;
onItemSelected(selectedItem, index);
@@ -132,17 +139,22 @@ function PopoverMenu({
const renderBackButtonItem = () => {
const previousMenuItems = getPreviousSubMenu();
const previouslySelectedItem = previousMenuItems[enteredSubMenuIndexes[enteredSubMenuIndexes.length - 1]];
+ const hasBackButtonText = !!previouslySelectedItem.backButtonText;
return (
diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx
index 9d6e6cbbd41f..e7cadbe73b82 100644
--- a/src/components/Section/index.tsx
+++ b/src/components/Section/index.tsx
@@ -1,8 +1,9 @@
-import React from 'react';
import type {ReactNode} from 'react';
+import React from 'react';
import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
import {View} from 'react-native';
import type {ValueOf} from 'type-fest';
+import ImageSVG from '@components/ImageSVG';
import Lottie from '@components/Lottie';
import type DotLottieAnimation from '@components/LottieAnimations/types';
import type {MenuItemWithLink} from '@components/MenuItemList';
@@ -22,12 +23,12 @@ const CARD_LAYOUT = {
ICON_ON_RIGHT: 'iconOnRight',
} as const;
-type SectionProps = ChildrenProps & {
+type SectionProps = Partial & {
/** An array of props that are passed to individual MenuItem components */
menuItems?: MenuItemWithLink[];
/** The text to display in the title of the section */
- title: string;
+ title?: string;
/** The text to display in the subtitle of the section */
subtitle?: string;
@@ -59,8 +60,8 @@ type SectionProps = ChildrenProps & {
/** Whether the section is in the central pane of the layout */
isCentralPane?: boolean;
- /** The illustration to display in the header. Can be a JSON object representing a Lottie animation. */
- illustration?: DotLottieAnimation;
+ /** The illustration to display in the header. Can be an image or a JSON object representing a Lottie animation. */
+ illustration?: DotLottieAnimation | IconAsset;
/** The background color to apply in the upper half of the screen. */
illustrationBackgroundColor?: string;
@@ -76,8 +77,25 @@ type SectionProps = ChildrenProps & {
/** The component to display in the title of the section */
renderSubtitle?: () => ReactNode;
+
+ /** The component to display custom title */
+ renderTitle?: () => ReactNode;
+
+ /** The width of the icon. */
+ iconWidth?: number;
+
+ /** The height of the icon. */
+ iconHeight?: number;
};
+function isIllustrationLottieAnimation(illustration: DotLottieAnimation | IconAsset | undefined): illustration is DotLottieAnimation {
+ if (typeof illustration === 'number' || !illustration) {
+ return false;
+ }
+
+ return 'file' in illustration && 'w' in illustration && 'h' in illustration;
+}
+
function Section({
children,
childrenStyles,
@@ -90,6 +108,7 @@ function Section({
subtitleStyles,
subtitleMuted = false,
title,
+ renderTitle,
titleStyles,
isCentralPane = false,
illustration,
@@ -97,6 +116,8 @@ function Section({
illustrationStyle,
contentPaddingOnLargeScreens,
overlayContent,
+ iconWidth,
+ iconHeight,
renderSubtitle,
}: SectionProps) {
const styles = useThemeStyles();
@@ -104,12 +125,18 @@ function Section({
const StyleUtils = useStyleUtils();
const {shouldUseNarrowLayout} = useResponsiveLayout();
- const illustrationContainerStyle: StyleProp = StyleUtils.getBackgroundColorStyle(illustrationBackgroundColor ?? illustration?.backgroundColor ?? theme.appBG);
+ const isLottie = isIllustrationLottieAnimation(illustration);
+
+ const lottieIllustration = isLottie ? illustration : undefined;
+
+ const illustrationContainerStyle: StyleProp = StyleUtils.getBackgroundColorStyle(illustrationBackgroundColor ?? lottieIllustration?.backgroundColor ?? theme.appBG);
return (
{cardLayout === CARD_LAYOUT.ICON_ON_TOP && (
@@ -117,13 +144,20 @@ function Section({
{!!illustration && (
-
+ {isLottie ? (
+
+ ) : (
+
+ )}
{overlayContent?.()}
@@ -132,15 +166,17 @@ function Section({
{cardLayout === CARD_LAYOUT.ICON_ON_LEFT && (
)}
-
- {title}
-
+ {renderTitle ? renderTitle() : {title}}
{cardLayout === CARD_LAYOUT.ICON_ON_RIGHT && (
diff --git a/src/components/SectionList/index.android.tsx b/src/components/SectionList/index.android.tsx
index 04cdd70e9ee2..60c769945b8e 100644
--- a/src/components/SectionList/index.android.tsx
+++ b/src/components/SectionList/index.android.tsx
@@ -11,7 +11,7 @@ function SectionListWithRef(props: SectionListProps
);
}
diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx
index f5e565504354..b1c689b55afa 100644
--- a/src/components/SelectionList/BaseListItem.tsx
+++ b/src/components/SelectionList/BaseListItem.tsx
@@ -5,6 +5,7 @@ import * as Expensicons from '@components/Icon/Expensicons';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import useHover from '@hooks/useHover';
+import {useMouseContext} from '@hooks/useMouseContext';
import useSyncFocus from '@hooks/useSyncFocus';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -36,11 +37,16 @@ function BaseListItem({
const theme = useTheme();
const styles = useThemeStyles();
const {hovered, bind} = useHover();
+ const {isMouseDownOnInput, setMouseUp} = useMouseContext();
const pressableRef = useRef(null);
// Sync focus on an item
- useSyncFocus(pressableRef, Boolean(isFocused), shouldSyncFocus);
+ useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus);
+ const handleMouseUp = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setMouseUp();
+ };
const rightHandSideComponentRender = () => {
if (canSelectMultiple || !rightHandSideComponent) {
@@ -67,6 +73,10 @@ function BaseListItem({
{...bind}
ref={pressableRef}
onPress={(e) => {
+ if (isMouseDownOnInput) {
+ e?.stopPropagation(); // Preventing the click action
+ return;
+ }
if (shouldPreventEnterKeySubmit && e && 'key' in e && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) {
return;
}
@@ -82,6 +92,8 @@ function BaseListItem({
id={keyForList ?? ''}
style={pressableStyle}
onFocus={onFocus}
+ onMouseUp={handleMouseUp}
+ onMouseLeave={handleMouseUp}
tabIndex={item.tabIndex}
>
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index f886758a1de9..6bbe69a1e2c6 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -85,6 +85,7 @@ function BaseSelectionList(
onEndReachedThreshold,
windowSize = 5,
updateCellsBatchingPeriod = 50,
+ removeClippedSubviews = true,
}: BaseSelectionListProps,
ref: ForwardedRef,
) {
@@ -258,13 +259,17 @@ function BaseSelectionList(
initialFocusedIndex: flattenedSections.allOptions.findIndex((option) => option.keyForList === initiallyFocusedOptionKey),
maxIndex: Math.min(flattenedSections.allOptions.length - 1, CONST.MAX_SELECTION_LIST_PAGE_LENGTH * currentPage - 1),
disabledIndexes: disabledArrowKeyIndexes,
- isActive: true,
+ isActive: isFocused,
onFocusedIndexChange: (index: number) => {
scrollToIndex(index, true);
},
isFocused,
});
+ const clearInputAfterSelect = useCallback(() => {
+ onChangeText?.('');
+ }, [onChangeText]);
+
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedOnSelectRow = useCallback(lodashDebounce(onSelectRow, 1000, {leading: true}), [onSelectRow]);
@@ -287,6 +292,10 @@ function BaseSelectionList(
scrollToIndex(Math.max(selectedOptionsCount - 1, 0), true);
}
}
+
+ if (shouldShowTextInput) {
+ clearInputAfterSelect();
+ }
}
if (shouldDebounceRowSelect) {
@@ -584,7 +593,7 @@ function BaseSelectionList(
[flattenedSections.allOptions, setFocusedIndex, updateAndScrollToFocusedIndex],
);
- useImperativeHandle(ref, () => ({scrollAndHighlightItem}), [scrollAndHighlightItem]);
+ useImperativeHandle(ref, () => ({scrollAndHighlightItem, clearInputAfterSelect}), [scrollAndHighlightItem, clearInputAfterSelect]);
/** Selects row when pressing Enter */
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, shouldDebounceRowSelect ? debouncedSelectFocusedOption : selectFocusedOption, {
@@ -669,6 +678,7 @@ function BaseSelectionList(
<>
{!listHeaderContent && header()}
void;
+} & CellProps;
+
+function TotalCell({showTooltip, isLargeScreenWidth, reportItem}: ReportCellProps) {
+ const styles = useThemeStyles();
+
+ return (
+
+ );
+}
+
+function ActionCell({onButtonPress}: ActionCellProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ return (
+
+ );
+}
+
function ReportListItem({
item,
isFocused,
@@ -37,6 +79,10 @@ function ReportListItem({
const {isLargeScreenWidth} = useWindowDimensions();
const StyleUtils = useStyleUtils();
+ if (reportItem.transactions.length === 0) {
+ return;
+ }
+
const listItemPressableStyle = [styles.selectionListPressableItemWrapper, styles.pv3, item.isSelected && styles.activeComponentBG, isFocused && styles.sidebarLinkActive];
const handleOnButtonPress = () => {
@@ -45,28 +91,10 @@ function ReportListItem({
const openReportInRHP = (transactionItem: TransactionListItemType) => {
const searchParams = getSearchParams();
- const currentQuery = searchParams && `query` in searchParams ? (searchParams?.query as string) : CONST.TAB_SEARCH.ALL;
+ const currentQuery = searchParams?.query ?? CONST.TAB_SEARCH.ALL;
Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute(currentQuery, transactionItem.transactionThreadReportID));
};
- const totalCell = (
-
- );
-
- const actionCell = (
-
- );
-
if (!reportItem?.reportName && reportItem.transactions.length > 1) {
return null;
}
@@ -130,7 +158,7 @@ function ReportListItem({
onButtonPress={handleOnButtonPress}
/>
)}
-
+
@@ -138,11 +166,26 @@ function ReportListItem({
{`${reportItem.transactions.length} ${translate('search.groupedExpenses')}`}
- {totalCell}
+
+
+
- {/** styles.reportListItemActionButtonMargin added here to move the action button by the type column distance */}
{isLargeScreenWidth && (
- {actionCell}
+ <>
+ {/** We add an empty view with type style to align the total with the table header */}
+
+
+
+
+ >
)}
@@ -156,6 +199,7 @@ function ReportListItem({
showItemHeaderOnNarrowLayout={false}
containerStyle={styles.mt3}
isHovered={hovered}
+ isChildListItem
/>
))}
diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx
index cb1ef3fdc6e1..50a34be86f61 100644
--- a/src/components/SelectionList/Search/TransactionListItemRow.tsx
+++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx
@@ -1,4 +1,4 @@
-import React, {memo} from 'react';
+import React from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import Button from '@components/Button';
@@ -26,8 +26,6 @@ type CellProps = {
// eslint-disable-next-line react/no-unused-prop-types
showTooltip: boolean;
// eslint-disable-next-line react/no-unused-prop-types
- keyForList: string;
- // eslint-disable-next-line react/no-unused-prop-types
isLargeScreenWidth: boolean;
};
@@ -43,6 +41,10 @@ type ActionCellProps = {
onButtonPress: () => void;
} & CellProps;
+type TotalCellProps = {
+ isChildListItem: boolean;
+} & TransactionCellProps;
+
type TransactionListItemRowProps = {
item: TransactionListItemType;
showTooltip: boolean;
@@ -50,6 +52,7 @@ type TransactionListItemRowProps = {
showItemHeaderOnNarrowLayout?: boolean;
containerStyle?: StyleProp;
isHovered?: boolean;
+ isChildListItem?: boolean;
};
const getTypeIcon = (type?: SearchTransactionType) => {
@@ -65,15 +68,7 @@ const getTypeIcon = (type?: SearchTransactionType) => {
}
};
-function arePropsEqual(prevProps: CellProps, nextProps: CellProps) {
- return prevProps.keyForList === nextProps.keyForList;
-}
-
-function areReceiptPropsEqual(prevProps: ReceiptCellProps, nextProps: ReceiptCellProps) {
- return prevProps.keyForList === nextProps.keyForList && prevProps.isHovered === nextProps.isHovered;
-}
-
-const ReceiptCell = memo(({transactionItem, isHovered = false}: ReceiptCellProps) => {
+function ReceiptCell({transactionItem, isHovered = false}: ReceiptCellProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -94,9 +89,9 @@ const ReceiptCell = memo(({transactionItem, isHovered = false}: ReceiptCellProps
/>
);
-}, areReceiptPropsEqual);
+}
-const DateCell = memo(({transactionItem, showTooltip, isLargeScreenWidth}: TransactionCellProps) => {
+function DateCell({transactionItem, showTooltip, isLargeScreenWidth}: TransactionCellProps) {
const styles = useThemeStyles();
const date = TransactionUtils.getCreated(transactionItem, CONST.DATE.MONTH_DAY_ABBR_FORMAT);
@@ -107,9 +102,9 @@ const DateCell = memo(({transactionItem, showTooltip, isLargeScreenWidth}: Trans
style={[styles.label, styles.pre, styles.justifyContentCenter, isLargeScreenWidth ? undefined : [styles.textMicro, styles.textSupporting]]}
/>
);
-}, arePropsEqual);
+}
-const MerchantCell = memo(({transactionItem, showTooltip}: TransactionCellProps) => {
+function MerchantCell({transactionItem, showTooltip}: TransactionCellProps) {
const styles = useThemeStyles();
const description = TransactionUtils.getDescription(transactionItem);
@@ -120,9 +115,9 @@ const MerchantCell = memo(({transactionItem, showTooltip}: TransactionCellProps)
style={[styles.label, styles.pre, styles.justifyContentCenter]}
/>
);
-}, arePropsEqual);
+}
-const TotalCell = memo(({showTooltip, isLargeScreenWidth, transactionItem}: TransactionCellProps) => {
+function TotalCell({showTooltip, isLargeScreenWidth, transactionItem, isChildListItem}: TotalCellProps) {
const styles = useThemeStyles();
const currency = TransactionUtils.getCurrency(transactionItem);
@@ -130,12 +125,12 @@ const TotalCell = memo(({showTooltip, isLargeScreenWidth, transactionItem}: Tran
);
-}, arePropsEqual);
+}
-const TypeCell = memo(({transactionItem, isLargeScreenWidth}: TransactionCellProps) => {
+function TypeCell({transactionItem, isLargeScreenWidth}: TransactionCellProps) {
const theme = useTheme();
const typeIcon = getTypeIcon(transactionItem.type);
@@ -147,9 +142,9 @@ const TypeCell = memo(({transactionItem, isLargeScreenWidth}: TransactionCellPro
width={isLargeScreenWidth ? 20 : 12}
/>
);
-}, arePropsEqual);
+}
-const ActionCell = memo(({onButtonPress}: ActionCellProps) => {
+function ActionCell({onButtonPress}: ActionCellProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
@@ -159,12 +154,12 @@ const ActionCell = memo(({onButtonPress}: ActionCellProps) => {
onPress={onButtonPress}
small
pressOnEnter
- style={[styles.p0]}
+ style={[styles.w100]}
/>
);
-}, arePropsEqual);
+}
-const CategoryCell = memo(({isLargeScreenWidth, showTooltip, transactionItem}: TransactionCellProps) => {
+function CategoryCell({isLargeScreenWidth, showTooltip, transactionItem}: TransactionCellProps) {
const styles = useThemeStyles();
return isLargeScreenWidth ? (
);
-}, arePropsEqual);
+}
-const TagCell = memo(({isLargeScreenWidth, showTooltip, transactionItem}: TransactionCellProps) => {
+function TagCell({isLargeScreenWidth, showTooltip, transactionItem}: TransactionCellProps) {
const styles = useThemeStyles();
return isLargeScreenWidth ? (
);
-}, arePropsEqual);
+}
-const TaxCell = memo(({transactionItem, showTooltip}: TransactionCellProps) => {
+function TaxCell({transactionItem, showTooltip}: TransactionCellProps) {
const styles = useThemeStyles();
const isFromExpenseReport = transactionItem.reportType === CONST.REPORT.TYPE.EXPENSE;
@@ -212,9 +207,17 @@ const TaxCell = memo(({transactionItem, showTooltip}: TransactionCellProps) => {
style={[styles.optionDisplayName, styles.label, styles.pre, styles.justifyContentCenter, styles.textAlignRight]}
/>
);
-}, arePropsEqual);
+}
-function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeaderOnNarrowLayout = true, containerStyle, isHovered = false}: TransactionListItemRowProps) {
+function TransactionListItemRow({
+ item,
+ showTooltip,
+ onButtonPress,
+ showItemHeaderOnNarrowLayout = true,
+ containerStyle,
+ isHovered = false,
+ isChildListItem = false,
+}: TransactionListItemRowProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isLargeScreenWidth} = useWindowDimensions();
@@ -237,7 +240,6 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade
@@ -269,20 +268,18 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade
showTooltip={showTooltip}
transactionItem={item}
isLargeScreenWidth={isLargeScreenWidth}
- keyForList={item.keyForList ?? ''}
+ isChildListItem={isChildListItem}
/>
@@ -297,7 +294,6 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade
@@ -334,7 +328,6 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade
{item.shouldShowCategory && (
)}
-
+
diff --git a/src/components/SelectionList/SearchTableHeader.tsx b/src/components/SelectionList/SearchTableHeader.tsx
index ab930936ec9f..c0bf8874eb07 100644
--- a/src/components/SelectionList/SearchTableHeader.tsx
+++ b/src/components/SelectionList/SearchTableHeader.tsx
@@ -14,7 +14,7 @@ import SortableHeaderText from './SortableHeaderText';
type SearchColumnConfig = {
columnName: SearchColumnType;
translationKey: TranslationPaths;
- isSortable?: boolean;
+ isColumnSortable?: boolean;
shouldShow: (data: OnyxTypes.SearchResults['data']) => boolean;
};
@@ -23,7 +23,7 @@ const SearchColumns: SearchColumnConfig[] = [
columnName: CONST.SEARCH_TABLE_COLUMNS.RECEIPT,
translationKey: 'common.receipt',
shouldShow: () => true,
- isSortable: false,
+ isColumnSortable: false,
},
{
columnName: CONST.SEARCH_TABLE_COLUMNS.DATE,
@@ -53,20 +53,21 @@ const SearchColumns: SearchColumnConfig[] = [
{
columnName: CONST.SEARCH_TABLE_COLUMNS.CATEGORY,
translationKey: 'common.category',
- shouldShow: (data: OnyxTypes.SearchResults['data']) => SearchUtils.getShouldShowColumn(data, CONST.SEARCH_TABLE_COLUMNS.CATEGORY),
+ shouldShow: () => true,
},
{
columnName: CONST.SEARCH_TABLE_COLUMNS.TAG,
translationKey: 'common.tag',
- shouldShow: (data: OnyxTypes.SearchResults['data']) => SearchUtils.getShouldShowColumn(data, CONST.SEARCH_TABLE_COLUMNS.TAG),
+ shouldShow: () => true,
},
{
columnName: CONST.SEARCH_TABLE_COLUMNS.TAX_AMOUNT,
translationKey: 'common.tax',
- shouldShow: (data: OnyxTypes.SearchResults['data']) => SearchUtils.getShouldShowColumn(data, CONST.SEARCH_TABLE_COLUMNS.TAX_AMOUNT),
+ shouldShow: () => true,
+ isColumnSortable: false,
},
{
- columnName: CONST.SEARCH_TABLE_COLUMNS.TOTAL,
+ columnName: CONST.SEARCH_TABLE_COLUMNS.TOTAL_AMOUNT,
translationKey: 'common.total',
shouldShow: () => true,
},
@@ -74,11 +75,13 @@ const SearchColumns: SearchColumnConfig[] = [
columnName: CONST.SEARCH_TABLE_COLUMNS.TYPE,
translationKey: 'common.type',
shouldShow: () => true,
+ isColumnSortable: false,
},
{
columnName: CONST.SEARCH_TABLE_COLUMNS.ACTION,
translationKey: 'common.action',
shouldShow: () => true,
+ isColumnSortable: false,
},
];
@@ -86,10 +89,11 @@ type SearchTableHeaderProps = {
data: OnyxTypes.SearchResults['data'];
sortBy?: SearchColumnType;
sortOrder?: SortOrder;
+ isSortingAllowed: boolean;
onSortPress: (column: SearchColumnType, order: SortOrder) => void;
};
-function SearchTableHeader({data, sortBy, sortOrder, onSortPress}: SearchTableHeaderProps) {
+function SearchTableHeader({data, sortBy, sortOrder, isSortingAllowed, onSortPress}: SearchTableHeaderProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {isSmallScreenWidth, isMediumScreenWidth} = useWindowDimensions();
@@ -103,9 +107,14 @@ function SearchTableHeader({data, sortBy, sortOrder, onSortPress}: SearchTableHe
return (
- {SearchColumns.map(({columnName, translationKey, shouldShow, isSortable}) => {
+ {SearchColumns.map(({columnName, translationKey, shouldShow, isColumnSortable}) => {
+ if (!shouldShow(data)) {
+ return null;
+ }
+
const isActive = sortBy === columnName;
const textStyle = columnName === CONST.SEARCH_TABLE_COLUMNS.RECEIPT ? StyleUtils.getTextOverflowStyle('clip') : null;
+ const isSortable = isSortingAllowed && isColumnSortable;
return (
onSortPress(columnName, order)}
/>
diff --git a/src/components/SelectionList/SortableHeaderText.tsx b/src/components/SelectionList/SortableHeaderText.tsx
index d84d438b3749..bd5f4873bbbc 100644
--- a/src/components/SelectionList/SortableHeaderText.tsx
+++ b/src/components/SelectionList/SortableHeaderText.tsx
@@ -14,23 +14,34 @@ type SearchTableHeaderColumnProps = {
text: string;
isActive: boolean;
sortOrder: SortOrder;
- shouldShow?: boolean;
isSortable?: boolean;
containerStyle?: StyleProp;
textStyle?: StyleProp;
onPress: (order: SortOrder) => void;
};
-export default function SortableHeaderText({text, sortOrder, isActive, textStyle, containerStyle, shouldShow = true, isSortable = true, onPress}: SearchTableHeaderColumnProps) {
+export default function SortableHeaderText({text, sortOrder, isActive, textStyle, containerStyle, isSortable = true, onPress}: SearchTableHeaderColumnProps) {
const styles = useThemeStyles();
const theme = useTheme();
- if (!shouldShow) {
- return null;
+ if (!isSortable) {
+ return (
+
+
+
+ {text}
+
+
+
+ );
}
const icon = sortOrder === CONST.SORT_ORDER.ASC ? Expensicons.ArrowUpLong : Expensicons.ArrowDownLong;
const displayIcon = isActive;
+ const activeColumnStyle = isSortable && isActive && styles.searchTableHeaderActive;
const nextSortOrder = isActive && sortOrder === CONST.SORT_ORDER.DESC ? CONST.SORT_ORDER.ASC : CONST.SORT_ORDER.DESC;
@@ -46,7 +57,7 @@ export default function SortableHeaderText({text, sortOrder, isActive, textStyle
{text}
diff --git a/src/components/SelectionList/UserListItem.tsx b/src/components/SelectionList/UserListItem.tsx
index a517a9f1ca15..d07ac03c00f5 100644
--- a/src/components/SelectionList/UserListItem.tsx
+++ b/src/components/SelectionList/UserListItem.tsx
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import React, {useCallback} from 'react';
import {View} from 'react-native';
import Icon from '@components/Icon';
@@ -30,6 +30,7 @@ function UserListItem({
rightHandSideComponent,
onFocus,
shouldSyncFocus,
+ pressableStyle,
}: UserListItemProps) {
const styles = useThemeStyles();
const theme = useTheme();
@@ -63,6 +64,7 @@ function UserListItem({
rightHandSideComponent={rightHandSideComponent}
errors={item.errors}
pendingAction={item.pendingAction}
+ pressableStyle={pressableStyle}
FooterComponent={
item.invitedSecondaryLogin ? (
diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts
index 460a467337e2..5d5e7fc3891b 100644
--- a/src/components/SelectionList/types.ts
+++ b/src/components/SelectionList/types.ts
@@ -398,6 +398,9 @@ type BaseSelectionListProps = Partial & {
/** Styles for the section title */
sectionTitleStyles?: StyleProp;
+ /** This may improve scroll performance for large lists */
+ removeClippedSubviews?: boolean;
+
/**
* When true, the list won't be visible until the list layout is measured. This prevents the list from "blinking" as it's scrolled to the bottom which is recommended for large lists.
* When false, the list will render immediately and scroll to the bottom which works great for small lists.
@@ -430,6 +433,7 @@ type BaseSelectionListProps = Partial & {
type SelectionListHandle = {
scrollAndHighlightItem?: (items: string[], timeout: number) => void;
+ clearInputAfterSelect?: () => void;
};
type ItemLayout = {
diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx
index f56c4dd1a863..b6e2a753c829 100644
--- a/src/components/SettlementButton.tsx
+++ b/src/components/SettlementButton.tsx
@@ -18,6 +18,7 @@ import type {LastPaymentMethod, Policy, Report} from '@src/types/onyx';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import type AnchorAlignment from '@src/types/utils/AnchorAlignment';
import type {EmptyObject} from '@src/types/utils/EmptyObject';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
import ButtonWithDropdownMenu from './ButtonWithDropdownMenu';
import type {PaymentType} from './ButtonWithDropdownMenu/types';
import * as Expensicons from './Icon/Expensicons';
@@ -149,9 +150,10 @@ function SettlementButton({
const session = useSession();
const chatReport = ReportUtils.getReport(chatReportID);
+ const isInvoiceReport = (!isEmptyObject(iouReport) && ReportUtils.isInvoiceReport(iouReport)) || false;
const isPaidGroupPolicy = ReportUtils.isPaidGroupPolicyExpenseChat(chatReport);
const shouldShowPaywithExpensifyOption = !isPaidGroupPolicy || (!shouldHidePaymentOptions && ReportUtils.isPayer(session, iouReport as OnyxEntry));
- const shouldShowPayElsewhereOption = !isPaidGroupPolicy || policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL;
+ const shouldShowPayElsewhereOption = (!isPaidGroupPolicy || policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL) && !isInvoiceReport;
const paymentButtonOptions = useMemo(() => {
const buttonOptions = [];
const isExpenseReport = ReportUtils.isExpenseReport(iouReport);
@@ -178,7 +180,7 @@ function SettlementButton({
value: CONST.IOU.REPORT_ACTION_TYPE.APPROVE,
disabled: !!shouldDisableApproveButton,
};
- const canUseWallet = !isExpenseReport && currency === CONST.CURRENCY.USD;
+ const canUseWallet = !isExpenseReport && !isInvoiceReport && currency === CONST.CURRENCY.USD;
// Only show the Approve button if the user cannot pay the expense
if (shouldHidePaymentOptions && shouldShowApproveButton) {
@@ -199,6 +201,23 @@ function SettlementButton({
buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.ELSEWHERE]);
}
+ if (isInvoiceReport) {
+ buttonOptions.push({
+ text: translate('iou.settlePersonal', {formattedAmount}),
+ icon: Expensicons.User,
+ value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE,
+ backButtonText: translate('iou.individual'),
+ subMenuItems: [
+ {
+ text: translate('iou.payElsewhere', {formattedAmount: ''}),
+ icon: Expensicons.Cash,
+ value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE,
+ onSelected: () => onPress(CONST.IOU.PAYMENT_TYPE.ELSEWHERE),
+ },
+ ],
+ });
+ }
+
if (shouldShowApproveButton) {
buttonOptions.push(approveButtonOption);
}
@@ -211,6 +230,7 @@ function SettlementButton({
// We don't want to reorder the options when the preferred payment method changes while the button is still visible
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currency, formattedAmount, iouReport, policyID, translate, shouldHidePaymentOptions, shouldShowApproveButton, shouldDisableApproveButton]);
+
const selectPaymentType = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => {
if (iouPaymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY || iouPaymentType === CONST.IOU.PAYMENT_TYPE.VBBA) {
triggerKYCFlow(event, iouPaymentType);
@@ -252,6 +272,10 @@ function SettlementButton({
success
buttonRef={buttonRef}
+ shouldAlwaysShowDropdownMenu={isInvoiceReport}
+ customText={isInvoiceReport ? translate('iou.settlePayment', {formattedAmount}) : undefined}
+ menuHeaderText={isInvoiceReport ? translate('workspace.invoices.paymentMethods.chooseInvoiceMethod') : undefined}
+ isSplitButton={!isInvoiceReport}
isDisabled={isDisabled}
isLoading={isLoading}
onPress={(event, iouPaymentType) => selectPaymentType(event, iouPaymentType, triggerKYCFlow)}
diff --git a/src/components/SignInButtons/AppleSignIn/index.android.tsx b/src/components/SignInButtons/AppleSignIn/index.android.tsx
index ec669590d029..a528fe7c5a10 100644
--- a/src/components/SignInButtons/AppleSignIn/index.android.tsx
+++ b/src/components/SignInButtons/AppleSignIn/index.android.tsx
@@ -5,6 +5,7 @@ import Log from '@libs/Log';
import * as Session from '@userActions/Session';
import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
+import type {AppleSignInProps} from '.';
/**
* Apple Sign In Configuration for Android.
@@ -33,7 +34,7 @@ function appleSignInRequest(): Promise {
/**
* Apple Sign In button for Android.
*/
-function AppleSignIn() {
+function AppleSignIn({onPress = () => {}}: AppleSignInProps) {
const handleSignIn = () => {
appleSignInRequest()
.then((token) => Session.beginAppleSignIn(token))
@@ -46,7 +47,10 @@ function AppleSignIn() {
};
return (
{
+ onPress();
+ handleSignIn();
+ }}
provider={CONST.SIGN_IN_METHOD.APPLE}
/>
);
diff --git a/src/components/SignInButtons/AppleSignIn/index.ios.tsx b/src/components/SignInButtons/AppleSignIn/index.ios.tsx
index 4df8375edad8..57aae97b9c48 100644
--- a/src/components/SignInButtons/AppleSignIn/index.ios.tsx
+++ b/src/components/SignInButtons/AppleSignIn/index.ios.tsx
@@ -5,6 +5,7 @@ import IconButton from '@components/SignInButtons/IconButton';
import Log from '@libs/Log';
import * as Session from '@userActions/Session';
import CONST from '@src/CONST';
+import type {AppleSignInProps} from '.';
/**
* Apple Sign In method for iOS that returns identityToken.
@@ -32,7 +33,7 @@ function appleSignInRequest(): Promise {
/**
* Apple Sign In button for iOS.
*/
-function AppleSignIn() {
+function AppleSignIn({onPress = () => {}}: AppleSignInProps) {
const handleSignIn = () => {
appleSignInRequest()
.then((token) => Session.beginAppleSignIn(token))
@@ -45,7 +46,10 @@ function AppleSignIn() {
};
return (
{
+ onPress();
+ handleSignIn();
+ }}
provider={CONST.SIGN_IN_METHOD.APPLE}
/>
);
diff --git a/src/components/SignInButtons/AppleSignIn/index.tsx b/src/components/SignInButtons/AppleSignIn/index.tsx
index 9d7322878c98..7ca0261d6c72 100644
--- a/src/components/SignInButtons/AppleSignIn/index.tsx
+++ b/src/components/SignInButtons/AppleSignIn/index.tsx
@@ -24,6 +24,8 @@ type SingletonAppleSignInButtonProps = AppleSignInDivProps & {
type AppleSignInProps = WithNavigationFocusProps & {
isDesktopFlow?: boolean;
+ // eslint-disable-next-line react/no-unused-prop-types
+ onPress?: () => void;
};
/**
@@ -139,3 +141,4 @@ function AppleSignIn({isDesktopFlow = false}: AppleSignInProps) {
AppleSignIn.displayName = 'AppleSignIn';
export default withNavigationFocus(AppleSignIn);
+export type {AppleSignInProps};
diff --git a/src/components/SignInButtons/GoogleSignIn/index.native.tsx b/src/components/SignInButtons/GoogleSignIn/index.native.tsx
index 2744d8958080..3fac942c1279 100644
--- a/src/components/SignInButtons/GoogleSignIn/index.native.tsx
+++ b/src/components/SignInButtons/GoogleSignIn/index.native.tsx
@@ -5,6 +5,7 @@ import Log from '@libs/Log';
import * as Session from '@userActions/Session';
import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
+import type {GoogleSignInProps} from '.';
/**
* Google Sign In method for iOS and android that returns identityToken.
@@ -44,10 +45,13 @@ function googleSignInRequest() {
/**
* Google Sign In button for iOS.
*/
-function GoogleSignIn() {
+function GoogleSignIn({onPress = () => {}}: GoogleSignInProps) {
return (
{
+ onPress();
+ googleSignInRequest();
+ }}
provider={CONST.SIGN_IN_METHOD.GOOGLE}
/>
);
diff --git a/src/components/SignInButtons/GoogleSignIn/index.tsx b/src/components/SignInButtons/GoogleSignIn/index.tsx
index 3cc4cdebffa6..f12d039209f5 100644
--- a/src/components/SignInButtons/GoogleSignIn/index.tsx
+++ b/src/components/SignInButtons/GoogleSignIn/index.tsx
@@ -9,6 +9,8 @@ import type Response from '@src/types/modules/google';
type GoogleSignInProps = {
isDesktopFlow?: boolean;
+ // eslint-disable-next-line react/no-unused-prop-types
+ onPress?: () => void;
};
/** Div IDs for styling the two different Google Sign-In buttons. */
@@ -90,3 +92,4 @@ function GoogleSignIn({isDesktopFlow = false}: GoogleSignInProps) {
GoogleSignIn.displayName = 'GoogleSignIn';
export default GoogleSignIn;
+export type {GoogleSignInProps};
diff --git a/src/components/SingleOptionSelector.tsx b/src/components/SingleOptionSelector.tsx
index 9832167e3f6a..c1088f9cd153 100644
--- a/src/components/SingleOptionSelector.tsx
+++ b/src/components/SingleOptionSelector.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import type {StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -22,9 +23,15 @@ type SingleOptionSelectorProps = {
/** Function to be called when an option is selected */
onSelectOption?: (item: Item) => void;
+
+ /** Styles for the option row element */
+ optionRowStyles?: StyleProp;
+
+ /** Styles for the select circle */
+ selectCircleStyles?: StyleProp;
};
-function SingleOptionSelector({options = [], selectedOptionKey, onSelectOption = () => {}}: SingleOptionSelectorProps) {
+function SingleOptionSelector({options = [], selectedOptionKey, onSelectOption = () => {}, optionRowStyles, selectCircleStyles}: SingleOptionSelectorProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
return (
@@ -35,7 +42,7 @@ function SingleOptionSelector({options = [], selectedOptionKey, onSelectOption =
key={option.key}
>
onSelectOption(option)}
role={CONST.ROLE.BUTTON}
accessibilityState={{checked: selectedOptionKey === option.key}}
@@ -44,7 +51,7 @@ function SingleOptionSelector({options = [], selectedOptionKey, onSelectOption =
>
{translate(option.label)}
diff --git a/src/components/StateSelector.tsx b/src/components/StateSelector.tsx
index 8cae007679ff..67ba80c13ef8 100644
--- a/src/components/StateSelector.tsx
+++ b/src/components/StateSelector.tsx
@@ -1,5 +1,5 @@
import {useIsFocused} from '@react-navigation/native';
-import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST';
+import {CONST as COMMON_CONST} from 'expensify-common';
import React, {useEffect, useRef} from 'react';
import type {ForwardedRef} from 'react';
import type {View} from 'react-native';
diff --git a/src/components/Switch.tsx b/src/components/Switch.tsx
index 3d9b586b8b91..2e29008cd9ec 100644
--- a/src/components/Switch.tsx
+++ b/src/components/Switch.tsx
@@ -63,7 +63,7 @@ function Switch({isOn, onToggle, accessibilityLabel, disabled, showLockIcon}: Sw
pressDimmingValue={0.8}
>
- {(Boolean(disabled) || Boolean(showLockIcon)) && (
+ {(!!disabled || !!showLockIcon) && (
;
+
+ /** The draft transaction that holds data to be persisted on the current split transaction */
+ splitDraftTransaction: OnyxEntry;
};
type TaxPickerProps = TaxPickerOnyxProps & {
@@ -46,13 +51,20 @@ type TaxPickerProps = TaxPickerOnyxProps & {
/** The action to take */
// eslint-disable-next-line react/no-unused-prop-types
action?: IOUAction;
+
+ /** The type of IOU */
+ iouType?: ValueOf;
};
-function TaxPicker({selectedTaxRate = '', policy, transaction, insets, onSubmit}: TaxPickerProps) {
+function TaxPicker({selectedTaxRate = '', policy, transaction, insets, onSubmit, action, splitDraftTransaction, iouType}: TaxPickerProps) {
const StyleUtils = useStyleUtils();
const {translate} = useLocalize();
const [searchValue, setSearchValue] = useState('');
+ const isEditing = action === CONST.IOU.ACTION.EDIT;
+ const isEditingSplitBill = isEditing && iouType === CONST.IOU.TYPE.SPLIT;
+ const currentTransaction = isEditingSplitBill && !isEmptyObject(splitDraftTransaction) ? splitDraftTransaction : transaction;
+
const taxRates = policy?.taxRates;
const taxRatesCount = TransactionUtils.getEnabledTaxRateCount(taxRates?.taxes ?? {});
const isTaxRatesCountBelowThreshold = taxRatesCount < CONST.TAX_RATES_LIST_THRESHOLD;
@@ -74,8 +86,8 @@ function TaxPicker({selectedTaxRate = '', policy, transaction, insets, onSubmit}
}, [selectedTaxRate]);
const sections = useMemo(
- () => OptionsListUtils.getTaxRatesSection(policy, selectedOptions as OptionsListUtils.Tax[], searchValue, transaction),
- [searchValue, selectedOptions, policy, transaction],
+ () => OptionsListUtils.getTaxRatesSection(policy, selectedOptions as OptionsListUtils.Tax[], searchValue, currentTransaction),
+ [searchValue, selectedOptions, policy, currentTransaction],
);
const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(sections[0].data.length > 0, searchValue);
@@ -112,4 +124,7 @@ export default withOnyx({
return `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`;
},
},
+ splitDraftTransaction: {
+ key: ({transactionID}) => `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`,
+ },
})(TaxPicker);
diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx
index e4d33957f7f1..ebfbeffd68df 100644
--- a/src/components/TestToolMenu.tsx
+++ b/src/components/TestToolMenu.tsx
@@ -5,14 +5,11 @@ import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import * as ApiUtils from '@libs/ApiUtils';
import compose from '@libs/compose';
-import Navigation from '@libs/Navigation/Navigation';
import * as Network from '@userActions/Network';
import * as Session from '@userActions/Session';
import * as User from '@userActions/User';
import CONFIG from '@src/CONFIG';
-import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
import type {Network as NetworkOnyx, User as UserOnyx} from '@src/types/onyx';
import Button from './Button';
import {withNetwork} from './OnyxProvider';
@@ -92,17 +89,6 @@ function TestToolMenu({user = USER_DEFAULT, network}: TestToolMenuProps) {
onPress={() => Session.invalidateCredentials()}
/>
-
- {/* Navigate to the new Search Page. This button is temporary and should be removed after passing QA tests. */}
-
- {
- Navigation.navigate(ROUTES.SEARCH.getRoute(CONST.TAB_SEARCH.ALL));
- }}
- />
-
>
);
}
diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx
index 4f0c7dd71116..8f685fb668e7 100644
--- a/src/components/TextInput/BaseTextInput/index.native.tsx
+++ b/src/components/TextInput/BaseTextInput/index.native.tsx
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import type {ForwardedRef} from 'react';
import React, {forwardRef, useCallback, useEffect, useRef, useState} from 'react';
import type {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TextInput, TextInputFocusEventData, ViewStyle} from 'react-native';
@@ -242,7 +242,7 @@ function BaseTextInput(
setPasswordHidden((prevPasswordHidden) => !prevPasswordHidden);
}, []);
- const hasLabel = Boolean(label?.length);
+ const hasLabel = !!label?.length;
const isReadOnly = inputProps.readOnly ?? inputProps.disabled;
// Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and errorText can be an empty string
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx
index e1862e0a6737..3a1032ff7a43 100644
--- a/src/components/TextInput/BaseTextInput/index.tsx
+++ b/src/components/TextInput/BaseTextInput/index.tsx
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import type {ForwardedRef} from 'react';
import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import type {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, ViewStyle} from 'react-native';
@@ -239,7 +239,7 @@ function BaseTextInput(
setPasswordHidden((prevPasswordHidden: boolean | undefined) => !prevPasswordHidden);
}, []);
- const hasLabel = Boolean(label?.length);
+ const hasLabel = !!label?.length;
const isReadOnly = inputProps.readOnly ?? inputProps.disabled;
// Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and errorText can be an empty string
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
@@ -328,7 +328,7 @@ function BaseTextInput(
/>
)}
- {Boolean(prefixCharacter) && (
+ {!!prefixCharacter && (
)}
- {Boolean(inputProps.secureTextEntry) && (
+ {!!inputProps.secureTextEntry && (
void) | undefined;
+ /**
+ * Callback that is called when the text input is pressed up
+ */
+ onMouseUp?: ((e: React.MouseEvent) => void) | undefined;
+
/** Whether the currency symbol is pressable */
isCurrencyPressable: boolean;
diff --git a/src/components/UnitPicker.tsx b/src/components/UnitPicker.tsx
index a9202a348e4d..02f056e04dad 100644
--- a/src/components/UnitPicker.tsx
+++ b/src/components/UnitPicker.tsx
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import React, {useMemo} from 'react';
import useLocalize from '@hooks/useLocalize';
import {getUnitTranslationKey} from '@libs/WorkspacesSettingsUtils';
diff --git a/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx
index 35c0b5e23f1a..f04b92644d89 100644
--- a/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx
+++ b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import React, {useCallback} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
diff --git a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx
index 76efec0879a8..ea7da01e73eb 100644
--- a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx
+++ b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx
@@ -30,7 +30,7 @@ function VideoPlayerThumbnail({thumbnailUrl, onPress, accessibilityLabel}: Video
return (
{thumbnailUrl && (
-
+ {
+ * // This will run not just when myArray is a new array, but also when its contents change.
+ * }, [deepComparedArray]);
+ */
+export default function useDeepCompareRef(value: T): T | undefined {
+ const ref = useRef();
+ if (!isEqual(value, ref.current)) {
+ ref.current = value;
+ }
+ return ref.current;
+}
diff --git a/src/hooks/useGeographicalStateFromRoute.ts b/src/hooks/useGeographicalStateFromRoute.ts
index 13936ee78f5b..434d4c534d61 100644
--- a/src/hooks/useGeographicalStateFromRoute.ts
+++ b/src/hooks/useGeographicalStateFromRoute.ts
@@ -1,6 +1,6 @@
import {useRoute} from '@react-navigation/native';
import type {ParamListBase, RouteProp} from '@react-navigation/native';
-import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST';
+import {CONST as COMMON_CONST} from 'expensify-common';
type CustomParamList = ParamListBase & Record>;
type State = keyof typeof COMMON_CONST.STATES;
diff --git a/src/hooks/useHtmlPaste/index.ts b/src/hooks/useHtmlPaste/index.ts
index 925a3db518ae..a6d8993888cc 100644
--- a/src/hooks/useHtmlPaste/index.ts
+++ b/src/hooks/useHtmlPaste/index.ts
@@ -1,5 +1,5 @@
import {useNavigation} from '@react-navigation/native';
-import ExpensiMark from 'expensify-common/lib/ExpensiMark';
+import {ExpensiMark} from 'expensify-common';
import {useCallback, useEffect} from 'react';
import type UseHtmlPaste from './types';
diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts
index b1f430e232e4..d1af33aa9da5 100644
--- a/src/hooks/useMarkdownStyle.ts
+++ b/src/hooks/useMarkdownStyle.ts
@@ -28,6 +28,12 @@ function useMarkdownStyle(message: string | null = null): MarkdownStyle {
borderWidth: 4,
marginLeft: 0,
paddingLeft: 6,
+ /**
+ * since blockquote has `inline-block` display -> padding-right is needed to prevent cursor overlapping
+ * with last character of the text node.
+ * As long as paddingRight > cursor.width, cursor will be displayed correctly.
+ */
+ paddingRight: 1,
},
code: {
fontFamily: FontUtils.fontFamily.platform.MONOSPACE,
diff --git a/src/hooks/useMouseContext.tsx b/src/hooks/useMouseContext.tsx
new file mode 100644
index 000000000000..84889c4b59cd
--- /dev/null
+++ b/src/hooks/useMouseContext.tsx
@@ -0,0 +1,33 @@
+import type {ReactNode} from 'react';
+import React, {createContext, useContext, useMemo, useState} from 'react';
+
+type MouseContextProps = {
+ isMouseDownOnInput: boolean;
+ setMouseDown: () => void;
+ setMouseUp: () => void;
+};
+
+const MouseContext = createContext({
+ isMouseDownOnInput: false,
+ setMouseDown: () => {},
+ setMouseUp: () => {},
+});
+
+type MouseProviderProps = {
+ children: ReactNode;
+};
+
+function MouseProvider({children}: MouseProviderProps) {
+ const [isMouseDownOnInput, setIsMouseDownOnInput] = useState(false);
+
+ const setMouseDown = () => setIsMouseDownOnInput(true);
+ const setMouseUp = () => setIsMouseDownOnInput(false);
+
+ const value = useMemo(() => ({isMouseDownOnInput, setMouseDown, setMouseUp}), [isMouseDownOnInput]);
+
+ return {children};
+}
+
+const useMouseContext = () => useContext(MouseContext);
+
+export {MouseProvider, useMouseContext};
diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts
index 6666a4ebd0f2..e7cdfa5b45da 100644
--- a/src/hooks/useReportScrollManager/index.native.ts
+++ b/src/hooks/useReportScrollManager/index.native.ts
@@ -8,13 +8,16 @@ function useReportScrollManager(): ReportScrollManagerData {
/**
* Scroll to the provided index.
*/
- const scrollToIndex = (index: number) => {
- if (!flatListRef?.current) {
- return;
- }
-
- flatListRef.current.scrollToIndex({index});
- };
+ const scrollToIndex = useCallback(
+ (index: number) => {
+ if (!flatListRef?.current) {
+ return;
+ }
+
+ flatListRef.current.scrollToIndex({index});
+ },
+ [flatListRef],
+ );
/**
* Scroll to the bottom of the flatlist.
diff --git a/src/hooks/useReportScrollManager/index.ts b/src/hooks/useReportScrollManager/index.ts
index c60ba771917e..eb68f52fc760 100644
--- a/src/hooks/useReportScrollManager/index.ts
+++ b/src/hooks/useReportScrollManager/index.ts
@@ -8,13 +8,16 @@ function useReportScrollManager(): ReportScrollManagerData {
/**
* Scroll to the provided index. On non-native implementations we do not want to scroll when we are scrolling because
*/
- const scrollToIndex = (index: number, isEditing?: boolean) => {
- if (!flatListRef?.current || isEditing) {
- return;
- }
+ const scrollToIndex = useCallback(
+ (index: number, isEditing?: boolean) => {
+ if (!flatListRef?.current || isEditing) {
+ return;
+ }
- flatListRef.current.scrollToIndex({index, animated: true});
- };
+ flatListRef.current.scrollToIndex({index, animated: true});
+ },
+ [flatListRef],
+ );
/**
* Scroll to the bottom of the flatlist.
diff --git a/src/hooks/useResponsiveLayout.ts b/src/hooks/useResponsiveLayout.ts
index bf0b77ef915c..a26d50bc56b9 100644
--- a/src/hooks/useResponsiveLayout.ts
+++ b/src/hooks/useResponsiveLayout.ts
@@ -1,15 +1,14 @@
-import {useEffect, useRef, useState} from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
-import {useOnyx} from 'react-native-onyx';
-import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
-import ONYXKEYS from '@src/ONYXKEYS';
-import type {Modal} from '@src/types/onyx';
+import {NavigationContainerRefContext, NavigationContext} from '@react-navigation/native';
+import {useContext, useMemo} from 'react';
+import ModalContext from '@components/Modal/ModalContext';
+import CONST from '@src/CONST';
+import NAVIGATORS from '@src/NAVIGATORS';
import useWindowDimensions from './useWindowDimensions';
type ResponsiveLayoutResult = {
shouldUseNarrowLayout: boolean;
isSmallScreenWidth: boolean;
- isInModal: boolean;
+ isInNarrowPaneModal: boolean;
isExtraSmallScreenHeight: boolean;
isMediumScreenWidth: boolean;
isLargeScreenWidth: boolean;
@@ -19,39 +18,59 @@ type ResponsiveLayoutResult = {
/**
* Hook to determine if we are on mobile devices or in the Modal Navigator.
- * Use "shouldUseNarrowLayout" for "on mobile or in RHP/LHP", "isSmallScreenWidth" for "on mobile", "isInModal" for "in RHP/LHP".
+ * Use "shouldUseNarrowLayout" for "on mobile or in RHP/LHP", "isSmallScreenWidth" for "on mobile", "isInNarrowPaneModal" for "in RHP/LHP".
+ *
+ * There are two kinds of modals in this app:
+ * 1. Modal stack navigators from react-navigation
+ * 2. Modal components that use react-native-modal
+ *
+ * This hook is designed to handle both. `shouldUseNarrowLayout` will return `true` if any of the following are true:
+ * 1. The device screen width is narrow
+ * 2. The consuming component is the child of a "right docked" react-native-modal component
+ * 3. The consuming component is a screen in a modal stack navigator and not a child of a "non-right-docked" react-native-modal component.
+ *
+ * For more details on the various modal types we've defined for this app and implemented using react-native-modal, see `ModalType`.
*/
export default function useResponsiveLayout(): ResponsiveLayoutResult {
const {isSmallScreenWidth, isExtraSmallScreenHeight, isExtraSmallScreenWidth, isMediumScreenWidth, isLargeScreenWidth, isSmallScreen} = useWindowDimensions();
- const [willAlertModalBecomeVisible] = useOnyx(ONYXKEYS.MODAL, {selector: (value: OnyxEntry) => value?.willAlertModalBecomeVisible ?? false});
-
- const [isInModal, setIsInModal] = useState(false);
- const hasSetIsInModal = useRef(false);
- const updateModalStatus = () => {
- if (hasSetIsInModal.current) {
- return;
- }
- const isDisplayedInModal = Navigation.isDisplayedInModal();
- if (isInModal !== isDisplayedInModal) {
- setIsInModal(isDisplayedInModal);
- }
- hasSetIsInModal.current = true;
- };
+ // Note: activeModalType refers to our react-native-modal component wrapper, not react-navigation's modal stack navigators.
+ // This means it will only be defined if the component calling this hook is a child of a modal component. See BaseModal for the provider.
+ const {activeModalType} = useContext(ModalContext);
+
+ // We are using these contexts directly instead of useNavigation/useNavigationState, because those will throw an error if used outside a navigator.
+ // This hook can be used within or outside a navigator, so using useNavigationState does not work.
+ // Furthermore, wrapping useNavigationState in a try/catch does not work either, because that breaks the rules of hooks.
+ // Note that these three lines are copied closely from the internal implementation of useNavigation: https://github.com/react-navigation/react-navigation/blob/52a3234b7aaf4d4fcc9c0155f44f3ea2233f0f40/packages/core/src/useNavigation.tsx#L18-L28
+ const navigationContainerRef = useContext(NavigationContainerRefContext);
+ const navigator = useContext(NavigationContext);
+ const currentNavigator = navigator ?? navigationContainerRef;
- useEffect(() => {
- const unsubscribe = navigationRef?.current?.addListener('state', updateModalStatus);
+ const isDisplayedInNarrowModalNavigator = useMemo(
+ () =>
+ !!currentNavigator?.getParent?.(NAVIGATORS.RIGHT_MODAL_NAVIGATOR as unknown as undefined) ||
+ !!currentNavigator?.getParent?.(NAVIGATORS.LEFT_MODAL_NAVIGATOR as unknown as undefined),
+ [currentNavigator],
+ );
- if (navigationRef?.current?.isReady()) {
- updateModalStatus();
- }
+ // The component calling this hook is in a "narrow pane modal" if:
+ const isInNarrowPaneModal =
+ // it's a child of the right-docked modal
+ activeModalType === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED ||
+ // or there's a "right modal navigator" or "left modal navigator" on the top of the root navigation stack
+ // and the component calling this hook is not the child of another modal type, such as a confirm modal
+ (isDisplayedInNarrowModalNavigator && !activeModalType);
- return () => {
- unsubscribe?.();
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ const shouldUseNarrowLayout = isSmallScreenWidth || isInNarrowPaneModal;
- const shouldUseNarrowLayout = willAlertModalBecomeVisible ? isSmallScreenWidth : isSmallScreenWidth || isInModal;
- return {shouldUseNarrowLayout, isSmallScreenWidth, isInModal, isExtraSmallScreenHeight, isExtraSmallScreenWidth, isMediumScreenWidth, isLargeScreenWidth, isSmallScreen};
+ return {
+ shouldUseNarrowLayout,
+ isSmallScreenWidth,
+ isInNarrowPaneModal,
+ isExtraSmallScreenHeight,
+ isExtraSmallScreenWidth,
+ isMediumScreenWidth,
+ isLargeScreenWidth,
+ isSmallScreen,
+ };
}
diff --git a/src/hooks/useSubscriptionPlan.ts b/src/hooks/useSubscriptionPlan.ts
new file mode 100644
index 000000000000..5ac5066ba965
--- /dev/null
+++ b/src/hooks/useSubscriptionPlan.ts
@@ -0,0 +1,32 @@
+import {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import {isPolicyOwner} from '@libs/PolicyUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+
+function useSubscriptionPlan() {
+ const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
+ const [session] = useOnyx(ONYXKEYS.SESSION);
+
+ // Filter workspaces in which user is the owner and the type is either corporate (control) or team (collect)
+ const ownerPolicies = useMemo(
+ () =>
+ Object.values(policies ?? {}).filter(
+ (policy) => isPolicyOwner(policy, session?.accountID ?? -1) && (CONST.POLICY.TYPE.CORPORATE === policy?.type || CONST.POLICY.TYPE.TEAM === policy?.type),
+ ),
+ [policies, session?.accountID],
+ );
+
+ if (isEmptyObject(ownerPolicies)) {
+ return null;
+ }
+
+ // Check if user has corporate (control) workspace
+ const hasControlWorkspace = ownerPolicies.some((policy) => policy?.type === CONST.POLICY.TYPE.CORPORATE);
+
+ // Corporate (control) workspace is supposed to be the higher priority
+ return hasControlWorkspace ? CONST.POLICY.TYPE.CORPORATE : CONST.POLICY.TYPE.TEAM;
+}
+
+export default useSubscriptionPlan;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index eb18de0b8d70..76e4d5d5a143 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -1,5 +1,4 @@
-import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST';
-import Str from 'expensify-common/lib/str';
+import {CONST as COMMON_CONST, Str} from 'expensify-common';
import CONST from '@src/CONST';
import type {Country} from '@src/CONST';
import type {ConnectionName, PolicyConnectionSyncStage} from '@src/types/onyx/Policy';
@@ -33,7 +32,6 @@ import type {
LogSizeParams,
ManagerApprovedAmountParams,
ManagerApprovedParams,
- NewFaceEnterMagicCodeParams,
NoLongerHaveAccessParams,
NotAllowedExtensionParams,
NotYouParams,
@@ -66,6 +64,7 @@ import type {
SetTheRequestParams,
SettledAfterAddedBankAccountParams,
SettleExpensifyCardParams,
+ SignUpNewFaceCodeParams,
SizeExceededParams,
SplitAmountParams,
StepCounterParams,
@@ -125,6 +124,7 @@ export default {
buttonConfirm: 'Got it',
name: 'Name',
attachment: 'Attachment',
+ center: 'Center',
from: 'From',
to: 'To',
optional: 'Optional',
@@ -148,7 +148,7 @@ export default {
magicCode: 'Magic code',
twoFactorCode: 'Two-factor code',
workspaces: 'Workspaces',
- chats: 'Chats',
+ inbox: 'Inbox',
group: 'Group',
profile: 'Profile',
referral: 'Referral',
@@ -164,6 +164,7 @@ export default {
continue: 'Continue',
firstName: 'First name',
lastName: 'Last name',
+ addCardTermsOfService: 'Expensify Terms of Service',
phone: 'Phone',
phoneNumber: 'Phone number',
phoneNumberPlaceholder: '(xxx) xxx-xxxx',
@@ -171,6 +172,7 @@ export default {
and: 'and',
details: 'Details',
privacy: 'Privacy',
+ privacyPolicy: 'Privacy Policy',
hidden: 'Hidden',
visible: 'Visible',
delete: 'Delete',
@@ -186,7 +188,6 @@ export default {
saveAndContinue: 'Save & continue',
settings: 'Settings',
termsOfService: 'Terms of Service',
- expensifyTermsOfService: 'Expensify Terms of Service',
members: 'Members',
invite: 'Invite',
here: 'here',
@@ -224,6 +225,7 @@ export default {
tomorrowAt: 'Tomorrow at',
yesterdayAt: 'Yesterday at',
conjunctionAt: 'at',
+ conjunctionTo: 'to',
genericErrorMessage: 'Oops... something went wrong and your request could not be completed. Please try again later.',
error: {
invalidAmount: 'Invalid amount.',
@@ -241,6 +243,7 @@ export default {
enterAmount: 'Enter an amount.',
enterDate: 'Enter a date.',
invalidTimeRange: 'Please enter a time using the 12-hour clock format (e.g., 2:30 PM).',
+ pleaseCompleteForm: 'Please complete the form above to continue.',
},
comma: 'comma',
semicolon: 'semicolon',
@@ -334,6 +337,9 @@ export default {
action: 'Action',
expenses: 'Expenses',
tax: 'Tax',
+ shared: 'Shared',
+ drafts: 'Drafts',
+ finished: 'Finished',
},
location: {
useCurrent: 'Use current location',
@@ -351,7 +357,7 @@ export default {
expensifyDoesntHaveAccessToCamera: "Expensify can't take photos without access to your camera. Tap Settings to update permissions.",
attachmentError: 'Attachment error',
errorWhileSelectingAttachment: 'An error occurred while selecting an attachment, please try again.',
- errorWhileSelectingCorruptedImage: 'An error occurred while selecting a corrupted attachment, please try another file.',
+ errorWhileSelectingCorruptedAttachment: 'An error occurred while selecting a corrupted attachment, please try another file.',
takePhoto: 'Take photo',
chooseFromGallery: 'Choose from gallery',
chooseDocument: 'Choose document',
@@ -425,11 +431,11 @@ export default {
anotherLoginPageIsOpen: 'Another login page is open.',
anotherLoginPageIsOpenExplanation: "You've opened the login page in a separate tab, please login from that specific tab.",
welcome: 'Welcome!',
+ welcomeWithoutExclamation: 'Welcome',
phrase2: "Money talks. And now that chat and payments are in one place, it's also easy.",
phrase3: 'Your payments get to you as fast as you can get your point across.',
enterPassword: 'Please enter your password',
- newFaceEnterMagicCode: ({login}: NewFaceEnterMagicCodeParams) =>
- `It's always great to see a new face around here! Please enter the magic code sent to ${login}. It should arrive within a minute or two.`,
+ welcomeNewFace: ({login}: SignUpNewFaceCodeParams) => `${login}, it's always great to see a new face around here!`,
welcomeEnterMagicCode: ({login}: WelcomeEnterMagicCodeParams) => `Please enter the magic code sent to ${login}. It should arrive within a minute or two.`,
},
login: {
@@ -507,7 +513,6 @@ export default {
beginningOfChatHistoryDomainRoomPartTwo: ' to chat with colleagues, share tips, and ask questions.',
beginningOfChatHistoryAdminRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAdminRoomPartOneParams) => `Collaboration among ${workspaceName} admins starts here! 🎉\nUse `,
beginningOfChatHistoryAdminRoomPartTwo: ' to chat about topics such as workspace configurations and more.',
- beginningOfChatHistoryAdminOnlyPostingRoom: 'Only admins can send messages in this room.',
beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartOneParams) =>
`Collaboration between all ${workspaceName} members starts here! 🎉\nUse `,
beginningOfChatHistoryAnnounceRoomPartTwo: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartTwo) => ` to chat about anything ${workspaceName} related.`,
@@ -533,6 +538,7 @@ export default {
invoice: 'invoice an expense',
},
},
+ adminOnlyCanPost: 'Only admins can send messages in this room.',
reportAction: {
asCopilot: 'as copilot for',
},
@@ -673,7 +679,11 @@ export default {
deleteConfirmation: 'Are you sure that you want to delete this expense?',
settledExpensify: 'Paid',
settledElsewhere: 'Paid elsewhere',
+ individual: 'Individual',
settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} with Expensify` : `Pay with Expensify`),
+ settlePersonal: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} as an individual` : `Pay as an individual`),
+ settlePayment: ({formattedAmount}: SettleExpensifyCardParams) => `Pay ${formattedAmount}`,
+ settleBusiness: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} as a business` : `Pay as a business`),
payElsewhere: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} elsewhere` : `Pay elsewhere`),
nextStep: 'Next Steps',
finished: 'Finished',
@@ -726,6 +736,8 @@ export default {
other: 'Unexpected error, please try again later.',
genericCreateFailureMessage: 'Unexpected error submitting this expense. Please try again later.',
genericCreateInvoiceFailureMessage: 'Unexpected error sending invoice, please try again later.',
+ genericHoldExpenseFailureMessage: 'Unexpected error while holding the expense. Please try again later.',
+ genericUnholdExpenseFailureMessage: 'Unexpected error while taking the expense off hold. Please try again later.',
receiptDeleteFailureError: 'Unexpected error deleting this receipt. Please try again later.',
// eslint-disable-next-line rulesdir/use-periods-for-error-messages
receiptFailureMessage: "The receipt didn't upload. ",
@@ -751,6 +763,8 @@ export default {
reason: 'Reason',
holdReasonRequired: 'A reason is required when holding.',
expenseOnHold: 'This expense was put on hold. Review the comments for next steps.',
+ expenseDuplicate: 'This expense has the same details as another one. Review the duplicates to remove the hold.',
+ reviewDuplicates: 'Review duplicates',
confirmApprove: 'Confirm approval amount',
confirmApprovalAmount: "Approve what's not on hold, or approve the entire report.",
confirmPay: 'Confirm payment amount',
@@ -1060,6 +1074,29 @@ export default {
password: 'Please enter your Expensify password.',
},
},
+ addPaymentCardPage: {
+ addAPaymentCard: 'Add payment card',
+ nameOnCard: 'Name on card',
+ paymentCardNumber: 'Card number',
+ expiration: 'Expiration date',
+ expirationDate: 'MMYY',
+ cvv: 'CVV',
+ billingAddress: 'Billing address',
+ growlMessageOnSave: 'Your payment card was successfully added',
+ expensifyPassword: 'Expensify password',
+ error: {
+ invalidName: 'Name can only include letters.',
+ addressZipCode: 'Please enter a valid zip code.',
+ paymentCardNumber: 'Please enter a valid card number.',
+ expirationDate: 'Please select a valid expiration date.',
+ securityCode: 'Please enter a valid security code.',
+ addressStreet: 'Please enter a valid billing address that is not a PO Box.',
+ addressState: 'Please select a state.',
+ addressCity: 'Please enter a city.',
+ genericFailureMessage: 'An error occurred while adding your card, please try again.',
+ password: 'Please enter your Expensify password.',
+ },
+ },
walletPage: {
paymentMethodsTitle: 'Payment methods',
setDefaultConfirmation: 'Make default payment method',
@@ -1424,6 +1461,9 @@ export default {
onceTheAbove: 'Once the above steps are completed, please reach out to ',
toUnblock: ' to unblock your login.',
},
+ welcomeSignUpForm: {
+ join: 'Join',
+ },
detailsPage: {
localTime: 'Local time',
},
@@ -1513,7 +1553,9 @@ export default {
checkHelpLine: 'Your routing number and account number can be found on a check for the account.',
validateAccountError: {
phrase1: 'Hold up! We need you to validate your account first. To do so, ',
- phrase2: 'sign back in with a magic code',
+ phrase2: 'sign back in with a magic code ',
+ phrase3: 'or ',
+ phrase4: 'verify your account here',
},
hasPhoneLoginError: 'To add a verified bank account please ensure your primary login is a valid email and try again. You can add your phone number as a secondary login.',
hasBeenThrottledError: 'There was an error adding your bank account. Please wait a few minutes and try again.',
@@ -1867,6 +1909,7 @@ export default {
alerts: 'Get realtime updates and alerts',
},
bookTravel: 'Book travel',
+ bookDemo: 'Book demo',
termsAndConditions: {
header: 'Before we continue...',
title: 'Please read the Terms & Conditions for travel',
@@ -1879,6 +1922,13 @@ export default {
agree: 'I agree to the travel ',
error: 'You must accept the Terms & Conditions for travel to continue',
},
+ flight: 'Flight',
+ hotel: 'Hotel',
+ car: 'Car',
+ viewTrip: 'View trip',
+ trip: 'Trip',
+ tripSummary: 'Trip summary',
+ departs: 'Departs',
},
workspace: {
common: {
@@ -2240,6 +2290,8 @@ export default {
foreignDefault: 'Foreign currency default',
customTaxName: 'Custom tax name',
value: 'Value',
+ taxReclaimableOn: 'Tax reclaimable on',
+ taxRate: 'Tax rate',
error: {
taxRateAlreadyExists: 'This tax name is already in use.',
valuePercentageRange: 'Please enter a valid percentage between 0 and 100.',
@@ -2247,6 +2299,7 @@ export default {
deleteFailureMessage: 'An error occurred while deleting the tax rate. Please try again or ask Concierge for help.',
updateFailureMessage: 'An error occurred while updating the tax rate. Please try again or ask Concierge for help.',
createFailureMessage: 'An error occurred while creating the tax rate. Please try again or ask Concierge for help.',
+ updateTaxClaimableFailureMessage: 'The reclaimable portion must be less than the distance rate amount.',
},
deleteTaxConfirmation: 'Are you sure you want to delete this tax?',
deleteMultipleTaxConfirmation: ({taxAmount}) => `Are you sure you want to delete ${taxAmount} taxes?`,
@@ -2340,7 +2393,7 @@ export default {
subtitle: 'Connect to your accounting system to code transactions with your chart of accounts, auto-match payments, and keep your finances in sync.',
qbo: 'Quickbooks Online',
xero: 'Xero',
- setup: 'Set up',
+ setup: 'Connect',
lastSync: 'Last synced just now',
import: 'Import',
export: 'Export',
@@ -2492,6 +2545,14 @@ export default {
viewUnpaidInvoices: 'View unpaid invoices',
sendInvoice: 'Send invoice',
sendFrom: 'Send from',
+ paymentMethods: {
+ personal: 'Personal',
+ business: 'Business',
+ chooseInvoiceMethod: 'Choose a payment method below:',
+ addBankAccount: 'Add bank account',
+ payingAsIndividual: 'Paying as an individual',
+ payingAsBusiness: 'Paying as a business',
+ },
},
travel: {
unlockConciergeBookingTravel: 'Unlock Concierge travel booking',
@@ -2528,12 +2589,15 @@ export default {
centrallyManage: 'Centrally manage rates, choose to track in miles or kilometers, and set a default category.',
rate: 'Rate',
addRate: 'Add rate',
+ trackTax: 'Track tax',
deleteRates: ({count}: DistanceRateOperationsParams) => `Delete ${Str.pluralize('rate', 'rates', count)}`,
enableRates: ({count}: DistanceRateOperationsParams) => `Enable ${Str.pluralize('rate', 'rates', count)}`,
disableRates: ({count}: DistanceRateOperationsParams) => `Disable ${Str.pluralize('rate', 'rates', count)}`,
enableRate: 'Enable rate',
status: 'Status',
unit: 'Unit',
+ taxFeatureNotEnabledMessage: 'Taxes must be enabled on the workspace to use this feature. Head over to ',
+ changePromptMessage: ' to make that change.',
defaultCategory: 'Default category',
deleteDistanceRate: 'Delete distance rate',
areYouSureDelete: ({count}: DistanceRateOperationsParams) => `Are you sure you want to delete ${Str.pluralize('this rate', 'these rates', count)}?`,
@@ -3059,7 +3123,7 @@ export default {
categoryOutOfPolicy: 'Category no longer valid',
conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams) => `Applied ${surcharge}% conversion surcharge`,
customUnitOutOfPolicy: 'Unit no longer valid',
- duplicatedTransaction: 'Potential duplicate',
+ duplicatedTransaction: 'Duplicate',
fieldRequired: 'Report fields are required',
futureDate: 'Future date not allowed',
invoiceMarkup: ({invoiceMarkup}: ViolationsInvoiceMarkupParams) => `Marked up by ${invoiceMarkup}%`,
@@ -3144,4 +3208,99 @@ export default {
systemMessage: {
mergedWithCashTransaction: 'matched a receipt to this transaction.',
},
+ subscription: {
+ mobileReducedFunctionalityMessage: 'You can’t make changes to your subscription in the mobile app.',
+ cardSection: {
+ title: 'Payment',
+ subtitle: 'Add a payment card to pay for your Expensify subscription.',
+ addCardButton: 'Add payment card',
+ cardNextPayment: 'Your next payment date is',
+ cardEnding: ({cardNumber}) => `Card ending in ${cardNumber}`,
+ cardInfo: ({name, expiration, currency}) => `Name: ${name}, Expiration: ${expiration}, Currency: ${currency}`,
+ changeCard: 'Change payment card',
+ changeCurrency: 'Change payment currency',
+ cardNotFound: 'No payment card added',
+ retryPaymentButton: 'Retry payment',
+ },
+ yourPlan: {
+ title: 'Your plan',
+ collect: {
+ title: 'Collect',
+ priceAnnual: 'From $5/active member with the Expensify Card, $10/active member without the Expensify Card.',
+ pricePayPerUse: 'From $10/active member with the Expensify Card, $20/active member without the Expensify Card.',
+ benefit1: 'Unlimited SmartScans and distance tracking',
+ benefit2: 'Expensify Cards with Smart Limits',
+ benefit3: 'Bill pay and invoicing',
+ benefit4: 'Expense approvals',
+ benefit5: 'ACH reimbursement',
+ benefit6: 'QuickBooks and Xero integrations',
+ benefit7: 'Custom insights and reporting',
+ },
+ control: {
+ title: 'Control',
+ priceAnnual: 'From $9/active member with the Expensify Card, $18/active member without the Expensify Card.',
+ pricePayPerUse: 'From $18/active member with the Expensify Card, $36/active member without the Expensify Card.',
+ benefit1: 'Everything in Collect, plus:',
+ benefit2: 'NetSuite and Sage Intacct integrations',
+ benefit3: 'Certinia and Workday sync',
+ benefit4: 'Multiple expense approvers',
+ benefit5: 'SAML/SSO',
+ benefit6: 'Budgeting',
+ },
+ saveWithExpensifyTitle: 'Save with the Expensify Card',
+ saveWithExpensifyDescription: 'Use our savings calculator to see how cash back from the Expensify Card can reduce your Expensify bill.',
+ saveWithExpensifyButton: 'Learn more',
+ },
+ details: {
+ title: 'Subscription details',
+ annual: 'Annual subscription',
+ payPerUse: 'Pay-per-use',
+ subscriptionSize: 'Subscription size',
+ headsUpTitle: 'Heads up: ',
+ headsUpBody:
+ "If you don’t set your subscription size now, we’ll set it automatically to your first month's active member count. You’ll then be committed to paying for at least this number of members for the next 12 months. You can increase your subscription size at any time, but you can’t decrease it until your subscription is over.",
+ zeroCommitment: 'Zero commitment at the discounted annual subscription rate',
+ },
+ subscriptionSize: {
+ title: 'Subscription size',
+ yourSize: 'Your subscription size is the number of open seats that can be filled by any active member in a given month.',
+ eachMonth:
+ 'Each month, your subscription covers up to the number of active members set above. Any time you increase your subscription size, you’ll start a new 12-month subscription at that new size.',
+ note: 'Note: An active member is anyone who has created, edited, submitted, approved, reimbursed, or exported expense data tied to your company workspace.',
+ confirmDetails: 'Confirm your new annual subscription details',
+ subscriptionSize: 'Subscription size',
+ activeMembers: ({size}) => `${size} active members/month`,
+ subscriptionRenews: 'Subscription renews',
+ youCantDowngrade: 'You can’t downgrade during your annual subscription',
+ youAlreadyCommitted: ({size, date}) =>
+ `You already committed to an annual subscription size of ${size} active members per month until ${date}. You can switch to a pay-per-use subscription on ${date} by disabling auto-renew.`,
+ error: {
+ size: 'Please enter a valid subscription size.',
+ },
+ },
+ paymentCard: {
+ addPaymentCard: 'Add payment card',
+ enterPaymentCardDetails: 'Enter your payment card details.',
+ security: 'Expensify is PCI-DSS compliant, uses bank-level encryption, and utilizes redundant infrastructure to protect your data.',
+ learnMoreAboutSecurity: 'Learn more about our security.',
+ },
+ subscriptionSettings: {
+ title: 'Subscription settings',
+ autoRenew: 'Auto-renew',
+ autoIncrease: 'Auto-increase annual seats',
+ saveUpTo: ({amountSaved}) => `Save up to $${amountSaved}/month per active member`,
+ automaticallyIncrease:
+ 'Automatically increase your annual seats to accommodate for active members that exceed your subscription size. Note: This will extend your annual subscription end date.',
+ disableAutoRenew: 'Disable auto-renew',
+ helpUsImprove: 'Help us improve Expensify',
+ whatsMainReason: 'What’s the main reason you’re disabling auto-renew on your subscription?',
+ renewsOn: ({date}) => `Renews on ${date}`,
+ },
+ },
+ feedbackSurvey: {
+ tooLimited: 'Functionality needs improvement',
+ tooExpensive: 'Too expensive',
+ inadequateSupport: 'Inadequate customer support',
+ businessClosing: 'Company closing, downsizing, or acquired',
+ },
} satisfies TranslationBase;
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 735fbf37c59f..d03e13d1a9ff 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import CONST from '@src/CONST';
import type {ConnectionName, PolicyConnectionSyncStage} from '@src/types/onyx/Policy';
import type {
@@ -32,7 +32,6 @@ import type {
LogSizeParams,
ManagerApprovedAmountParams,
ManagerApprovedParams,
- NewFaceEnterMagicCodeParams,
NoLongerHaveAccessParams,
NotAllowedExtensionParams,
NotYouParams,
@@ -65,6 +64,7 @@ import type {
SetTheRequestParams,
SettledAfterAddedBankAccountParams,
SettleExpensifyCardParams,
+ SignUpNewFaceCodeParams,
SizeExceededParams,
SplitAmountParams,
StepCounterParams,
@@ -119,6 +119,7 @@ export default {
to: 'A',
optional: 'Opcional',
new: 'Nuevo',
+ center: 'Centrar',
search: 'Buscar',
find: 'Encontrar',
searchWithThreeDots: 'Buscar...',
@@ -138,7 +139,7 @@ export default {
magicCode: 'Código mágico',
twoFactorCode: 'Autenticación de dos factores',
workspaces: 'Espacios de trabajo',
- chats: 'Chats',
+ inbox: 'Bandeja de entrada',
group: 'Grupo',
profile: 'Perfil',
referral: 'Remisión',
@@ -147,6 +148,8 @@ export default {
preferences: 'Preferencias',
view: 'Ver',
not: 'No',
+ privacyPolicy: 'la Política de Privacidad de Expensify',
+ addCardTermsOfService: 'Términos de Servicio',
signIn: 'Conectarse',
signInWithGoogle: 'Iniciar sesión con Google',
signInWithApple: 'Iniciar sesión con Apple',
@@ -176,7 +179,6 @@ export default {
saveAndContinue: 'Guardar y continuar',
settings: 'Configuración',
termsOfService: 'Términos de Servicio',
- expensifyTermsOfService: 'Términos de Servicio de Expensify',
members: 'Miembros',
invite: 'Invitar',
here: 'aquí',
@@ -214,6 +216,7 @@ export default {
tomorrowAt: 'Mañana a las',
yesterdayAt: 'Ayer a las',
conjunctionAt: 'a',
+ conjunctionTo: 'a',
genericErrorMessage: 'Ups... algo no ha ido bien y la acción no se ha podido completar. Por favor, inténtalo más tarde.',
error: {
invalidAmount: 'Importe no válido.',
@@ -231,6 +234,7 @@ export default {
enterAmount: 'Introduce un importe.',
enterDate: 'Introduce una fecha.',
invalidTimeRange: 'Por favor, introduce una hora entre 1 y 12 (por ejemplo, 2:30 PM).',
+ pleaseCompleteForm: 'Por favor complete el formulario de arriba para continuar..',
},
comma: 'la coma',
semicolon: 'el punto y coma',
@@ -324,6 +328,9 @@ export default {
action: 'Acción',
expenses: 'Gastos',
tax: 'Impuesto',
+ shared: 'Compartidos',
+ drafts: 'Borradores',
+ finished: 'Finalizados',
},
connectionComplete: {
title: 'Conexión Completa',
@@ -345,7 +352,7 @@ export default {
expensifyDoesntHaveAccessToCamera: 'Expensify no puede tomar fotos sin acceso a la cámara. Haz click en Configuración para actualizar los permisos.',
attachmentError: 'Error al adjuntar archivo',
errorWhileSelectingAttachment: 'Ha ocurrido un error al seleccionar un archivo adjunto. Por favor, inténtalo de nuevo.',
- errorWhileSelectingCorruptedImage: 'Ha ocurrido un error al seleccionar un archivo adjunto corrupto. Por favor, inténtalo con otro archivo.',
+ errorWhileSelectingCorruptedAttachment: 'Ha ocurrido un error al seleccionar un archivo adjunto corrupto. Por favor, inténtalo con otro archivo.',
takePhoto: 'Hacer una foto',
chooseFromGallery: 'Elegir de la galería',
chooseDocument: 'Elegir documento',
@@ -416,11 +423,11 @@ export default {
anotherLoginPageIsOpen: 'Otra página de inicio de sesión está abierta.',
anotherLoginPageIsOpenExplanation: 'Ha abierto la página de inicio de sesión en una pestaña separada, inicie sesión desde esa pestaña específica.',
welcome: '¡Bienvenido!',
+ welcomeWithoutExclamation: 'Bienvenido',
phrase2: 'El dinero habla. Y ahora que chat y pagos están en un mismo lugar, es también fácil.',
phrase3: 'Tus pagos llegan tan rápido como tus mensajes.',
enterPassword: 'Por favor, introduce tu contraseña',
- newFaceEnterMagicCode: ({login}: NewFaceEnterMagicCodeParams) =>
- `¡Siempre es genial ver una cara nueva por aquí! Por favor ingresa el código mágico enviado a ${login}. Debería llegar en un par de minutos.`,
+ welcomeNewFace: ({login}: SignUpNewFaceCodeParams) => `${login}, siempre es genial ver una cara nueva por aquí!`,
welcomeEnterMagicCode: ({login}: WelcomeEnterMagicCodeParams) => `Por favor, introduce el código mágico enviado a ${login}. Debería llegar en un par de minutos.`,
},
login: {
@@ -500,7 +507,6 @@ export default {
beginningOfChatHistoryAdminRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAdminRoomPartOneParams) =>
`¡Este es el lugar para que los administradores de ${workspaceName} colaboren! 🎉\nUsa `,
beginningOfChatHistoryAdminRoomPartTwo: ' para chatear sobre temas como la configuración del espacio de trabajo y mas.',
- beginningOfChatHistoryAdminOnlyPostingRoom: 'Solo los administradores pueden enviar mensajes en esta sala.',
beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartOneParams) =>
`¡Este es el lugar para que todos los miembros de ${workspaceName} colaboren! 🎉\nUsa `,
beginningOfChatHistoryAnnounceRoomPartTwo: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartTwo) => ` para chatear sobre cualquier cosa relacionada con ${workspaceName}.`,
@@ -526,6 +532,7 @@ export default {
invoice: 'facturar un gasto',
},
},
+ adminOnlyCanPost: 'Solo los administradores pueden enviar mensajes en esta sala.',
reportAction: {
asCopilot: 'como copiloto de',
},
@@ -666,7 +673,11 @@ export default {
deleteConfirmation: '¿Estás seguro de que quieres eliminar esta solicitud?',
settledExpensify: 'Pagado',
settledElsewhere: 'Pagado de otra forma',
+ individual: 'Individual',
settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} con Expensify` : `Pagar con Expensify`),
+ settlePersonal: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pago ${formattedAmount} como individuo` : `Pago individual`),
+ settlePayment: ({formattedAmount}: SettleExpensifyCardParams) => `Pagar ${formattedAmount}`,
+ settleBusiness: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} como negocio` : `Pagar como empresa`),
payElsewhere: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} de otra forma` : `Pagar de otra forma`),
nextStep: 'Pasos Siguientes',
finished: 'Finalizado',
@@ -719,6 +730,8 @@ export default {
invalidSplit: 'La suma de las partes debe ser igual al importe total.',
invalidSplitParticipants: 'Introduce un importe superior a cero para al menos dos participantes.',
other: 'Error inesperado, por favor inténtalo más tarde.',
+ genericHoldExpenseFailureMessage: 'Error inesperado al bloquear el gasto, por favor inténtalo de nuevo más tarde.',
+ genericUnholdExpenseFailureMessage: 'Error inesperado al desbloquear el gasto, por favor inténtalo de nuevo más tarde.',
genericCreateFailureMessage: 'Error inesperado al enviar este gasto. Por favor, inténtalo más tarde.',
genericCreateInvoiceFailureMessage: 'Error inesperado al enviar la factura, inténtalo de nuevo más tarde.',
receiptDeleteFailureError: 'Error inesperado al borrar este recibo. Vuelve a intentarlo más tarde.',
@@ -745,6 +758,8 @@ export default {
reason: 'Razón',
holdReasonRequired: 'Se requiere una razón para bloquear.',
expenseOnHold: 'Este gasto está bloqueado. Revisa los comentarios para saber como proceder.',
+ expenseDuplicate: 'Esta solicitud tiene los mismos detalles que otra. Revise los duplicados para eliminar la retención.',
+ reviewDuplicates: 'Revisar duplicados',
confirmApprove: 'Confirmar importe a aprobar',
confirmApprovalAmount: 'Aprueba lo que no está bloqueado, o aprueba todo el informe.',
confirmPay: 'Confirmar importe de pago',
@@ -1056,6 +1071,29 @@ export default {
password: 'Por favor, introduce tu contraseña de Expensify.',
},
},
+ addPaymentCardPage: {
+ addAPaymentCard: 'Añade tarjeta de pago',
+ nameOnCard: 'Nombre en la tarjeta',
+ paymentCardNumber: 'Número de la tarjeta',
+ expiration: 'Fecha de vencimiento',
+ expirationDate: 'MMAA',
+ cvv: 'CVV',
+ billingAddress: 'Dirección de envio',
+ growlMessageOnSave: 'Tu tarjeta de pago se añadió correctamente',
+ expensifyPassword: 'Contraseña de Expensify',
+ error: {
+ invalidName: 'El nombre sólo puede incluir letras.',
+ addressZipCode: 'Por favor, introduce un código postal válido.',
+ paymentCardNumber: 'Por favor, introduce un número de tarjeta de pago válido.',
+ expirationDate: 'Por favor, selecciona una fecha de vencimiento válida.',
+ securityCode: 'Por favor, introduce un código de seguridad válido.',
+ addressStreet: 'Por favor, introduce una dirección de facturación válida que no sea un apartado postal.',
+ addressState: 'Por favor, selecciona un estado.',
+ addressCity: 'Por favor, introduce una ciudad.',
+ genericFailureMessage: 'Se produjo un error al añadir tu tarjeta. Vuelva a intentarlo.',
+ password: 'Por favor, introduce tu contraseña de Expensify.',
+ },
+ },
walletPage: {
paymentMethodsTitle: 'Métodos de pago',
setDefaultConfirmation: 'Marcar como método de pago predeterminado',
@@ -1425,6 +1463,9 @@ export default {
onceTheAbove: 'Una vez completados los pasos anteriores, ponte en contacto con ',
toUnblock: ' para desbloquear el inicio de sesión.',
},
+ welcomeSignUpForm: {
+ join: 'Unirse',
+ },
detailsPage: {
localTime: 'Hora local',
},
@@ -1530,6 +1571,8 @@ export default {
validateAccountError: {
phrase1: '¡Un momento! Primero necesitas validar tu cuenta. Para hacerlo, ',
phrase2: 'vuelve a iniciar sesión con un código mágico',
+ phrase3: 'o',
+ phrase4: 'verifique tu cuenta aquí',
},
hasPhoneLoginError:
'Para añadir una cuenta bancaria verificada, asegúrate de que tu nombre de usuario principal sea un correo electrónico válido y vuelve a intentarlo. Puedes añadir tu número de teléfono como nombre de usuario secundario.',
@@ -1891,6 +1934,7 @@ export default {
alerts: 'Obtén actualizaciones y alertas en tiempo real',
},
bookTravel: 'Reservar viajes',
+ bookDemo: 'Pedir demostración',
termsAndConditions: {
header: 'Antes de continuar...',
title: 'Por favor, lee los Términos y condiciones para reservar viajes',
@@ -1903,6 +1947,13 @@ export default {
agree: 'Acepto los ',
error: 'Debes aceptar los Términos y condiciones para que el viaje continúe',
},
+ flight: 'Vuelo',
+ hotel: 'Hotel',
+ car: 'Auto',
+ viewTrip: 'Ver viaje',
+ trip: 'Viaje',
+ tripSummary: 'Resumen del viaje',
+ departs: 'Sale',
},
workspace: {
common: {
@@ -2278,6 +2329,8 @@ export default {
foreignDefault: 'Moneda extranjera por defecto',
customTaxName: 'Nombre del impuesto',
value: 'Valor',
+ taxRate: 'Tasa de impuesto',
+ taxReclaimableOn: 'Impuesto recuperable en',
error: {
taxRateAlreadyExists: 'Ya existe un impuesto con este nombre.',
customNameRequired: 'El nombre del impuesto es obligatorio.',
@@ -2285,6 +2338,7 @@ export default {
deleteFailureMessage: 'Se ha producido un error al intentar eliminar la tasa de impuesto. Por favor, inténtalo más tarde.',
updateFailureMessage: 'Se ha producido un error al intentar modificar la tasa de impuesto. Por favor, inténtalo más tarde.',
createFailureMessage: 'Se ha producido un error al intentar crear la tasa de impuesto. Por favor, inténtalo más tarde.',
+ updateTaxClaimableFailureMessage: 'La porción recuperable debe ser menor al monto del importe por distancia.',
},
deleteTaxConfirmation: '¿Estás seguro de que quieres eliminar este impuesto?',
deleteMultipleTaxConfirmation: ({taxAmount}) => `¿Estás seguro de que quieres eliminar ${taxAmount} impuestos?`,
@@ -2531,6 +2585,14 @@ export default {
viewUnpaidInvoices: 'Ver facturas emitidas pendientes',
sendInvoice: 'Enviar factura',
sendFrom: 'Enviar desde',
+ paymentMethods: {
+ personal: 'Personal',
+ business: 'Empresas',
+ chooseInvoiceMethod: 'Elija un método de pago:',
+ addBankAccount: 'Añadir cuenta bancaria',
+ payingAsIndividual: 'Pago individual',
+ payingAsBusiness: 'Pagar como una empresa',
+ },
},
travel: {
unlockConciergeBookingTravel: 'Desbloquea la reserva de viajes con Concierge',
@@ -2567,12 +2629,15 @@ export default {
centrallyManage: 'Gestiona centralizadamente las tasas, elige si contabilizar en millas o kilómetros, y define una categoría por defecto',
rate: 'Tasa',
addRate: 'Agregar tasa',
+ trackTax: 'Impuesto de seguimiento',
deleteRates: ({count}: DistanceRateOperationsParams) => `Eliminar ${Str.pluralize('tasa', 'tasas', count)}`,
enableRates: ({count}: DistanceRateOperationsParams) => `Activar ${Str.pluralize('tasa', 'tasas', count)}`,
disableRates: ({count}: DistanceRateOperationsParams) => `Desactivar ${Str.pluralize('tasa', 'tasas', count)}`,
enableRate: 'Activar tasa',
status: 'Estado',
unit: 'Unidad',
+ taxFeatureNotEnabledMessage: 'Los impuestos deben estar activados en el área de trabajo para poder utilizar esta función. Dirígete a ',
+ changePromptMessage: ' para hacer ese cambio.',
defaultCategory: 'Categoría predeterminada',
deleteDistanceRate: 'Eliminar tasa de distancia',
areYouSureDelete: ({count}: DistanceRateOperationsParams) => `¿Estás seguro de que quieres eliminar ${Str.pluralize('esta tasa', 'estas tasas', count)}?`,
@@ -3562,7 +3627,7 @@ export default {
categoryOutOfPolicy: 'La categoría ya no es válida',
conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams = {}) => `${surcharge}% de recargo aplicado`,
customUnitOutOfPolicy: 'La unidad ya no es válida',
- duplicatedTransaction: 'Posible duplicado',
+ duplicatedTransaction: 'Duplicado',
fieldRequired: 'Los campos del informe son obligatorios',
futureDate: 'Fecha futura no permitida',
invoiceMarkup: ({invoiceMarkup}: ViolationsInvoiceMarkupParams) => `Incrementado un ${invoiceMarkup}%`,
@@ -3650,4 +3715,99 @@ export default {
systemMessage: {
mergedWithCashTransaction: 'encontró un recibo para esta transacción.',
},
+ subscription: {
+ mobileReducedFunctionalityMessage: 'No puedes hacer cambios en tu suscripción en la aplicación móvil.',
+ cardSection: {
+ title: 'Pago',
+ subtitle: 'Añade una tarjeta de pago para abonar tu suscripción a Expensify',
+ addCardButton: 'Añade tarjeta de pago',
+ cardNextPayment: 'Your next payment date is',
+ cardEnding: ({cardNumber}) => `Tarjeta terminada en ${cardNumber}`,
+ cardInfo: ({name, expiration, currency}) => `Nombre: ${name}, Expiración: ${expiration}, Moneda: ${currency}`,
+ changeCard: 'Cambiar tarjeta de pago',
+ changeCurrency: 'Cambiar moneda de pago',
+ cardNotFound: 'No se ha añadido ninguna tarjeta de pago',
+ retryPaymentButton: 'Reintentar el pago',
+ },
+ yourPlan: {
+ title: 'Tu plan',
+ collect: {
+ title: 'Recolectar',
+ priceAnnual: 'Desde $5/miembro activo con la Tarjeta Expensify, $10/miembro activo sin la Tarjeta Expensify.',
+ pricePayPerUse: 'Desde $10/miembro activo con la Tarjeta Expensify, $20/miembro activo sin la Tarjeta Expensify.',
+ benefit1: 'SmartScans ilimitados y seguimiento de la distancia',
+ benefit2: 'Tarjetas Expensify con Límites Inteligentes',
+ benefit3: 'Pago de facturas y facturación',
+ benefit4: 'Aprobación de gastos',
+ benefit5: 'Reembolso ACH',
+ benefit6: 'Integraciones con QuickBooks y Xero',
+ benefit7: 'Reportes e informes personalizados',
+ },
+ control: {
+ title: 'Control',
+ priceAnnual: 'Desde $9/miembro activo con la Tarjeta Expensify, $18/miembro activo sin la Tarjeta Expensify.',
+ pricePayPerUse: 'Desde $18/miembro activo con la Tarjeta Expensify, $36/miembro activo sin la Tarjeta Expensify.',
+ benefit1: 'Todo en Recolectar, más:',
+ benefit2: 'Integraciones con NetSuite y Sage Intacct',
+ benefit3: 'Sincronización de Certinia y Workday',
+ benefit4: 'Varios aprobadores de gastos',
+ benefit5: 'SAML/SSO',
+ benefit6: 'Presupuestos',
+ },
+ saveWithExpensifyTitle: 'Ahorra con la Tarjeta Expensify',
+ saveWithExpensifyDescription: 'Utiliza nuestra calculadora de ahorro para ver cómo el reembolso en efectivo de la Tarjeta Expensify puede reducir tu factura de Expensify',
+ saveWithExpensifyButton: 'Más información',
+ },
+ details: {
+ title: 'Datos de suscripción',
+ annual: 'Suscripción anual',
+ payPerUse: 'Pago por uso',
+ subscriptionSize: 'Tamaño de suscripción',
+ headsUpTitle: 'Atención: ',
+ headsUpBody:
+ 'Si no estableces ahora el tamaño de tu suscripción, lo haremos automáticamente con el número de suscriptores activos del primer mes. A partir de ese momento, estarás suscrito para pagar al menos por ese número de afiliados durante los 12 meses siguientes. Puedes aumentar el tamaño de tu suscripción en cualquier momento, pero no puedes reducirlo hasta que finalice tu suscripción.',
+ zeroCommitment: 'Compromiso cero con la tarifa de suscripción anual reducida',
+ },
+ subscriptionSize: {
+ title: 'Tamaño de suscripción',
+ yourSize: 'El tamaño de tu suscripción es el número de plazas abiertas que puede ocupar cualquier miembro activo en un mes determinado.',
+ eachMonth:
+ 'Cada mes, tu suscripción cubre hasta el número de miembros activos establecido anteriormente. Cada vez que aumentes el tamaño de tu suscripción, iniciarás una nueva suscripción de 12 meses con ese nuevo tamaño.',
+ note: 'Nota: Un miembro activo es cualquiera que haya creado, editado, enviado, aprobado, reembolsado, o exportado datos de gastos vinculados al espacio de trabajo de tu empresa.',
+ confirmDetails: 'Confirma los datos de tu nueva suscripción anual',
+ subscriptionSize: 'Tamaño de suscripción',
+ activeMembers: ({size}) => `${size} miembros activos/mes`,
+ subscriptionRenews: 'Renovación de la suscripción',
+ youCantDowngrade: 'No puedes bajar de categoría durante tu suscripción anual',
+ youAlreadyCommitted: ({size, date}) =>
+ `Ya se ha comprometido a un tamaño de suscripción anual de ${size} miembros activos al mes hasta el ${date}. Puede cambiar a una suscripción de pago por uso en ${date} desactivando la auto-renovación.`,
+ error: {
+ size: 'Por favor ingrese un tamaño de suscripción valido.',
+ },
+ },
+ paymentCard: {
+ addPaymentCard: 'Añade tarjeta de pago',
+ enterPaymentCardDetails: 'Introduce los datos de tu tarjeta de pago.',
+ security: 'Expensify es PCI-DSS obediente, utiliza cifrado a nivel bancario, y emplea infraestructura redundante para proteger tus datos.',
+ learnMoreAboutSecurity: 'Conozca más sobre nuestra seguridad.',
+ },
+ subscriptionSettings: {
+ title: 'Configuración de suscripción',
+ autoRenew: 'Auto-renovación',
+ autoIncrease: 'Auto-incremento',
+ saveUpTo: ({amountSaved}) => `Ahorre hasta $${amountSaved} al mes por miembro activo`,
+ automaticallyIncrease:
+ 'Aumenta automáticamente tus plazas anuales para dar lugar a los miembros activos que superen el tamaño de tu suscripción. Nota: Esto ampliará la fecha de finalización de tu suscripción anual.',
+ disableAutoRenew: 'Desactivar auto-renovación',
+ helpUsImprove: 'Ayúdanos a mejorar Expensify',
+ whatsMainReason: '¿Cuál es la razón principal por la que deseas desactivar la auto-renovación de tu suscripción?',
+ renewsOn: ({date}) => `Se renovará el ${date}`,
+ },
+ },
+ feedbackSurvey: {
+ tooLimited: 'Hay que mejorar la funcionalidad',
+ tooExpensive: 'Demasiado caro',
+ inadequateSupport: 'Atención al cliente inadecuada',
+ businessClosing: 'Cierre, reducción, o adquisición de la empresa',
+ },
} satisfies EnglishTranslation;
diff --git a/src/languages/types.ts b/src/languages/types.ts
index e2e7e26e696b..5f0fc761e2de 100644
--- a/src/languages/types.ts
+++ b/src/languages/types.ts
@@ -18,7 +18,7 @@ type LoggedInAsParams = {
email: string;
};
-type NewFaceEnterMagicCodeParams = {
+type SignUpNewFaceCodeParams = {
login: string;
};
@@ -329,7 +329,7 @@ export type {
LoggedInAsParams,
ManagerApprovedAmountParams,
ManagerApprovedParams,
- NewFaceEnterMagicCodeParams,
+ SignUpNewFaceCodeParams,
NoLongerHaveAccessParams,
NotAllowedExtensionParams,
NotYouParams,
diff --git a/src/libs/API/parameters/AddSubscriptionPaymentCardParams.ts b/src/libs/API/parameters/AddSubscriptionPaymentCardParams.ts
new file mode 100644
index 000000000000..ef8fbc382737
--- /dev/null
+++ b/src/libs/API/parameters/AddSubscriptionPaymentCardParams.ts
@@ -0,0 +1,11 @@
+type AddSubscriptionPaymentCardParams = {
+ cardNumber: string;
+ cardYear: string;
+ cardMonth: string;
+ cardCVV: string;
+ addressName: string;
+ addressZip: string;
+ currency: string;
+};
+
+export default AddSubscriptionPaymentCardParams;
diff --git a/src/libs/API/parameters/BeginSignInParams.ts b/src/libs/API/parameters/BeginSignInParams.ts
index 2f85a3335c62..0dec2587ed14 100644
--- a/src/libs/API/parameters/BeginSignInParams.ts
+++ b/src/libs/API/parameters/BeginSignInParams.ts
@@ -1,5 +1,6 @@
type BeginSignInParams = {
email: string;
+ useNewBeginSignIn: boolean;
};
export default BeginSignInParams;
diff --git a/src/libs/API/parameters/CompleteSplitBillParams.ts b/src/libs/API/parameters/CompleteSplitBillParams.ts
index 50054ba6fd10..a1731d32fcc4 100644
--- a/src/libs/API/parameters/CompleteSplitBillParams.ts
+++ b/src/libs/API/parameters/CompleteSplitBillParams.ts
@@ -8,6 +8,8 @@ type CompleteSplitBillParams = {
category?: string;
tag?: string;
splits: string;
+ taxCode?: string;
+ taxAmount?: number;
};
export default CompleteSplitBillParams;
diff --git a/src/libs/API/parameters/PayInvoiceParams.ts b/src/libs/API/parameters/PayInvoiceParams.ts
new file mode 100644
index 000000000000..4c6633749adb
--- /dev/null
+++ b/src/libs/API/parameters/PayInvoiceParams.ts
@@ -0,0 +1,9 @@
+import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
+
+type PayInvoiceParams = {
+ reportID: string;
+ reportActionID: string;
+ paymentMethodType: PaymentMethodType;
+};
+
+export default PayInvoiceParams;
diff --git a/src/libs/API/parameters/RenamePolicyTaglist.ts b/src/libs/API/parameters/RenamePolicyTaglist.ts
deleted file mode 100644
index 8c92c99aa7fb..000000000000
--- a/src/libs/API/parameters/RenamePolicyTaglist.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-type RenamePolicyTaglist = {
- policyID: string;
- oldName: string;
- newName: string;
-};
-
-export default RenamePolicyTaglist;
diff --git a/src/libs/API/parameters/RenamePolicyTaglistParams.ts b/src/libs/API/parameters/RenamePolicyTaglistParams.ts
new file mode 100644
index 000000000000..dd7d08209d32
--- /dev/null
+++ b/src/libs/API/parameters/RenamePolicyTaglistParams.ts
@@ -0,0 +1,8 @@
+type RenamePolicyTaglistParams = {
+ policyID: string;
+ oldName: string;
+ newName: string;
+ tagListIndex: number;
+};
+
+export default RenamePolicyTaglistParams;
diff --git a/src/libs/API/parameters/RenamePolicyTagsParams.ts b/src/libs/API/parameters/RenamePolicyTagsParams.ts
index bcf38384cf2c..6a384623d91f 100644
--- a/src/libs/API/parameters/RenamePolicyTagsParams.ts
+++ b/src/libs/API/parameters/RenamePolicyTagsParams.ts
@@ -2,6 +2,7 @@ type RenamePolicyTagsParams = {
policyID: string;
oldName: string;
newName: string;
+ tagListIndex: number;
};
export default RenamePolicyTagsParams;
diff --git a/src/libs/API/parameters/SendInvoiceParams.ts b/src/libs/API/parameters/SendInvoiceParams.ts
index 55a82e310399..f8ba0647fb0a 100644
--- a/src/libs/API/parameters/SendInvoiceParams.ts
+++ b/src/libs/API/parameters/SendInvoiceParams.ts
@@ -1,20 +1,25 @@
-type SendInvoiceParams = {
- senderWorkspaceID: string;
- accountID: number;
- receiverEmail?: string;
- receiverInvoiceRoomID?: string;
- amount: number;
- currency: string;
- comment: string;
- merchant: string;
- date: string;
- category?: string;
- invoiceRoomReportID?: string;
- createdChatReportActionID: string;
- invoiceReportID: string;
- reportPreviewReportActionID: string;
- transactionID: string;
- transactionThreadReportID: string;
-};
+import type {RequireAtLeastOne} from 'type-fest';
+
+type SendInvoiceParams = RequireAtLeastOne<
+ {
+ senderWorkspaceID: string;
+ accountID: number;
+ receiverEmail?: string;
+ receiverInvoiceRoomID?: string;
+ amount: number;
+ currency: string;
+ comment: string;
+ merchant: string;
+ date: string;
+ category?: string;
+ invoiceRoomReportID?: string;
+ createdChatReportActionID: string;
+ invoiceReportID: string;
+ reportPreviewReportActionID: string;
+ transactionID: string;
+ transactionThreadReportID: string;
+ },
+ 'receiverEmail' | 'receiverInvoiceRoomID'
+>;
export default SendInvoiceParams;
diff --git a/src/libs/API/parameters/SignUpUserParams.ts b/src/libs/API/parameters/SignUpUserParams.ts
new file mode 100644
index 000000000000..00080017d30f
--- /dev/null
+++ b/src/libs/API/parameters/SignUpUserParams.ts
@@ -0,0 +1,9 @@
+import type {ValueOf} from 'type-fest';
+import type CONST from '@src/CONST';
+
+type SignUpUserParams = {
+ email?: string;
+ preferredLocale: ValueOf | null;
+};
+
+export default SignUpUserParams;
diff --git a/src/libs/API/parameters/SplitBillParams.ts b/src/libs/API/parameters/SplitBillParams.ts
index dd5a88141222..76252abe3292 100644
--- a/src/libs/API/parameters/SplitBillParams.ts
+++ b/src/libs/API/parameters/SplitBillParams.ts
@@ -15,6 +15,8 @@ type SplitBillParams = {
policyID: string | undefined;
chatType: string | undefined;
splitPayerAccountIDs: number[];
+ taxCode: string;
+ taxAmount: number;
};
export default SplitBillParams;
diff --git a/src/libs/API/parameters/StartSplitBillParams.ts b/src/libs/API/parameters/StartSplitBillParams.ts
index ce7a310f14e2..499073c88de9 100644
--- a/src/libs/API/parameters/StartSplitBillParams.ts
+++ b/src/libs/API/parameters/StartSplitBillParams.ts
@@ -14,6 +14,8 @@ type StartSplitBillParams = {
createdReportActionID?: string;
billable: boolean;
chatType?: string;
+ taxCode?: string;
+ taxAmount?: number;
};
export default StartSplitBillParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index 68097a201120..b853f134b315 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -172,7 +172,7 @@ export type {default as SetWorkspaceApprovalModeParams} from './SetWorkspaceAppr
export type {default as SetWorkspacePayerParams} from './SetWorkspacePayerParams';
export type {default as SetWorkspaceReimbursementParams} from './SetWorkspaceReimbursementParams';
export type {default as SetPolicyRequiresTag} from './SetPolicyRequiresTag';
-export type {default as RenamePolicyTaglist} from './RenamePolicyTaglist';
+export type {default as RenamePolicyTaglistParams} from './RenamePolicyTaglistParams';
export type {default as SwitchToOldDotParams} from './SwitchToOldDotParams';
export type {default as TrackExpenseParams} from './TrackExpenseParams';
export type {default as EnablePolicyCategoriesParams} from './EnablePolicyCategoriesParams';
@@ -205,6 +205,7 @@ export type {default as DeletePolicyTaxesParams} from './DeletePolicyTaxesParams
export type {default as UpdatePolicyTaxValueParams} from './UpdatePolicyTaxValueParams';
export type {default as RenamePolicyTagsParams} from './RenamePolicyTagsParams';
export type {default as DeletePolicyTagsParams} from './DeletePolicyTagsParams';
+export type {default as AddSubscriptionPaymentCardParams} from './AddSubscriptionPaymentCardParams';
export type {default as SetPolicyCustomTaxNameParams} from './SetPolicyCustomTaxNameParams';
export type {default as SetPolicyForeignCurrencyDefaultParams} from './SetPolicyForeignCurrencyDefaultParams';
export type {default as SetPolicyCurrencyDefaultParams} from './SetPolicyCurrencyDefaultParams';
@@ -221,4 +222,6 @@ export type {default as LeavePolicyParams} from './LeavePolicyParams';
export type {default as OpenPolicyAccountingPageParams} from './OpenPolicyAccountingPageParams';
export type {default as SearchParams} from './Search';
export type {default as SendInvoiceParams} from './SendInvoiceParams';
+export type {default as PayInvoiceParams} from './PayInvoiceParams';
export type {default as MarkAsCashParams} from './MarkAsCashParams';
+export type {default as SignUpUserParams} from './SignUpUserParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 897b300f0471..6b34e04e1937 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -206,7 +206,10 @@ const WRITE_COMMANDS = {
ADD_BILLING_CARD_AND_REQUEST_WORKSPACE_OWNER_CHANGE: 'AddBillingCardAndRequestPolicyOwnerChange',
SET_POLICY_DISTANCE_RATES_UNIT: 'SetPolicyDistanceRatesUnit',
SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY: 'SetPolicyDistanceRatesDefaultCategory',
+ ENABLE_DISTANCE_REQUEST_TAX: 'EnableDistanceRequestTax',
UPDATE_POLICY_DISTANCE_RATE_VALUE: 'UpdatePolicyDistanceRateValue',
+ UPDATE_POLICY_DISTANCE_TAX_RATE_VALUE: 'UpdateDistanceTaxRate',
+ UPDATE_DISTANCE_TAX_CLAIMABLE_VALUE: 'UpdateDistanceTaxClaimableValue',
SET_POLICY_DISTANCE_RATES_ENABLED: 'SetPolicyDistanceRatesEnabled',
DELETE_POLICY_DISTANCE_RATES: 'DeletePolicyDistanceRates',
DISMISS_TRACK_EXPENSE_ACTIONABLE_WHISPER: 'DismissActionableWhisper',
@@ -216,7 +219,9 @@ const WRITE_COMMANDS = {
LEAVE_POLICY: 'LeavePolicy',
ACCEPT_SPOTNANA_TERMS: 'AcceptSpotnanaTerms',
SEND_INVOICE: 'SendInvoice',
+ PAY_INVOICE: 'PayInvoice',
MARK_AS_CASH: 'MarkAsCash',
+ SIGN_UP_USER: 'SignUpUser',
} as const;
type WriteCommand = ValueOf;
@@ -339,7 +344,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY]: Parameters.SetWorkspaceRequiresCategoryParams;
[WRITE_COMMANDS.DELETE_WORKSPACE_CATEGORIES]: Parameters.DeleteWorkspaceCategoriesParams;
[WRITE_COMMANDS.SET_POLICY_REQUIRES_TAG]: Parameters.SetPolicyRequiresTag;
- [WRITE_COMMANDS.RENAME_POLICY_TAG_LIST]: Parameters.RenamePolicyTaglist;
+ [WRITE_COMMANDS.RENAME_POLICY_TAG_LIST]: Parameters.RenamePolicyTaglistParams;
[WRITE_COMMANDS.CREATE_POLICY_TAG]: Parameters.CreatePolicyTagsParams;
[WRITE_COMMANDS.RENAME_POLICY_TAG]: Parameters.RenamePolicyTagsParams;
[WRITE_COMMANDS.SET_POLICY_TAGS_ENABLED]: Parameters.SetPolicyTagsEnabled;
@@ -418,12 +423,15 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.RENAME_POLICY_TAX]: Parameters.RenamePolicyTaxParams;
[WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT]: Parameters.SetPolicyDistanceRatesUnitParams;
[WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams;
+ [WRITE_COMMANDS.ENABLE_DISTANCE_REQUEST_TAX]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[WRITE_COMMANDS.UPDATE_POLICY_CONNECTION_CONFIG]: Parameters.UpdatePolicyConnectionConfigParams;
[WRITE_COMMANDS.UPDATE_MANY_POLICY_CONNECTION_CONFIGS]: Parameters.UpdateManyPolicyConnectionConfigurationsParams;
[WRITE_COMMANDS.REMOVE_POLICY_CONNECTION]: Parameters.RemovePolicyConnectionParams;
[WRITE_COMMANDS.UPDATE_POLICY_DISTANCE_RATE_VALUE]: Parameters.UpdatePolicyDistanceRateValueParams;
+ [WRITE_COMMANDS.UPDATE_POLICY_DISTANCE_TAX_RATE_VALUE]: Parameters.UpdatePolicyDistanceRateValueParams;
+ [WRITE_COMMANDS.UPDATE_DISTANCE_TAX_CLAIMABLE_VALUE]: Parameters.UpdatePolicyDistanceRateValueParams;
[WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_ENABLED]: Parameters.SetPolicyDistanceRatesEnabledParams;
[WRITE_COMMANDS.DELETE_POLICY_DISTANCE_RATES]: Parameters.DeletePolicyDistanceRatesParams;
[WRITE_COMMANDS.DISMISS_TRACK_EXPENSE_ACTIONABLE_WHISPER]: Parameters.DismissTrackExpenseActionableWhisperParams;
@@ -433,7 +441,9 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.LEAVE_POLICY]: Parameters.LeavePolicyParams;
[WRITE_COMMANDS.ACCEPT_SPOTNANA_TERMS]: EmptyObject;
[WRITE_COMMANDS.SEND_INVOICE]: Parameters.SendInvoiceParams;
+ [WRITE_COMMANDS.PAY_INVOICE]: Parameters.PayInvoiceParams;
[WRITE_COMMANDS.MARK_AS_CASH]: Parameters.MarkAsCashParams;
+ [WRITE_COMMANDS.SIGN_UP_USER]: Parameters.SignUpUserParams;
};
const READ_COMMANDS = {
@@ -479,6 +489,7 @@ const READ_COMMANDS = {
OPEN_POLICY_MORE_FEATURES_PAGE: 'OpenPolicyMoreFeaturesPage',
OPEN_POLICY_ACCOUNTING_PAGE: 'OpenPolicyAccountingPage',
SEARCH: 'Search',
+ OPEN_SUBSCRIPTION_PAGE: 'OpenSubscriptionPage',
} as const;
type ReadCommand = ValueOf;
@@ -526,6 +537,7 @@ type ReadCommandParameters = {
[READ_COMMANDS.OPEN_POLICY_MORE_FEATURES_PAGE]: Parameters.OpenPolicyMoreFeaturesPageParams;
[READ_COMMANDS.OPEN_POLICY_ACCOUNTING_PAGE]: Parameters.OpenPolicyAccountingPageParams;
[READ_COMMANDS.SEARCH]: Parameters.SearchParams;
+ [READ_COMMANDS.OPEN_SUBSCRIPTION_PAGE]: null;
};
const SIDE_EFFECT_REQUEST_COMMANDS = {
diff --git a/src/libs/ActiveClientManager/index.ts b/src/libs/ActiveClientManager/index.ts
index 69bd3a848e0b..e93cdb07d084 100644
--- a/src/libs/ActiveClientManager/index.ts
+++ b/src/libs/ActiveClientManager/index.ts
@@ -3,7 +3,7 @@
* only one tab is processing those saved requests or we would be duplicating data (or creating errors).
* This file ensures exactly that by tracking all the clientIDs connected, storing the most recent one last and it considers that last clientID the "leader".
*/
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import Onyx from 'react-native-onyx';
import * as ActiveClients from '@userActions/ActiveClients';
import ONYXKEYS from '@src/ONYXKEYS';
diff --git a/src/libs/BankAccountUtils.ts b/src/libs/BankAccountUtils.ts
index a7fbc5f3bd4e..d3c5bb4998a8 100644
--- a/src/libs/BankAccountUtils.ts
+++ b/src/libs/BankAccountUtils.ts
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import type {OnyxEntry} from 'react-native-onyx';
import type * as OnyxTypes from '@src/types/onyx';
diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts
index ec2423833087..52530b1b3bb9 100644
--- a/src/libs/CurrencyUtils.ts
+++ b/src/libs/CurrencyUtils.ts
@@ -87,9 +87,22 @@ function convertToBackendAmount(amountAsFloat: number): number {
*
* @note we do not support any currencies with more than two decimal places.
*/
-function convertToFrontendAmount(amountAsInt: number): number {
+function convertToFrontendAmountAsInteger(amountAsInt: number): number {
return Math.trunc(amountAsInt) / 100.0;
}
+
+/**
+ * Takes an amount in "cents" as an integer and converts it to a string amount used in the frontend.
+ *
+ * @note we do not support any currencies with more than two decimal places.
+ */
+function convertToFrontendAmountAsString(amountAsInt: number | null | undefined): string {
+ if (amountAsInt === null || amountAsInt === undefined) {
+ return '';
+ }
+ return convertToFrontendAmountAsInteger(amountAsInt).toFixed(2);
+}
+
/**
* Given an amount in the "cents", convert it to a string for display in the UI.
* The backend always handle things in "cents" (subunit equal to 1/100)
@@ -98,10 +111,17 @@ function convertToFrontendAmount(amountAsInt: number): number {
* @param currency - IOU currency
*/
function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURRENCY.USD): string {
- const convertedAmount = convertToFrontendAmount(amountInCents);
+ const convertedAmount = convertToFrontendAmountAsInteger(amountInCents);
+ /**
+ * Fallback currency to USD if it empty string or undefined
+ */
+ let currencyWithFallback = currency;
+ if (!currency) {
+ currencyWithFallback = CONST.CURRENCY.USD;
+ }
return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, {
style: 'currency',
- currency,
+ currency: currencyWithFallback,
// We are forcing the number of decimals because we override the default number of decimals in the backend for RSD
// See: https://github.com/Expensify/PHP-Libs/pull/834
@@ -128,7 +148,7 @@ function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRE
* Acts the same as `convertAmountToDisplayString` but the result string does not contain currency
*/
function convertToDisplayStringWithoutCurrency(amountInCents: number, currency: string = CONST.CURRENCY.USD) {
- const convertedAmount = convertToFrontendAmount(amountInCents);
+ const convertedAmount = convertToFrontendAmountAsInteger(amountInCents);
return NumberFormatUtils.formatToParts(BaseLocaleListener.getPreferredLocale(), convertedAmount, {
style: 'currency',
currency,
@@ -148,7 +168,7 @@ function convertToDisplayStringWithoutCurrency(amountInCents: number, currency:
*/
function isValidCurrencyCode(currencyCode: string): boolean {
const currency = currencyList?.[currencyCode];
- return Boolean(currency);
+ return !!currency;
}
export {
@@ -158,7 +178,8 @@ export {
getCurrencySymbol,
isCurrencySymbolLTR,
convertToBackendAmount,
- convertToFrontendAmount,
+ convertToFrontendAmountAsInteger,
+ convertToFrontendAmountAsString,
convertToDisplayString,
convertAmountToDisplayString,
convertToDisplayStringWithoutCurrency,
diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts
index 8bd37ddd698d..50cb9a20dff6 100644
--- a/src/libs/DateUtils.ts
+++ b/src/libs/DateUtils.ts
@@ -15,8 +15,10 @@ import {
isAfter,
isBefore,
isSameDay,
+ isSameMonth,
isSameSecond,
isSameYear,
+ isThisYear,
isValid,
parse,
set,
@@ -728,6 +730,73 @@ function getLastBusinessDayOfMonth(inputDate: Date): number {
return getDate(currentDate);
}
+/**
+ * Returns a formatted date range from date 1 to date 2.
+ * Dates are formatted as follows:
+ * 1. When both dates refer to the same day: Mar 17
+ * 2. When both dates refer to the same month: Mar 17-20
+ * 3. When both dates refer to the same year: Feb 28 to Mar 1
+ * 4. When the dates are from different years: Dec 28, 2023 to Jan 5, 2024
+ */
+function getFormattedDateRange(date1: Date, date2: Date): string {
+ const {translateLocal} = Localize;
+
+ if (isSameDay(date1, date2)) {
+ // Dates are from the same day
+ return format(date1, 'MMM d');
+ }
+ if (isSameMonth(date1, date2)) {
+ // Dates in the same month and year, differ by days
+ return `${format(date1, 'MMM d')}-${format(date2, 'd')}`;
+ }
+ if (isSameYear(date1, date2)) {
+ // Dates are in the same year, differ by months
+ return `${format(date1, 'MMM d')} ${translateLocal('common.to').toLowerCase()} ${format(date2, 'MMM d')}`;
+ }
+ // Dates differ by years, months, days
+ return `${format(date1, 'MMM d, yyyy')} ${translateLocal('common.to').toLowerCase()} ${format(date2, 'MMM d, yyyy')}`;
+}
+
+/**
+ * Returns a formatted date range from date 1 to date 2 of a reservation.
+ * Dates are formatted as follows:
+ * 1. When both dates refer to the same day and the current year: Sunday, Mar 17
+ * 2. When both dates refer to the same day but not the current year: Wednesday, Mar 17, 2023
+ * 3. When both dates refer to the current year: Sunday, Mar 17 to Wednesday, Mar 20
+ * 4. When the dates are from different years or from a year which is not current: Wednesday, Mar 17, 2023 to Saturday, Jan 20, 2024
+ */
+function getFormattedReservationRangeDate(date1: Date, date2: Date): string {
+ const {translateLocal} = Localize;
+ if (isSameDay(date1, date2) && isThisYear(date1)) {
+ // Dates are from the same day
+ return format(date1, 'EEEE, MMM d');
+ }
+ if (isSameDay(date1, date2)) {
+ // Dates are from the same day but not this year
+ return format(date1, 'EEEE, MMM d, yyyy');
+ }
+ if (isSameYear(date1, date2) && isThisYear(date1)) {
+ // Dates are in the current year, differ by months
+ return `${format(date1, 'EEEE, MMM d')} ${translateLocal('common.conjunctionTo')} ${format(date2, 'EEEE, MMM d')}`;
+ }
+ // Dates differ by years, months, days or only by months but the year is not current
+ return `${format(date1, 'EEEE, MMM d, yyyy')} ${translateLocal('common.conjunctionTo')} ${format(date2, 'EEEE, MMM d, yyyy')}`;
+}
+
+/**
+ * Returns a formatted date of departure.
+ * Dates are formatted as follows:
+ * 1. When the date refers to the current day: Departs on Sunday, Mar 17 at 8:00
+ * 2. When the date refers not to the current day: Departs on Wednesday, Mar 17, 2023 at 8:00
+ */
+function getFormattedTransportDate(date: Date): string {
+ const {translateLocal} = Localize;
+ if (isThisYear(date)) {
+ return `${translateLocal('travel.departs')} ${format(date, 'EEEE, MMM d')} ${translateLocal('common.conjunctionAt')} ${format(date, 'HH:MM')}`;
+ }
+ return `${translateLocal('travel.departs')} ${format(date, 'EEEE, MMM d, yyyy')} ${translateLocal('common.conjunctionAt')} ${format(date, 'HH:MM')}`;
+}
+
const DateUtils = {
formatToDayOfWeek,
formatToLongDateWithWeekday,
@@ -768,6 +837,9 @@ const DateUtils = {
formatToSupportedTimezone,
enrichMoneyRequestTimestamp,
getLastBusinessDayOfMonth,
+ getFormattedDateRange,
+ getFormattedReservationRangeDate,
+ getFormattedTransportDate,
};
export default DateUtils;
diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts
index 9cb48534214e..cbae1e0d3bfb 100644
--- a/src/libs/DistanceRequestUtils.ts
+++ b/src/libs/DistanceRequestUtils.ts
@@ -8,6 +8,7 @@ import type {LastSelectedDistanceRates, Report} from '@src/types/onyx';
import type {Unit} from '@src/types/onyx/Policy';
import type Policy from '@src/types/onyx/Policy';
import type {EmptyObject} from '@src/types/utils/EmptyObject';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
import * as CurrencyUtils from './CurrencyUtils';
import * as PolicyUtils from './PolicyUtils';
import * as ReportUtils from './ReportUtils';
@@ -38,6 +39,35 @@ Onyx.connect({
const METERS_TO_KM = 0.001; // 1 kilometer is 1000 meters
const METERS_TO_MILES = 0.000621371; // There are approximately 0.000621371 miles in a meter
+function getMileageRates(policy: OnyxEntry, includeDisabledRates = false): Record {
+ const mileageRates: Record = {};
+
+ if (!policy || !policy?.customUnits) {
+ return mileageRates;
+ }
+
+ const distanceUnit = PolicyUtils.getCustomUnit(policy);
+ if (!distanceUnit?.rates) {
+ return mileageRates;
+ }
+
+ Object.entries(distanceUnit.rates).forEach(([rateID, rate]) => {
+ if (!includeDisabledRates && rate.enabled === false) {
+ return;
+ }
+
+ mileageRates[rateID] = {
+ rate: rate.rate,
+ currency: rate.currency,
+ unit: distanceUnit.attributes.unit,
+ name: rate.name,
+ customUnitRateID: rate.customUnitRateID,
+ };
+ });
+
+ return mileageRates;
+}
+
/**
* Retrieves the default mileage rate based on a given policy.
*
@@ -49,16 +79,17 @@ const METERS_TO_MILES = 0.000621371; // There are approximately 0.000621371 mile
* @returns [unit] - The unit of measurement for the distance.
*/
function getDefaultMileageRate(policy: OnyxEntry | EmptyObject): MileageRate | null {
- if (!policy?.customUnits) {
+ if (isEmptyObject(policy) || !policy?.customUnits) {
return null;
}
- const distanceUnit = Object.values(policy.customUnits).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
+ const distanceUnit = PolicyUtils.getCustomUnit(policy);
if (!distanceUnit?.rates) {
return null;
}
+ const mileageRates = getMileageRates(policy);
- const distanceRate = Object.values(distanceUnit.rates).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE) ?? Object.values(distanceUnit.rates)[0];
+ const distanceRate = Object.values(mileageRates).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE) ?? Object.values(mileageRates)[0] ?? {};
return {
customUnitRateID: distanceRate.customUnitRateID,
@@ -179,38 +210,6 @@ function getDistanceMerchant(
return `${distanceInUnits} @ ${ratePerUnit}`;
}
-/**
- * Retrieves the mileage rates for given policy.
- *
- * @param policy - The policy from which to extract the mileage rates.
- *
- * @returns An array of mileage rates or an empty array if not found.
- */
-function getMileageRates(policy: OnyxEntry): Record {
- const mileageRates: Record = {};
-
- if (!policy || !policy?.customUnits) {
- return mileageRates;
- }
-
- const distanceUnit = Object.values(policy.customUnits).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
- if (!distanceUnit?.rates) {
- return mileageRates;
- }
-
- Object.entries(distanceUnit.rates).forEach(([rateID, rate]) => {
- mileageRates[rateID] = {
- rate: rate.rate,
- currency: rate.currency,
- unit: distanceUnit.attributes.unit,
- name: rate.name,
- customUnitRateID: rate.customUnitRateID,
- };
- });
-
- return mileageRates;
-}
-
/**
* Retrieves the rate and unit for a P2P distance expense for a given currency.
*
@@ -256,16 +255,38 @@ function getCustomUnitRateID(reportID: string) {
const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? null;
const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`] ?? null;
const policy = PolicyUtils.getPolicy(report?.policyID ?? parentReport?.policyID ?? '');
-
let customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID;
if (ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isPolicyExpenseChat(parentReport)) {
- customUnitRateID = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? getDefaultMileageRate(policy)?.customUnitRateID ?? '';
+ const distanceUnit = Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
+ const lastSelectedDistanceRateID = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? '';
+ const lastSelectedDistanceRate = distanceUnit?.rates[lastSelectedDistanceRateID] ?? {};
+ if (lastSelectedDistanceRate.enabled && lastSelectedDistanceRateID) {
+ customUnitRateID = lastSelectedDistanceRateID;
+ } else {
+ customUnitRateID = getDefaultMileageRate(policy)?.customUnitRateID ?? '';
+ }
}
return customUnitRateID;
}
+/**
+ * Get taxable amount from a specific distance rate, taking into consideration the tax claimable amount configured for the distance rate
+ */
+function getTaxableAmount(policy: OnyxEntry, customUnitRateID: string, distance: number) {
+ const distanceUnit = PolicyUtils.getCustomUnit(policy);
+ const customUnitRate = PolicyUtils.getCustomUnitRate(policy, customUnitRateID);
+ if (!distanceUnit || !distanceUnit?.customUnitID || !customUnitRate) {
+ return 0;
+ }
+ const unit = distanceUnit?.attributes?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES;
+ const rate = customUnitRate?.rate ?? 0;
+ const amount = getDistanceRequestAmount(distance, unit, rate);
+ const taxClaimablePercentage = customUnitRate.attributes?.taxClaimablePercentage ?? 0;
+ return amount * taxClaimablePercentage;
+}
+
export default {
getDefaultMileageRate,
getDistanceMerchant,
@@ -276,6 +297,7 @@ export default {
getRateForP2P,
getCustomUnitRateID,
convertToDistanceInMeters,
+ getTaxableAmount,
};
export type {MileageRate};
diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts
index 0be7e76a0aa9..f96bdd573cfe 100644
--- a/src/libs/EmojiUtils.ts
+++ b/src/libs/EmojiUtils.ts
@@ -1,5 +1,5 @@
import {getUnixTime} from 'date-fns';
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import memoize from 'lodash/memoize';
import Onyx from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
@@ -519,13 +519,13 @@ const enrichEmojiReactionWithTimestamps = (emoji: ReportActionReaction, emojiNam
*/
function hasAccountIDEmojiReacted(accountID: number, usersReactions: UsersReactions, skinTone?: number) {
if (skinTone === undefined) {
- return Boolean(usersReactions[accountID]);
+ return !!usersReactions[accountID];
}
const userReaction = usersReactions[accountID];
if (!userReaction?.skinTones || !Object.values(userReaction?.skinTones ?? {}).length) {
return false;
}
- return Boolean(userReaction.skinTones[skinTone]);
+ return !!userReaction.skinTones[skinTone];
}
/**
diff --git a/src/libs/Environment/betaChecker/index.android.ts b/src/libs/Environment/betaChecker/index.android.ts
index da65ae1b7383..258bf80aab13 100644
--- a/src/libs/Environment/betaChecker/index.android.ts
+++ b/src/libs/Environment/betaChecker/index.android.ts
@@ -10,7 +10,7 @@ let isLastSavedBeta = false;
Onyx.connect({
key: ONYXKEYS.IS_BETA,
callback: (value) => {
- isLastSavedBeta = Boolean(value);
+ isLastSavedBeta = !!value;
},
});
diff --git a/src/libs/Fullstory/index.native.ts b/src/libs/Fullstory/index.native.ts
index d26d449b5fd9..4a7551beca77 100644
--- a/src/libs/Fullstory/index.native.ts
+++ b/src/libs/Fullstory/index.native.ts
@@ -1,5 +1,6 @@
import FullStory, {FSPage} from '@fullstory/react-native';
import type {OnyxEntry} from 'react-native-onyx';
+import * as Environment from '@src/libs/Environment/Environment';
import type {UserMetadata} from '@src/types/onyx';
/**
@@ -40,9 +41,13 @@ const FS = {
// anonymize FullStory user identity metadata
FullStory.anonymize();
} else {
- // define FullStory user identity
- FullStory.identify(String(metadata.accountID), {
- properties: metadata,
+ Environment.getEnvironment().then((envName: string) => {
+ // define FullStory user identity
+ const localMetadata = metadata;
+ localMetadata.environment = envName;
+ FullStory.identify(String(localMetadata.accountID), {
+ properties: localMetadata,
+ });
});
}
},
diff --git a/src/libs/Fullstory/index.ts b/src/libs/Fullstory/index.ts
index 24c725acf81a..a9c75ad838e9 100644
--- a/src/libs/Fullstory/index.ts
+++ b/src/libs/Fullstory/index.ts
@@ -63,7 +63,11 @@ const FS = {
}
FS.onReady().then(() => {
FS.consent(true);
- FS.fsIdentify(value);
+ if (value) {
+ const localMetadata = value;
+ localMetadata.environment = envName;
+ FS.fsIdentify(localMetadata);
+ }
});
});
} catch (e) {
diff --git a/src/libs/GetStyledTextArray.ts b/src/libs/GetStyledTextArray.ts
index ffae31dc861b..9eb8971c81bd 100644
--- a/src/libs/GetStyledTextArray.ts
+++ b/src/libs/GetStyledTextArray.ts
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import StringUtils from './StringUtils';
type StyledText = {
diff --git a/src/libs/HttpUtils.ts b/src/libs/HttpUtils.ts
index b254303d1784..a826c668be12 100644
--- a/src/libs/HttpUtils.ts
+++ b/src/libs/HttpUtils.ts
@@ -27,8 +27,8 @@ Onyx.connect({
if (!network) {
return;
}
- shouldFailAllRequests = Boolean(network.shouldFailAllRequests);
- shouldForceOffline = Boolean(network.shouldForceOffline);
+ shouldFailAllRequests = !!network.shouldFailAllRequests;
+ shouldForceOffline = !!network.shouldForceOffline;
},
});
diff --git a/src/libs/KeyboardShortcut/index.ts b/src/libs/KeyboardShortcut/index.ts
index b349c43b5715..4f89d44da959 100644
--- a/src/libs/KeyboardShortcut/index.ts
+++ b/src/libs/KeyboardShortcut/index.ts
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import * as KeyCommand from 'react-native-key-command';
import getOperatingSystem from '@libs/getOperatingSystem';
import localeCompare from '@libs/LocaleCompare';
diff --git a/src/libs/LocalePhoneNumber.ts b/src/libs/LocalePhoneNumber.ts
index 460d5fc0fe9f..3e3e4c4e0def 100644
--- a/src/libs/LocalePhoneNumber.ts
+++ b/src/libs/LocalePhoneNumber.ts
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import Onyx from 'react-native-onyx';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -19,6 +19,9 @@ function formatPhoneNumber(number: string): string {
return '';
}
+ // eslint-disable-next-line no-param-reassign
+ number = number.replace(/ /g, '\u00A0');
+
// do not parse the string, if it doesn't contain the SMS domain and it's not a phone number
if (number.indexOf(CONST.SMS.DOMAIN) === -1 && !CONST.REGEX.DIGITS_AND_PLUS.test(number)) {
return number;
diff --git a/src/libs/Log.ts b/src/libs/Log.ts
index 101996870e1d..64271dee2265 100644
--- a/src/libs/Log.ts
+++ b/src/libs/Log.ts
@@ -2,7 +2,7 @@
// action would likely cause confusion about which one to use. But most other API methods should happen inside an action file.
/* eslint-disable rulesdir/no-api-in-views */
-import Logger from 'expensify-common/lib/Logger';
+import {ExpensiMark, Logger} from 'expensify-common';
import Onyx from 'react-native-onyx';
import type {Merge} from 'type-fest';
import CONST from '@src/CONST';
@@ -24,7 +24,7 @@ Onyx.connect({
shouldCollectLogs = false;
}
- shouldCollectLogs = Boolean(val);
+ shouldCollectLogs = !!val;
},
});
@@ -80,5 +80,6 @@ const Log = new Logger({
isDebug: true,
});
timeout = setTimeout(() => Log.info('Flushing logs older than 10 minutes', true, {}, true), 10 * 60 * 1000);
+ExpensiMark.setLogger(Log);
export default Log;
diff --git a/src/libs/LoginUtils.ts b/src/libs/LoginUtils.ts
index 2d49cb992717..ded60ab3e800 100644
--- a/src/libs/LoginUtils.ts
+++ b/src/libs/LoginUtils.ts
@@ -1,5 +1,4 @@
-import {PUBLIC_DOMAINS} from 'expensify-common/lib/CONST';
-import Str from 'expensify-common/lib/str';
+import {PUBLIC_DOMAINS, Str} from 'expensify-common';
import Onyx from 'react-native-onyx';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts
index 2df75030ac19..c1c2165fd3a7 100644
--- a/src/libs/ModifiedExpenseMessage.ts
+++ b/src/libs/ModifiedExpenseMessage.ts
@@ -3,12 +3,12 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PolicyTagList, ReportAction} from '@src/types/onyx';
+import type {ModifiedExpense} from '@src/types/onyx/OriginalMessage';
import * as CurrencyUtils from './CurrencyUtils';
import DateUtils from './DateUtils';
import getReportPolicyID from './getReportPolicyID';
import * as Localize from './Localize';
import * as PolicyUtils from './PolicyUtils';
-import type {ExpenseOriginalMessage} from './ReportUtils';
import * as TransactionUtils from './TransactionUtils';
let allPolicyTags: OnyxCollection = {};
@@ -109,7 +109,7 @@ function getForReportAction(reportID: string | undefined, reportAction: OnyxEntr
if (reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE) {
return '';
}
- const reportActionOriginalMessage = reportAction?.originalMessage as ExpenseOriginalMessage | undefined;
+ const reportActionOriginalMessage = reportAction?.originalMessage as ModifiedExpense | undefined;
const policyID = getReportPolicyID(reportID) ?? '';
const removalFragments: string[] = [];
@@ -126,11 +126,11 @@ function getForReportAction(reportID: string | undefined, reportAction: OnyxEntr
const hasModifiedMerchant = reportActionOriginalMessage && 'oldMerchant' in reportActionOriginalMessage && 'merchant' in reportActionOriginalMessage;
if (hasModifiedAmount) {
- const oldCurrency = reportActionOriginalMessage?.oldCurrency ?? '';
+ const oldCurrency = reportActionOriginalMessage?.oldCurrency;
const oldAmountValue = reportActionOriginalMessage?.oldAmount ?? 0;
const oldAmount = oldAmountValue > 0 ? CurrencyUtils.convertToDisplayString(reportActionOriginalMessage?.oldAmount ?? 0, oldCurrency) : '';
- const currency = reportActionOriginalMessage?.currency ?? '';
+ const currency = reportActionOriginalMessage?.currency;
const amount = CurrencyUtils.convertToDisplayString(reportActionOriginalMessage?.amount ?? 0, currency);
// Only Distance edits should modify amount and merchant (which stores distance) in a single transaction.
diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts
index 1d55c0f49356..67ba9f62421d 100644
--- a/src/libs/MoneyRequestUtils.ts
+++ b/src/libs/MoneyRequestUtils.ts
@@ -37,31 +37,16 @@ function addLeadingZero(amount: string): string {
return amount.startsWith('.') ? `0${amount}` : amount;
}
-/**
- * Calculate the length of the amount with leading zeroes
- */
-function calculateAmountLength(amount: string, decimals: number): number {
- const leadingZeroes = amount.match(/^0+/);
- const leadingZeroesLength = leadingZeroes?.[0]?.length ?? 0;
- const absAmount = parseFloat((Number(stripCommaFromAmount(amount)) * 10 ** decimals).toFixed(2)).toString();
-
- if (/\D/.test(absAmount)) {
- return CONST.IOU.AMOUNT_MAX_LENGTH + 1;
- }
-
- return leadingZeroesLength + (absAmount === '0' ? 2 : absAmount.length);
-}
-
/**
* Check if amount is a decimal up to 3 digits
*/
function validateAmount(amount: string, decimals: number, amountMaxLength: number = CONST.IOU.AMOUNT_MAX_LENGTH): boolean {
const regexString =
decimals === 0
- ? `^\\d+(,\\d*)*$` // Don't allow decimal point if decimals === 0
- : `^\\d+(,\\d*)*(\\.\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point
+ ? `^\\d{1,${amountMaxLength}}$` // Don't allow decimal point if decimals === 0
+ : `^\\d{1,${amountMaxLength}}(\\.\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point
const decimalNumberRegex = new RegExp(regexString, 'i');
- return amount === '' || (decimalNumberRegex.test(amount) && calculateAmountLength(amount, decimals) <= amountMaxLength);
+ return amount === '' || decimalNumberRegex.test(amount);
}
/**
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index dad9178aaf45..807c938e21dd 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -4,7 +4,6 @@ import {createStackNavigator} from '@react-navigation/stack';
import React from 'react';
import type {
AddPersonalBankAccountNavigatorParamList,
- DetailsNavigatorParamList,
EditRequestNavigatorParamList,
EnablePaymentsNavigatorParamList,
FlagCommentNavigatorParamList,
@@ -104,10 +103,6 @@ const SplitDetailsModalStackNavigator = createModalStackNavigator require('../../../../pages/iou/SplitBillDetailsPage').default as React.ComponentType,
});
-const DetailsModalStackNavigator = createModalStackNavigator({
- [SCREENS.DETAILS_ROOT]: () => require('../../../../pages/DetailsPage').default as React.ComponentType,
-});
-
const ProfileModalStackNavigator = createModalStackNavigator({
[SCREENS.PROFILE_ROOT]: () => require('../../../../pages/ProfilePage').default as React.ComponentType,
});
@@ -222,6 +217,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Profile/CustomStatus/StatusClearAfterPage').default as React.ComponentType,
[SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: () => require('../../../../pages/settings/Profile/CustomStatus/SetDatePage').default as React.ComponentType,
[SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: () => require('../../../../pages/settings/Profile/CustomStatus/SetTimePage').default as React.ComponentType,
+ [SCREENS.SETTINGS.SUBSCRIPTION.SIZE]: () => require('../../../../pages/settings/Subscription/SubscriptionSize/SubscriptionSizePage').default as React.ComponentType,
+ [SCREENS.SETTINGS.SUBSCRIPTION.DISABLE_AUTO_RENEW_SURVEY]: () => require('../../../../pages/settings/Subscription/DisableAutoRenewSurveyPage').default as React.ComponentType,
[SCREENS.WORKSPACE.RATE_AND_UNIT]: () => require('../../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/InitialPage').default as React.ComponentType,
[SCREENS.WORKSPACE.RATE_AND_UNIT_RATE]: () => require('../../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage').default as React.ComponentType,
[SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT]: () => require('../../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage').default as React.ComponentType,
@@ -246,6 +243,9 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage').default as React.ComponentType,
[SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRateDetailsPage').default as React.ComponentType,
[SCREENS.WORKSPACE.DISTANCE_RATE_EDIT]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRateEditPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT]: () =>
+ require('../../../../pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RATE_EDIT]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRateTaxRateEditPage').default as React.ComponentType,
[SCREENS.WORKSPACE.TAGS_SETTINGS]: () => require('../../../../pages/workspace/tags/WorkspaceTagsSettingsPage').default as React.ComponentType,
[SCREENS.WORKSPACE.TAG_SETTINGS]: () => require('../../../../pages/workspace/tags/TagSettingsPage').default as React.ComponentType,
[SCREENS.WORKSPACE.TAG_LIST_VIEW]: () => require('../../../../pages/workspace/tags/WorkspaceViewTagsPage').default as React.ComponentType,
@@ -330,6 +330,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/taxes/ValuePage').default as React.ComponentType,
[SCREENS.WORKSPACE.TAX_CREATE]: () => require('../../../../pages/workspace/taxes/WorkspaceCreateTaxPage').default as React.ComponentType,
[SCREENS.SETTINGS.SAVE_THE_WORLD]: () => require('../../../../pages/TeachersUnite/SaveTheWorldPage').default as React.ComponentType,
+ [SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD]: () => require('../../../../pages/settings/Subscription/PaymentCard/AddPaymentCard').default as React.ComponentType,
});
const EnablePaymentsStackNavigator = createModalStackNavigator({
@@ -378,7 +379,6 @@ const SearchReportModalStackNavigator = createModalStackNavigator
)}
-
+
)}
-
+
-
-
+ {
Navigation.navigate(ROUTES.HOME);
}}
role={CONST.ROLE.BUTTON}
- accessibilityLabel={translate('common.chats')}
+ accessibilityLabel={translate('common.inbox')}
wrapperStyle={styles.flex1}
style={styles.bottomTabBarItem}
>
-
+
+ {
+ Navigation.navigate(ROUTES.SEARCH.getRoute(CONST.TAB_SEARCH.ALL));
+ }}
+ role={CONST.ROLE.BUTTON}
+ accessibilityLabel={translate('common.search')}
+ wrapperStyle={styles.flex1}
+ style={styles.bottomTabBarItem}
+ >
+
+
+
+
+
+
+
+
);
}
diff --git a/src/libs/Navigation/AppNavigator/index.native.tsx b/src/libs/Navigation/AppNavigator/index.native.tsx
new file mode 100644
index 000000000000..f740f9eb5b94
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/index.native.tsx
@@ -0,0 +1,38 @@
+import React, {memo, useContext, useEffect} from 'react';
+import {NativeModules} from 'react-native';
+import {InitialURLContext} from '@components/InitialURLContextProvider';
+import Navigation from '@libs/Navigation/Navigation';
+
+type AppNavigatorProps = {
+ /** If we have an authToken this is true */
+ authenticated: boolean;
+};
+
+function AppNavigator({authenticated}: AppNavigatorProps) {
+ const initUrl = useContext(InitialURLContext);
+
+ useEffect(() => {
+ if (!NativeModules.HybridAppModule || !initUrl) {
+ return;
+ }
+
+ Navigation.isNavigationReady().then(() => {
+ Navigation.navigate(initUrl);
+ });
+ }, [initUrl]);
+
+ if (authenticated) {
+ const AuthScreens = require('./AuthScreens').default;
+
+ // These are the protected screens and only accessible when an authToken is present
+ return ;
+ }
+
+ const PublicScreens = require('./PublicScreens').default;
+
+ return ;
+}
+
+AppNavigator.displayName = 'AppNavigator';
+
+export default memo(AppNavigator);
diff --git a/src/libs/Navigation/AppNavigator/index.tsx b/src/libs/Navigation/AppNavigator/index.tsx
index 9729a2f812ce..f8b14781a5ec 100644
--- a/src/libs/Navigation/AppNavigator/index.tsx
+++ b/src/libs/Navigation/AppNavigator/index.tsx
@@ -1,8 +1,11 @@
-import React, {useContext, useEffect} from 'react';
+import React, {lazy, memo, Suspense, useContext, useEffect} from 'react';
import {NativeModules} from 'react-native';
import {InitialURLContext} from '@components/InitialURLContextProvider';
import Navigation from '@libs/Navigation/Navigation';
+const AuthScreens = lazy(() => import('./AuthScreens'));
+const PublicScreens = lazy(() => import('./PublicScreens'));
+
type AppNavigatorProps = {
/** If we have an authToken this is true */
authenticated: boolean;
@@ -22,14 +25,21 @@ function AppNavigator({authenticated}: AppNavigatorProps) {
}, [initUrl]);
if (authenticated) {
- const AuthScreens = require('./AuthScreens').default;
-
// These are the protected screens and only accessible when an authToken is present
- return ;
+ return (
+
+
+
+ );
}
- const PublicScreens = require('./PublicScreens').default;
- return ;
+
+ return (
+
+
+
+ );
}
AppNavigator.displayName = 'AppNavigator';
-export default AppNavigator;
+
+export default memo(AppNavigator);
diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts
index 3fa3e0c5c317..34e093f2b74b 100644
--- a/src/libs/Navigation/Navigation.ts
+++ b/src/libs/Navigation/Navigation.ts
@@ -356,14 +356,6 @@ function navigateWithSwitchPolicyID(params: SwitchPolicyIDParams) {
return switchPolicyID(navigationRef.current, params);
}
-/** Check if the modal is being displayed */
-function isDisplayedInModal() {
- const state = navigationRef?.current?.getRootState();
- const lastRoute = state?.routes?.at(-1);
- const lastRouteName = lastRoute?.name;
- return lastRouteName === NAVIGATORS.LEFT_MODAL_NAVIGATOR || lastRouteName === NAVIGATORS.RIGHT_MODAL_NAVIGATOR;
-}
-
export default {
setShouldPopAllStateOnUP,
navigate,
@@ -383,7 +375,6 @@ export default {
parseHybridAppUrl,
navigateWithSwitchPolicyID,
resetToHome,
- isDisplayedInModal,
closeRHPFlow,
setNavigationActionToMicrotaskQueue,
};
diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx
index 06a3dce8d59a..9e1eb348451b 100644
--- a/src/libs/Navigation/NavigationRoot.tsx
+++ b/src/libs/Navigation/NavigationRoot.tsx
@@ -135,6 +135,11 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
// We want to clean saved scroll offsets for screens that aren't anymore in the state.
cleanStaleScrollOffsets(state);
+
+ // clear all window selection on navigation
+ // this is to prevent the selection from persisting when navigating to a new page in web
+ // using "?" to avoid crash in native
+ window?.getSelection?.()?.removeAllRanges?.();
};
return (
diff --git a/src/libs/Navigation/linkTo/index.ts b/src/libs/Navigation/linkTo/index.ts
index b9839483a056..313f525f390b 100644
--- a/src/libs/Navigation/linkTo/index.ts
+++ b/src/libs/Navigation/linkTo/index.ts
@@ -71,7 +71,7 @@ export default function linkTo(navigation: NavigationContainerRef | undefined, (value) => value === undefined),
);
- // If the type is UP, we deeplinked into one of the RHP flows and we want to replace the current screen with the previous one in the flow
- // and at the same time we want the back button to go to the page we were before the deeplink
- if (type === CONST.NAVIGATION.TYPE.UP) {
- action.type = CONST.NAVIGATION.ACTION_TYPE.REPLACE;
-
- // If this action is navigating to the report screen and the top most navigator is different from the one we want to navigate - PUSH the new screen to the top of the stack
- } else if (action.payload.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR && (isTargetScreenDifferentThanCurrent || areParamsDifferent)) {
+ // If this action is navigating to the report screen and the top most navigator is different from the one we want to navigate - PUSH the new screen to the top of the stack by default
+ if (action.payload.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR && (isTargetScreenDifferentThanCurrent || areParamsDifferent)) {
// We need to push a tab if the tab doesn't match the central pane route that we are going to push.
const topmostBottomTabRoute = getTopmostBottomTabRoute(rootState);
const policyIDsFromState = extractPolicyIDsFromState(stateFromPath);
@@ -100,13 +95,22 @@ export default function linkTo(navigation: NavigationContainerRef> =
[SCREENS.SETTINGS.SAVE_THE_WORLD]: [SCREENS.I_KNOW_A_TEACHER, SCREENS.INTRO_SCHOOL_PRINCIPAL, SCREENS.I_AM_A_TEACHER],
[SCREENS.SETTINGS.TROUBLESHOOT]: [SCREENS.SETTINGS.CONSOLE],
[SCREENS.SEARCH.CENTRAL_PANE]: [SCREENS.SEARCH.REPORT_RHP],
- [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: [],
+ [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: [SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD, SCREENS.SETTINGS.SUBSCRIPTION.SIZE, SCREENS.SETTINGS.SUBSCRIPTION.DISABLE_AUTO_RENEW_SURVEY],
};
export default CENTRAL_PANE_TO_RHP_MAPPING;
diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
index 2aaceb96f52a..f91d290639ff 100755
--- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
@@ -79,6 +79,8 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
SCREENS.WORKSPACE.CREATE_DISTANCE_RATE,
SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS,
SCREENS.WORKSPACE.DISTANCE_RATE_EDIT,
+ SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT,
+ SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RATE_EDIT,
SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS,
],
};
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 2bd3642ce912..bb002ec2c01f 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -126,6 +126,10 @@ const config: LinkingOptions['config'] = {
path: ROUTES.SETTINGS_LANGUAGE,
exact: true,
},
+ [SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD]: {
+ path: ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD,
+ exact: true,
+ },
[SCREENS.SETTINGS.PREFERENCES.THEME]: {
path: ROUTES.SETTINGS_THEME,
exact: true,
@@ -277,6 +281,12 @@ const config: LinkingOptions['config'] = {
[SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: {
path: ROUTES.SETTINGS_STATUS_CLEAR_AFTER_TIME,
},
+ [SCREENS.SETTINGS.SUBSCRIPTION.SIZE]: {
+ path: ROUTES.SETTINGS_SUBSCRIPTION_SIZE,
+ },
+ [SCREENS.SETTINGS.SUBSCRIPTION.DISABLE_AUTO_RENEW_SURVEY]: {
+ path: ROUTES.SETTINGS_SUBSCRIPTION_DISABLE_AUTO_RENEW_SURVEY,
+ },
[SCREENS.WORKSPACE.CURRENCY]: {
path: ROUTES.WORKSPACE_PROFILE_CURRENCY.route,
},
@@ -413,6 +423,12 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.DISTANCE_RATE_EDIT]: {
path: ROUTES.WORKSPACE_DISTANCE_RATE_EDIT.route,
},
+ [SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT]: {
+ path: ROUTES.WORKSPACE_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT.route,
+ },
+ [SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RATE_EDIT]: {
+ path: ROUTES.WORKSPACE_DISTANCE_RATE_TAX_RATE_EDIT.route,
+ },
[SCREENS.WORKSPACE.TAGS_SETTINGS]: {
path: ROUTES.WORKSPACE_TAGS_SETTINGS.route,
},
@@ -604,11 +620,6 @@ const config: LinkingOptions['config'] = {
[SCREENS.I_AM_A_TEACHER]: ROUTES.I_AM_A_TEACHER,
},
},
- [SCREENS.RIGHT_MODAL.DETAILS]: {
- screens: {
- [SCREENS.DETAILS_ROOT]: ROUTES.DETAILS.route,
- },
- },
[SCREENS.RIGHT_MODAL.PROFILE]: {
screens: {
[SCREENS.PROFILE_ROOT]: ROUTES.PROFILE.route,
diff --git a/src/libs/Navigation/switchPolicyID.ts b/src/libs/Navigation/switchPolicyID.ts
index c526773c3fce..78d23cb9d53c 100644
--- a/src/libs/Navigation/switchPolicyID.ts
+++ b/src/libs/Navigation/switchPolicyID.ts
@@ -75,7 +75,7 @@ export default function switchPolicyID(navigation: NavigationContainerRef {
isSequentialQueueRunning = false;
- resolveIsReadyPromise?.();
+ if (NetworkStore.isOffline() || PersistedRequests.getAll().length === 0) {
+ resolveIsReadyPromise?.();
+ }
currentRequest = null;
flushOnyxUpdatesQueue();
});
diff --git a/src/libs/NetworkConnection.ts b/src/libs/NetworkConnection.ts
index b3dd24fcd4ae..2d020ec778ae 100644
--- a/src/libs/NetworkConnection.ts
+++ b/src/libs/NetworkConnection.ts
@@ -68,7 +68,7 @@ Onyx.connect({
if (!network) {
return;
}
- const currentShouldForceOffline = Boolean(network.shouldForceOffline);
+ const currentShouldForceOffline = !!network.shouldForceOffline;
if (currentShouldForceOffline === shouldForceOffline) {
return;
}
@@ -79,7 +79,7 @@ Onyx.connect({
} else {
// If we are no longer forcing offline fetch the NetInfo to set isOffline appropriately
NetInfo.fetch().then((state) => {
- const isInternetReachable = Boolean(state.isInternetReachable);
+ const isInternetReachable = !!state.isInternetReachable;
setOfflineStatus(isInternetReachable);
Log.info(
`[NetworkStatus] The force-offline mode was turned off. Getting the device network status from NetInfo. Network state: ${JSON.stringify(
@@ -100,6 +100,10 @@ function subscribeToBackendAndInternetReachability(): () => void {
const intervalID = setInterval(() => {
// Offline status also implies backend unreachability
if (isOffline) {
+ // Periodically recheck the network connection
+ // More info: https://github.com/Expensify/App/issues/42988
+ recheckNetworkConnection();
+ Log.info(`[NetworkStatus] Rechecking the network connection with "isOffline" set to "true" to double-check internet reachability.`);
return;
}
// Using the API url ensures reachability is tested over internet
@@ -160,7 +164,7 @@ function subscribeToNetworkStatus(): () => void {
return;
}
setOfflineStatus(state.isInternetReachable === false);
- Log.info(`[NetworkStatus] NetInfo.addEventListener event coming, setting "offlineStatus" to ${Boolean(state.isInternetReachable)} with network state: ${JSON.stringify(state)}`);
+ Log.info(`[NetworkStatus] NetInfo.addEventListener event coming, setting "offlineStatus" to ${!!state.isInternetReachable} with network state: ${JSON.stringify(state)}`);
setNetWorkStatus(state.isInternetReachable);
});
diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts
index daa2015b5cd0..484104ebb881 100644
--- a/src/libs/NextStepUtils.ts
+++ b/src/libs/NextStepUtils.ts
@@ -1,5 +1,5 @@
import {format, lastDayOfMonth, setDate} from 'date-fns';
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import Onyx from 'react-native-onyx';
import type {OnyxCollection} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
diff --git a/src/libs/Notification/LocalNotification/BrowserNotifications.ts b/src/libs/Notification/LocalNotification/BrowserNotifications.ts
index 18fd8256a5ec..4ba13418b29e 100644
--- a/src/libs/Notification/LocalNotification/BrowserNotifications.ts
+++ b/src/libs/Notification/LocalNotification/BrowserNotifications.ts
@@ -1,5 +1,5 @@
// Web and desktop implementation only. Do not import for direct use. Use LocalNotification.
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import type {ImageSourcePropType} from 'react-native';
import EXPENSIFY_ICON_URL from '@assets/images/expensify-logo-round-clearspace.png';
import * as AppUpdate from '@libs/actions/AppUpdate';
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index fdf7f4d0cebb..9840e29ff347 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -1,5 +1,5 @@
/* eslint-disable no-continue */
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
// eslint-disable-next-line you-dont-need-lodash-underscore/get
import lodashGet from 'lodash/get';
import lodashOrderBy from 'lodash/orderBy';
@@ -144,7 +144,6 @@ type GetOptionsConfig = {
maxRecentReportsToShow?: number;
excludeLogins?: string[];
includeMultipleParticipantReports?: boolean;
- includePersonalDetails?: boolean;
includeRecentReports?: boolean;
includeSelfDM?: boolean;
sortByReportTypeInSearch?: boolean;
@@ -174,6 +173,7 @@ type GetOptionsConfig = {
policyReportFieldOptions?: string[];
recentlyUsedPolicyReportFieldOptions?: string[];
transactionViolations?: OnyxCollection;
+ includeInvoiceRooms?: boolean;
};
type GetUserToInviteConfig = {
@@ -531,14 +531,14 @@ function getAllReportErrors(report: OnyxEntry, reportActions: OnyxEntry<
const parentReportAction: OnyxEntry =
!report?.parentReportID || !report?.parentReportActionID ? null : allReportActions?.[report.parentReportID ?? '']?.[report.parentReportActionID ?? ''] ?? null;
- if (parentReportAction?.actorAccountID === currentUserAccountID && ReportActionUtils.isTransactionThread(parentReportAction)) {
+ if (ReportActionUtils.wasActionTakenByCurrentUser(parentReportAction) && ReportActionUtils.isTransactionThread(parentReportAction)) {
const transactionID = parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction?.originalMessage?.IOUTransactionID : null;
const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
if (TransactionUtils.hasMissingSmartscanFields(transaction ?? null) && !ReportUtils.isSettled(transaction?.reportID)) {
reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxError('report.genericSmartscanFailureMessage');
}
} else if ((ReportUtils.isIOUReport(report) || ReportUtils.isExpenseReport(report)) && report?.ownerAccountID === currentUserAccountID) {
- if (ReportUtils.hasMissingSmartscanFields(report?.reportID ?? '') && !ReportUtils.isSettled(report?.reportID)) {
+ if (ReportUtils.shouldShowRBRForMissingSmartscanFields(report?.reportID ?? '') && !ReportUtils.isSettled(report?.reportID)) {
reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxError('report.genericSmartscanFailureMessage');
}
} else if (ReportUtils.hasSmartscanError(Object.values(reportActions ?? {}))) {
@@ -576,12 +576,22 @@ function getLastActorDisplayName(lastActorDetails: Partial | nu
* Update alternate text for the option when applicable
*/
function getAlternateText(option: ReportUtils.OptionData, {showChatPreviewLine = false, forcePolicyNamePreview = false}: PreviewConfig) {
+ const report = ReportUtils.getReport(option.reportID);
+ const isAdminRoom = ReportUtils.isAdminRoom(report);
+ const isAnnounceRoom = ReportUtils.isAnnounceRoom(report);
+
if (!!option.isThread || !!option.isMoneyRequestReport) {
return option.lastMessageText ? option.lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet');
}
- if (!!option.isChatRoom || !!option.isPolicyExpenseChat) {
+
+ if (option.isChatRoom && !isAdminRoom && !isAnnounceRoom) {
+ return showChatPreviewLine && option.lastMessageText ? option.lastMessageText : option.subtitle;
+ }
+
+ if ((option.isPolicyExpenseChat ?? false) || isAdminRoom || isAnnounceRoom) {
return showChatPreviewLine && !forcePolicyNamePreview && option.lastMessageText ? option.lastMessageText : option.subtitle;
}
+
if (option.isTaskReport) {
return showChatPreviewLine && option.lastMessageText ? option.lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet');
}
@@ -1529,7 +1539,8 @@ function createOptionList(personalDetails: OnyxEntry, repor
.map(Number)
.filter((accountID) => accountID !== currentUserAccountID || !isOneOnOneChat);
- if (!accountIDs || accountIDs.length === 0) {
+ const isChatRoom = ReportUtils.isChatRoom(report);
+ if ((!accountIDs || accountIDs.length === 0) && !isChatRoom) {
return;
}
@@ -1582,6 +1593,9 @@ function orderOptions(options: ReportUtils.OptionData[], searchValue: string | u
options,
[
(option) => {
+ if (option.isSelfDM) {
+ return 0;
+ }
if (preferChatroomsOverThreads && option.isThread) {
return 4;
}
@@ -1684,7 +1698,6 @@ function getOptions(
maxRecentReportsToShow = 0,
excludeLogins = [],
includeMultipleParticipantReports = false,
- includePersonalDetails = false,
includeRecentReports = false,
// When sortByReportTypeInSearch flag is true, recentReports will include the personalDetails options as well.
sortByReportTypeInSearch = false,
@@ -1714,6 +1727,7 @@ function getOptions(
includePolicyReportFieldOptions = false,
policyReportFieldOptions = [],
recentlyUsedPolicyReportFieldOptions = [],
+ includeInvoiceRooms = false,
}: GetOptionsConfig,
): Options {
if (includeCategories) {
@@ -1801,6 +1815,7 @@ function getOptions(
// Sorting the reports works like this:
// - Order everything by the last message timestamp (descending)
+ // - When searching, self DM is put at the top
// - All archived reports should remain at the bottom
const orderedReportOptions = lodashSortBy(filteredReportOptions, (option) => {
const report = option.item;
@@ -1808,6 +1823,10 @@ function getOptions(
return CONST.DATE.UNIX_EPOCH;
}
+ if (searchValue) {
+ return [option.isSelfDM, report?.lastVisibleActionCreated];
+ }
+
return report?.lastVisibleActionCreated;
});
orderedReportOptions.reverse();
@@ -1825,6 +1844,7 @@ function getOptions(
const isMoneyRequestReport = option.isMoneyRequestReport;
const isSelfDM = option.isSelfDM;
const isOneOnOneChat = option.isOneOnOneChat;
+ const isChatRoom = option.isChatRoom;
// For 1:1 chat, we don't want to include currentUser as participants in order to not mark 1:1 chats as having multiple participants
const accountIDs = Object.keys(report.participants ?? {})
@@ -1861,14 +1881,14 @@ function getOptions(
return;
}
- if (!accountIDs || accountIDs.length === 0) {
+ if ((!accountIDs || accountIDs.length === 0) && !isChatRoom) {
return;
}
return option;
});
- const havingLoginPersonalDetails = options.personalDetails.filter((detail) => !!detail?.login && !!detail.accountID && !detail?.isOptimisticPersonalDetail);
+ const havingLoginPersonalDetails = includeP2P ? options.personalDetails.filter((detail) => !!detail?.login && !!detail.accountID && !detail?.isOptimisticPersonalDetail) : [];
let allPersonalDetailsOptions = havingLoginPersonalDetails;
if (sortPersonalDetailsByAlphaAsc) {
@@ -1913,8 +1933,16 @@ function getOptions(
const isCurrentUserOwnedPolicyExpenseChatThatCouldShow =
reportOption.isPolicyExpenseChat && reportOption.ownerAccountID === currentUserAccountID && includeOwnedWorkspaceChats && !reportOption.isArchivedRoom;
- // Skip if we aren't including multiple participant reports and this report has multiple participants
- if (!isCurrentUserOwnedPolicyExpenseChatThatCouldShow && !includeMultipleParticipantReports && !reportOption.login) {
+ const shouldShowInvoiceRoom = includeInvoiceRooms && ReportUtils.isInvoiceRoom(reportOption.item) && ReportUtils.isPolicyAdmin(reportOption.policyID ?? '', policies);
+
+ /**
+ Exclude the report option if it doesn't meet any of the following conditions:
+ - It is not an owned policy expense chat that could be shown
+ - Multiple participant reports are not included
+ - It doesn't have a login
+ - It is not an invoice room that should be shown
+ */
+ if (!isCurrentUserOwnedPolicyExpenseChatThatCouldShow && !includeMultipleParticipantReports && !reportOption.login && !shouldShowInvoiceRoom) {
continue;
}
@@ -1954,22 +1982,20 @@ function getOptions(
}
}
- if (includePersonalDetails) {
- const personalDetailsOptionsToExclude = [...optionsToExclude, {login: currentUserLogin}];
- // Next loop over all personal details removing any that are selectedUsers or recentChats
- allPersonalDetailsOptions.forEach((personalDetailOption) => {
- if (personalDetailsOptionsToExclude.some((optionToExclude) => optionToExclude.login === personalDetailOption.login)) {
- return;
- }
- const {searchText, participantsList, isChatRoom} = personalDetailOption;
- const participantNames = getParticipantNames(participantsList);
- if (searchValue && !isSearchStringMatch(searchValue, searchText, participantNames, isChatRoom)) {
- return;
- }
+ const personalDetailsOptionsToExclude = [...optionsToExclude, {login: currentUserLogin}];
+ // Next loop over all personal details removing any that are selectedUsers or recentChats
+ allPersonalDetailsOptions.forEach((personalDetailOption) => {
+ if (personalDetailsOptionsToExclude.some((optionToExclude) => optionToExclude.login === personalDetailOption.login)) {
+ return;
+ }
+ const {searchText, participantsList, isChatRoom} = personalDetailOption;
+ const participantNames = getParticipantNames(participantsList);
+ if (searchValue && !isSearchStringMatch(searchValue, searchText, participantNames, isChatRoom)) {
+ return;
+ }
- personalDetailsOptions.push(personalDetailOption);
- });
- }
+ personalDetailsOptions.push(personalDetailOption);
+ });
let currentUserOption = allPersonalDetailsOptions.find((personalDetailsOption) => personalDetailsOption.login === currentUserLogin);
if (searchValue && currentUserOption && !isSearchStringMatch(searchValue, currentUserOption.searchText)) {
@@ -2027,7 +2053,7 @@ function getSearchOptions(options: OptionList, searchValue = '', betas: Beta[] =
maxRecentReportsToShow: 0, // Unlimited
sortByReportTypeInSearch: true,
showChatPreviewLine: true,
- includePersonalDetails: true,
+ includeP2P: true,
forcePolicyNamePreview: true,
includeOwnedWorkspaceChats: true,
includeThreads: true,
@@ -2048,7 +2074,7 @@ function getShareLogOptions(options: OptionList, searchValue = '', betas: Beta[]
includeRecentReports: true,
includeMultipleParticipantReports: true,
sortByReportTypeInSearch: true,
- includePersonalDetails: true,
+ includeP2P: true,
forcePolicyNamePreview: true,
includeOwnedWorkspaceChats: true,
includeSelfDM: true,
@@ -2105,8 +2131,8 @@ function getFilteredOptions(
includePolicyReportFieldOptions = false,
policyReportFieldOptions: string[] = [],
recentlyUsedPolicyReportFieldOptions: string[] = [],
- includePersonalDetails = true,
maxRecentReportsToShow = 5,
+ includeInvoiceRooms = false,
) {
return getOptions(
{reports, personalDetails},
@@ -2115,7 +2141,6 @@ function getFilteredOptions(
searchInputValue: searchValue.trim(),
selectedOptions,
includeRecentReports: true,
- includePersonalDetails,
maxRecentReportsToShow,
excludeLogins,
includeOwnedWorkspaceChats,
@@ -2134,6 +2159,7 @@ function getFilteredOptions(
includePolicyReportFieldOptions,
policyReportFieldOptions,
recentlyUsedPolicyReportFieldOptions,
+ includeInvoiceRooms,
},
);
}
@@ -2161,7 +2187,6 @@ function getShareDestinationOptions(
maxRecentReportsToShow: 0, // Unlimited
includeRecentReports: true,
includeMultipleParticipantReports: true,
- includePersonalDetails: false,
showChatPreviewLine: true,
forcePolicyNamePreview: true,
includeThreads: true,
@@ -2218,7 +2243,7 @@ function getMemberInviteOptions(
{
betas,
searchInputValue: searchValue.trim(),
- includePersonalDetails: true,
+ includeP2P: true,
excludeLogins,
sortPersonalDetailsByAlphaAsc: true,
includeSelectedOptions,
diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts
index d58ac4d5218c..8cea00a28d94 100644
--- a/src/libs/PersonalDetailsUtils.ts
+++ b/src/libs/PersonalDetailsUtils.ts
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {CurrentUserPersonalDetails} from '@components/withCurrentUserPersonalDetails';
diff --git a/src/libs/PhoneNumber.ts b/src/libs/PhoneNumber.ts
index 787b3634030a..84ef35a18489 100644
--- a/src/libs/PhoneNumber.ts
+++ b/src/libs/PhoneNumber.ts
@@ -1,7 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import {parsePhoneNumber as originalParsePhoneNumber} from 'awesome-phonenumber';
import type {ParsedPhoneNumber, ParsedPhoneNumberInvalid, PhoneNumberParseOptions} from 'awesome-phonenumber';
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import CONST from '@src/CONST';
/**
diff --git a/src/libs/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts
index db739cc3b8c7..084356df7449 100644
--- a/src/libs/PolicyDistanceRatesUtils.ts
+++ b/src/libs/PolicyDistanceRatesUtils.ts
@@ -9,6 +9,8 @@ import * as NumberUtils from './NumberUtils';
type RateValueForm = typeof ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM | typeof ONYXKEYS.FORMS.POLICY_CREATE_DISTANCE_RATE_FORM | typeof ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_EDIT_FORM;
+type TaxReclaimableForm = typeof ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT_FORM;
+
function validateRateValue(values: FormOnyxValues, currency: string, toLocaleDigit: (arg: string) => string): FormInputErrors {
const errors: FormInputErrors = {};
const parsedRate = MoneyRequestUtils.replaceAllDigits(values.rate, toLocaleDigit);
@@ -24,6 +26,15 @@ function validateRateValue(values: FormOnyxValues, currency: stri
return errors;
}
+function validateTaxClaimableValue(values: FormOnyxValues, rate: Rate): FormInputErrors {
+ const errors: FormInputErrors = {};
+
+ if (rate.rate && Number(values.taxClaimableValue) > rate.rate / 100) {
+ errors.taxClaimableValue = 'workspace.taxes.error.updateTaxClaimableFailureMessage';
+ }
+ return errors;
+}
+
/**
* Get the optimistic rate name in a way that matches BE logic
* @param rates
@@ -33,4 +44,4 @@ function getOptimisticRateName(rates: Record): string {
return existingRatesWithSameName.length ? `${CONST.CUSTOM_UNITS.DEFAULT_RATE} ${existingRatesWithSameName.length}` : CONST.CUSTOM_UNITS.DEFAULT_RATE;
}
-export {validateRateValue, getOptimisticRateName};
+export {validateRateValue, getOptimisticRateName, validateTaxClaimableValue};
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index fd128fde19d9..7678de592a6f 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
@@ -90,6 +90,21 @@ function getNumericValue(value: number | string, toLocaleDigit: (arg: string) =>
return numValue.toFixed(CONST.CUSTOM_UNITS.RATE_DECIMALS);
}
+/**
+ * Retrieves the distance custom unit object for the given policy
+ */
+function getCustomUnit(policy: OnyxEntry | EmptyObject) {
+ return Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
+}
+
+/**
+ * Retrieves custom unit rate object from the given customUnitRateID
+ */
+function getCustomUnitRate(policy: OnyxEntry | EmptyObject, customUnitRateID: string): Rate | EmptyObject {
+ const distanceUnit = getCustomUnit(policy);
+ return distanceUnit?.rates[customUnitRateID] ?? {};
+}
+
function getRateDisplayValue(value: number, toLocaleDigit: (arg: string) => string): string {
const numValue = getNumericValue(value, toLocaleDigit);
if (Number.isNaN(numValue)) {
@@ -122,7 +137,7 @@ function getPolicyBrickRoadIndicatorStatus(policy: OnyxEntry): ValueOf, isOffline: boolean): boolean {
return (
!!policy &&
- (policy?.isPolicyExpenseChatEnabled || Boolean(policy?.isJoinRequestPending)) &&
+ (policy?.isPolicyExpenseChatEnabled || !!policy?.isJoinRequestPending) &&
(isOffline || policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policy.errors ?? {}).length > 0)
);
}
@@ -278,7 +293,7 @@ function isPaidGroupPolicy(policy: OnyxEntry | EmptyObject): boolean {
}
function isTaxTrackingEnabled(isPolicyExpenseChat: boolean, policy: OnyxEntry, isDistanceRequest: boolean): boolean {
- const distanceUnit = Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
+ const distanceUnit = getCustomUnit(policy);
const customUnitID = distanceUnit?.customUnitID ?? 0;
const isPolicyTaxTrackingEnabled = isPolicyExpenseChat && policy?.tax?.trackingEnabled;
const isTaxEnabledForDistance = isPolicyTaxTrackingEnabled && policy?.customUnits?.[customUnitID]?.attributes?.taxEnabled;
@@ -342,10 +357,10 @@ function canEditTaxRate(policy: Policy, taxID: string): boolean {
function isPolicyFeatureEnabled(policy: OnyxEntry | EmptyObject, featureName: PolicyFeatureName): boolean {
if (featureName === CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED) {
- return Boolean(policy?.tax?.trackingEnabled);
+ return !!policy?.tax?.trackingEnabled;
}
- return Boolean(policy?.[featureName]);
+ return !!policy?.[featureName];
}
function getApprovalWorkflow(policy: OnyxEntry | EmptyObject): ValueOf {
@@ -505,6 +520,8 @@ export {
findCurrentXeroOrganization,
getCurrentXeroOrganizationName,
getXeroBankAccountsWithDefaultSelect,
+ getCustomUnit,
+ getCustomUnitRate,
sortWorkspacesBySelected,
};
diff --git a/src/libs/Pusher/pusher.ts b/src/libs/Pusher/pusher.ts
index d35d6122aff7..6fe3eddb85d9 100644
--- a/src/libs/Pusher/pusher.ts
+++ b/src/libs/Pusher/pusher.ts
@@ -65,7 +65,7 @@ Onyx.connect({
if (!network) {
return;
}
- shouldForceOffline = Boolean(network.shouldForceOffline);
+ shouldForceOffline = !!network.shouldForceOffline;
},
});
diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts
index 849bc50e77b0..b3892d20162f 100644
--- a/src/libs/ReceiptUtils.ts
+++ b/src/libs/ReceiptUtils.ts
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import _ from 'lodash';
import type {OnyxEntry} from 'react-native-onyx';
import CONST from '@src/CONST';
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index 75d8e22ac975..ac0ee9e9025e 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -1,4 +1,4 @@
-import fastMerge from 'expensify-common/lib/fastMerge';
+import {fastMerge} from 'expensify-common';
import _ from 'lodash';
import lodashFindLast from 'lodash/findLast';
import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
@@ -11,6 +11,7 @@ import type {
ActionName,
ChangeLog,
IOUMessage,
+ JoinWorkspaceResolution,
OriginalMessageActionableMentionWhisper,
OriginalMessageActionableReportMentionWhisper,
OriginalMessageActionableTrackedExpenseWhisper,
@@ -908,7 +909,7 @@ function getOneTransactionThreadReportID(
// - they have an assocaited IOU transaction ID or
// - they have visibile childActions (like comments) that we'd want to display
// - the action is pending deletion and the user is offline
- (Boolean(action.originalMessage.IOUTransactionID) ||
+ (!!action.originalMessage.IOUTransactionID ||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
(isMessageDeleted(action) && action.childVisibleActionCount) ||
(action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && (isOffline ?? isNetworkOffline))),
@@ -1173,7 +1174,7 @@ function isReportActionUnread(reportAction: OnyxEntry, lastReadTim
return !isCreatedAction(reportAction);
}
- return Boolean(reportAction && lastReadTime && reportAction.created && lastReadTime < reportAction.created);
+ return !!(reportAction && lastReadTime && reportAction.created && lastReadTime < reportAction.created);
}
/**
@@ -1205,7 +1206,9 @@ function isActionableJoinRequest(reportAction: OnyxEntry): reportA
*/
function isActionableJoinRequestPending(reportID: string): boolean {
const sortedReportActions = getSortedReportActions(Object.values(getAllReportActions(reportID)));
- const findPendingRequest = sortedReportActions.find((reportActionItem) => isActionableJoinRequest(reportActionItem) && reportActionItem.originalMessage.choice === '');
+ const findPendingRequest = sortedReportActions.find(
+ (reportActionItem) => isActionableJoinRequest(reportActionItem) && reportActionItem.originalMessage.choice === ('' as JoinWorkspaceResolution),
+ );
return !!findPendingRequest;
}
@@ -1233,6 +1236,13 @@ function isLinkedTransactionHeld(reportActionID: string, reportID: string): bool
return TransactionUtils.isOnHoldByTransactionID(getLinkedTransactionID(reportActionID, reportID) ?? '');
}
+/**
+ * Check if the current user is the requestor of the action
+ */
+function wasActionTakenByCurrentUser(reportAction: OnyxEntry): boolean {
+ return currentUserAccountID === reportAction?.actorAccountID;
+}
+
export {
extractLinksFromMessageHtml,
getDismissedViolationMessageText,
@@ -1299,7 +1309,9 @@ export {
isActionableJoinRequest,
isActionableJoinRequestPending,
isActionableTrackExpense,
+ getAllReportActions,
isLinkedTransactionHeld,
+ wasActionTakenByCurrentUser,
isResolvedActionTrackExpense,
};
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index db1e9e19858c..d68d308feedd 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -1,6 +1,5 @@
import {format} from 'date-fns';
-import ExpensiMark from 'expensify-common/lib/ExpensiMark';
-import Str from 'expensify-common/lib/str';
+import {ExpensiMark, Str} from 'expensify-common';
import {isEmpty} from 'lodash';
import lodashEscape from 'lodash/escape';
import lodashFindLastIndex from 'lodash/findLastIndex';
@@ -41,13 +40,15 @@ import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon';
import type {
ChangeLog,
IOUMessage,
+ ModifiedExpense,
OriginalMessageActionName,
+ OriginalMessageApproved,
OriginalMessageCreated,
OriginalMessageDismissedViolation,
OriginalMessageReimbursementDequeued,
OriginalMessageRenamed,
+ OriginalMessageSubmitted,
PaymentMethodType,
- ReimbursementDeQueuedMessage,
} from '@src/types/onyx/OriginalMessage';
import type {Status} from '@src/types/onyx/PersonalDetails';
import type {NotificationPreference, Participants, PendingChatMember, Participant as ReportParticipant} from '@src/types/onyx/Report';
@@ -88,30 +89,6 @@ type AvatarRange = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
type WelcomeMessage = {showReportName: boolean; phrase1?: string; phrase2?: string};
-type ExpenseOriginalMessage = {
- oldComment?: string;
- newComment?: string;
- comment?: string;
- merchant?: string;
- oldCreated?: string;
- created?: string;
- oldMerchant?: string;
- oldAmount?: number;
- amount?: number;
- oldCurrency?: string;
- currency?: string;
- category?: string;
- oldCategory?: string;
- tag?: string;
- oldTag?: string;
- billable?: string;
- oldBillable?: string;
- oldTaxAmount?: number;
- taxAmount?: number;
- taxRate?: string;
- oldTaxRate?: string;
-};
-
type SpendBreakdown = {
nonReimbursableSpend: number;
reimbursableSpend: number;
@@ -212,12 +189,12 @@ type ReportOfflinePendingActionAndErrors = {
};
type OptimisticApprovedReportAction = Pick<
- ReportAction,
+ ReportAction & OriginalMessageApproved,
'actionName' | 'actorAccountID' | 'automatic' | 'avatar' | 'isAttachment' | 'originalMessage' | 'message' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction'
>;
type OptimisticSubmittedReportAction = Pick<
- ReportAction,
+ ReportAction & OriginalMessageSubmitted,
| 'actionName'
| 'actorAccountID'
| 'adminAccountID'
@@ -491,6 +468,11 @@ let currentUserPrivateDomain: string | undefined;
let currentUserAccountID: number | undefined;
let isAnonymousUser = false;
+// This cache is used to save parse result of report action html message into text
+// to prevent unnecessary parsing when the report action is not changed/modified.
+// Example case: when we need to get a report name of a thread which is dependent on a report action message.
+const parsedReportActionMessageCache: Record = {};
+
const defaultAvatarBuildingIconTestID = 'SvgDefaultAvatarBuilding Icon';
Onyx.connect({
@@ -783,7 +765,7 @@ function isCompletedTaskReport(report: OnyxEntry): boolean {
* Checks if the current user is the manager of the supplied report
*/
function isReportManager(report: OnyxEntry): boolean {
- return Boolean(report && report.managerID === currentUserAccountID);
+ return !!(report && report.managerID === currentUserAccountID);
}
/**
@@ -849,7 +831,7 @@ function isCurrentUserSubmitter(reportID: string): boolean {
return false;
}
const report = allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
- return Boolean(report && report.ownerAccountID === currentUserAccountID);
+ return !!(report && report.ownerAccountID === currentUserAccountID);
}
/**
@@ -901,10 +883,17 @@ function isPolicyExpenseChat(report: OnyxEntry | Participant | EmptyObje
return getChatType(report) === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT || (report?.isPolicyExpenseChat ?? false);
}
-function isInvoiceRoom(report: OnyxEntry): boolean {
+function isInvoiceRoom(report: OnyxEntry | EmptyObject): boolean {
return getChatType(report) === CONST.REPORT.CHAT_TYPE.INVOICE;
}
+/**
+ * Checks if a report is a completed task report.
+ */
+function isTripRoom(report: OnyxEntry): boolean {
+ return isChatReport(report) && getChatType(report) === CONST.REPORT.CHAT_TYPE.TRIP_ROOM;
+}
+
function isCurrentUserInvoiceReceiver(report: OnyxEntry): boolean {
if (report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) {
return currentUserAccountID === report.invoiceReceiver.accountID;
@@ -1022,7 +1011,7 @@ function isWorkspaceTaskReport(report: OnyxEntry): boolean {
* Returns true if report has a parent
*/
function isThread(report: OnyxEntry): boolean {
- return Boolean(report?.parentReportID && report?.parentReportActionID);
+ return !!(report?.parentReportID && report?.parentReportActionID);
}
/**
@@ -1573,7 +1562,7 @@ function canDeleteReportAction(reportAction: OnyxEntry, reportID:
/**
* Get welcome message based on room type
*/
-function getRoomWelcomeMessage(report: OnyxEntry, isUserPolicyAdmin: boolean): WelcomeMessage {
+function getRoomWelcomeMessage(report: OnyxEntry): WelcomeMessage {
const welcomeMessage: WelcomeMessage = {showReportName: true};
const workspaceName = getPolicyName(report);
@@ -1586,9 +1575,6 @@ function getRoomWelcomeMessage(report: OnyxEntry, isUserPolicyAdmin: boo
} else if (isAdminRoom(report)) {
welcomeMessage.phrase1 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAdminRoomPartOne', {workspaceName});
welcomeMessage.phrase2 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAdminRoomPartTwo');
- } else if (isAdminsOnlyPostingRoom(report) && !isUserPolicyAdmin) {
- welcomeMessage.phrase1 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAdminOnlyPostingRoom');
- welcomeMessage.showReportName = false;
} else if (isAnnounceRoom(report)) {
welcomeMessage.phrase1 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAnnounceRoomPartOne', {workspaceName});
welcomeMessage.phrase2 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAnnounceRoomPartTwo', {workspaceName});
@@ -1667,13 +1653,13 @@ function canShowReportRecipientLocalTime(personalDetails: OnyxCollection {
+ if (!includeOnlyActiveMembers) {
+ return true;
+ }
+ const pendingMember = report?.pendingChatMembers?.findLast((member) => member.accountID === accountID.toString());
+ return !pendingMember || pendingMember.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
+ });
return accountIDStrings.map((accountID) => Number(accountID));
}
@@ -2195,7 +2186,7 @@ function getReimbursementDeQueuedActionMessage(
report: OnyxEntry | EmptyObject,
isLHNPreview = false,
): string {
- const originalMessage = reportAction?.originalMessage as ReimbursementDeQueuedMessage | undefined;
+ const originalMessage = reportAction?.originalMessage;
const amount = originalMessage?.amount;
const currency = originalMessage?.currency;
const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency);
@@ -2284,7 +2275,7 @@ function isUnreadWithMention(reportOrOption: OnyxEntry | OptionData): bo
// lastMentionedTime and lastReadTime are both datetime strings and can be compared directly
const lastMentionedTime = reportOrOption.lastMentionedTime ?? '';
const lastReadTime = reportOrOption.lastReadTime ?? '';
- return Boolean('isUnreadWithMention' in reportOrOption && reportOrOption.isUnreadWithMention) || lastReadTime < lastMentionedTime;
+ return !!('isUnreadWithMention' in reportOrOption && reportOrOption.isUnreadWithMention) || lastReadTime < lastMentionedTime;
}
/**
@@ -2718,15 +2709,15 @@ function canEditFieldOfMoneyRequest(reportAction: OnyxEntry, field
function canEditReportAction(reportAction: OnyxEntry): boolean {
const isCommentOrIOU = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT || reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU;
- return Boolean(
+ return !!(
reportAction?.actorAccountID === currentUserAccountID &&
- isCommentOrIOU &&
- canEditMoneyRequest(reportAction) && // Returns true for non-IOU actions
- !isReportMessageAttachment(reportAction?.message?.[0]) &&
- (isEmptyObject(reportAction.attachmentInfo) || !reportAction.isOptimisticAction) &&
- !ReportActionsUtils.isDeletedAction(reportAction) &&
- !ReportActionsUtils.isCreatedTaskReportAction(reportAction) &&
- reportAction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
+ isCommentOrIOU &&
+ canEditMoneyRequest(reportAction) && // Returns true for non-IOU actions
+ !isReportMessageAttachment(reportAction?.message?.[0]) &&
+ (isEmptyObject(reportAction.attachmentInfo) || !reportAction.isOptimisticAction) &&
+ !ReportActionsUtils.isDeletedAction(reportAction) &&
+ !ReportActionsUtils.isCreatedTaskReportAction(reportAction) &&
+ reportAction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE
);
}
@@ -2754,14 +2745,6 @@ function areAllRequestsBeingSmartScanned(iouReportID: string, reportPreviewActio
return transactionsWithReceipts.every((transaction) => TransactionUtils.isReceiptBeingScanned(transaction));
}
-/**
- * Check if any of the transactions in the report has required missing fields
- *
- */
-function hasMissingSmartscanFields(iouReportID: string): boolean {
- return TransactionUtils.getAllReportTransactions(iouReportID).some((transaction) => TransactionUtils.hasMissingSmartscanFields(transaction));
-}
-
/**
* Get the transactions related to a report preview with receipts
* Get the details linked to the IOU reportAction
@@ -2778,6 +2761,33 @@ function getLinkedTransaction(reportAction: OnyxEntry {
+ if (!ReportActionsUtils.isMoneyRequestAction(action)) {
+ return false;
+ }
+ const transaction = getLinkedTransaction(action);
+ if (isEmptyObject(transaction)) {
+ return false;
+ }
+ if (!ReportActionsUtils.wasActionTakenByCurrentUser(action)) {
+ return false;
+ }
+ return TransactionUtils.hasMissingSmartscanFields(transaction);
+ });
+}
+
/**
* Given a parent IOU report action get report name for the LHN.
*/
@@ -2864,7 +2874,7 @@ function getReportPreviewMessage(
}
const transactionDetails = getTransactionDetails(linkedTransaction);
- const formattedAmount = CurrencyUtils.convertToDisplayString(transactionDetails?.amount ?? 0, transactionDetails?.currency ?? '');
+ const formattedAmount = CurrencyUtils.convertToDisplayString(transactionDetails?.amount ?? 0, transactionDetails?.currency);
return Localize.translateLocal('iou.didSplitAmount', {formattedAmount, comment: transactionDetails?.comment ?? ''});
}
}
@@ -2886,7 +2896,7 @@ function getReportPreviewMessage(
}
const transactionDetails = getTransactionDetails(linkedTransaction);
- const formattedAmount = CurrencyUtils.convertToDisplayString(transactionDetails?.amount ?? 0, transactionDetails?.currency ?? '');
+ const formattedAmount = CurrencyUtils.convertToDisplayString(transactionDetails?.amount ?? 0, transactionDetails?.currency);
return Localize.translateLocal('iou.trackedAmount', {formattedAmount, comment: transactionDetails?.comment ?? ''});
}
}
@@ -2992,8 +3002,8 @@ function getModifiedExpenseOriginalMessage(
transactionChanges: TransactionChanges,
isFromExpenseReport: boolean,
policy: OnyxEntry,
-): ExpenseOriginalMessage {
- const originalMessage: ExpenseOriginalMessage = {};
+): ModifiedExpense {
+ const originalMessage: ModifiedExpense = {};
// Remark: Comment field is the only one which has new/old prefixes for the keys (newComment/ oldComment),
// all others have old/- pattern such as oldCreated/created
if ('comment' in transactionChanges) {
@@ -3118,10 +3128,53 @@ function getInvoicePayerName(report: OnyxEntry): string {
return getPolicyName(report, false, allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiver?.policyID}`]);
}
+/**
+ * Parse html of reportAction into text
+ */
+function parseReportActionHtmlToText(reportAction: OnyxEntry, reportID: string, childReportID?: string): string {
+ if (!reportAction) {
+ return '';
+ }
+ const key = `${reportID}_${reportAction.reportActionID}_${reportAction.lastModified}`;
+ const cachedText = parsedReportActionMessageCache[key];
+ if (cachedText !== undefined) {
+ return cachedText;
+ }
+
+ const {html, text} = reportAction?.message?.[0] ?? {};
+
+ if (!html) {
+ return text ?? '';
+ }
+
+ const mentionReportRegex = //gi;
+ const matches = html.matchAll(mentionReportRegex);
+
+ const reportIDToName: Record = {};
+ for (const match of matches) {
+ if (match[1] !== childReportID) {
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ reportIDToName[match[1]] = getReportName(getReport(match[1])) ?? '';
+ }
+ }
+
+ const mentionUserRegex = //gi;
+ const accountIDToName: Record = {};
+ const accountIDs = Array.from(html.matchAll(mentionUserRegex), (mention) => Number(mention[1]));
+ const logins = PersonalDetailsUtils.getLoginsByAccountIDs(accountIDs);
+ accountIDs.forEach((id, index) => (accountIDToName[id] = logins[index]));
+
+ const parser = new ExpensiMark();
+ const textMessage = Str.removeSMSDomain(parser.htmlToText(html, {reportIDToName, accountIDToName}));
+ parsedReportActionMessageCache[key] = textMessage;
+
+ return textMessage;
+}
+
/**
* Get the report action message for a report action.
*/
-function getReportActionMessage(reportAction: ReportAction | EmptyObject, parentReportID?: string) {
+function getReportActionMessage(reportAction: ReportAction | EmptyObject, reportID?: string, childReportID?: string) {
if (isEmptyObject(reportAction)) {
return '';
}
@@ -3135,9 +3188,10 @@ function getReportActionMessage(reportAction: ReportAction | EmptyObject, parent
return ReportActionsUtils.getReportActionMessageText(reportAction);
}
if (ReportActionsUtils.isReimbursementQueuedAction(reportAction)) {
- return getReimbursementQueuedActionMessage(reportAction, getReport(parentReportID), false);
+ return getReimbursementQueuedActionMessage(reportAction, getReport(reportID), false);
}
- return Str.removeSMSDomain(reportAction?.message?.[0]?.text ?? '');
+
+ return parseReportActionHtmlToText(reportAction, reportID ?? '', childReportID);
}
/**
@@ -3175,7 +3229,7 @@ function getReportName(report: OnyxEntry, policy: OnyxEntry = nu
if (isArchivedRoom(report)) {
formattedName += ` (${Localize.translateLocal('common.archived')})`;
}
- return formattedName;
+ return formatReportLastMessageText(formattedName);
}
if (parentReportAction?.message?.[0]?.isDeletedParentAction) {
@@ -3183,7 +3237,7 @@ function getReportName(report: OnyxEntry, policy: OnyxEntry = nu
}
const isAttachment = ReportActionsUtils.isReportActionAttachment(!isEmptyObject(parentReportAction) ? parentReportAction : null);
- const parentReportActionMessage = getReportActionMessage(parentReportAction, report?.parentReportID).replace(/(\r\n|\n|\r)/gm, ' ');
+ const parentReportActionMessage = getReportActionMessage(parentReportAction, report?.parentReportID, report?.reportID ?? '').replace(/(\r\n|\n|\r)/gm, ' ');
if (isAttachment && parentReportActionMessage) {
return `[${Localize.translateLocal('common.attachment')}]`;
}
@@ -3203,6 +3257,11 @@ function getReportName(report: OnyxEntry, policy: OnyxEntry = nu
if (ReportActionsUtils.isModifiedExpenseAction(parentReportAction)) {
return ModifiedExpenseMessage.getForReportAction(report?.reportID, parentReportAction);
}
+
+ if (isTripRoom(report)) {
+ return report?.reportName ?? '';
+ }
+
return parentReportActionMessage;
}
@@ -3247,7 +3306,7 @@ function getReportName(report: OnyxEntry, policy: OnyxEntry = nu
}
if (formattedName) {
- return formattedName;
+ return formatReportLastMessageText(formattedName);
}
// Not a room or PolicyExpenseChat, generate title from first 5 other participants
@@ -3831,7 +3890,7 @@ function getIOUSubmittedMessage(report: OnyxEntry) {
];
}
- const submittedToPersonalDetail = getPersonalDetailsForAccountID(policy?.submitsTo ?? 0);
+ const submittedToPersonalDetail = getPersonalDetailsForAccountID(PolicyUtils.getSubmitToAccountID(policy, report?.ownerAccountID ?? 0));
let submittedToDisplayName = `${submittedToPersonalDetail.displayName ?? ''}${
submittedToPersonalDetail.displayName !== submittedToPersonalDetail.login ? ` (${submittedToPersonalDetail.login})` : ''
}`;
@@ -5125,7 +5184,7 @@ function shouldHideReport(report: OnyxEntry, currentReportId: string): b
}
/**
- * Checks to see if a report's parentAction is an expense that contains a violation
+ * Checks to see if a report's parentAction is an expense that contains a violation type of either violation or warning
*/
function doesTransactionThreadHaveViolations(report: OnyxEntry, transactionViolations: OnyxCollection, parentReportAction: OnyxEntry): boolean {
if (parentReportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) {
@@ -5141,7 +5200,7 @@ function doesTransactionThreadHaveViolations(report: OnyxEntry, transact
if (report?.stateNum !== CONST.REPORT.STATE_NUM.OPEN && report?.stateNum !== CONST.REPORT.STATE_NUM.SUBMITTED) {
return false;
}
- return TransactionUtils.hasViolation(IOUTransactionID, transactionViolations);
+ return TransactionUtils.hasViolation(IOUTransactionID, transactionViolations) || TransactionUtils.hasWarningTypeViolation(IOUTransactionID, transactionViolations);
}
/**
@@ -5167,6 +5226,14 @@ function hasViolations(reportID: string, transactionViolations: OnyxCollection TransactionUtils.hasViolation(transaction.transactionID, transactionViolations));
}
+/**
+ * Checks to see if a report contains a violation of type `warning`
+ */
+function hasWarningTypeViolations(reportID: string, transactionViolations: OnyxCollection): boolean {
+ const transactions = TransactionUtils.getAllReportTransactions(reportID);
+ return transactions.some((transaction) => TransactionUtils.hasWarningTypeViolation(transaction.transactionID, transactionViolations));
+}
+
/**
* Takes several pieces of data from Onyx and evaluates if a report should be shown in the option list (either when searching
* for reports or the reports shown in the LHN).
@@ -5401,14 +5468,14 @@ function canFlagReportAction(reportAction: OnyxEntry, reportID: st
return false;
}
- return Boolean(
+ return !!(
!isCurrentUserAction &&
- reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT &&
- !ReportActionsUtils.isDeletedAction(reportAction) &&
- !ReportActionsUtils.isCreatedTaskReportAction(reportAction) &&
- !isEmptyObject(report) &&
- report &&
- isAllowedToComment(report),
+ reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT &&
+ !ReportActionsUtils.isDeletedAction(reportAction) &&
+ !ReportActionsUtils.isCreatedTaskReportAction(reportAction) &&
+ !isEmptyObject(report) &&
+ report &&
+ isAllowedToComment(report)
);
}
@@ -5563,7 +5630,7 @@ function canRequestMoney(report: OnyxEntry, policy: OnyxEntry, o
let isOwnPolicyExpenseChat = report?.isOwnPolicyExpenseChat ?? false;
if (isExpenseReport(report) && getParentReport(report)) {
- isOwnPolicyExpenseChat = Boolean(getParentReport(report)?.isOwnPolicyExpenseChat);
+ isOwnPolicyExpenseChat = !!getParentReport(report)?.isOwnPolicyExpenseChat;
}
// In case there are no other participants than the current user and it's not user's own policy expense chat, they can't submit expenses from such report
@@ -5788,7 +5855,7 @@ function canLeaveRoom(report: OnyxEntry, isPolicyEmployee: boolean): boo
}
function isCurrentUserTheOnlyParticipant(participantAccountIDs?: number[]): boolean {
- return Boolean(participantAccountIDs?.length === 1 && participantAccountIDs?.[0] === currentUserAccountID);
+ return !!(participantAccountIDs?.length === 1 && participantAccountIDs?.[0] === currentUserAccountID);
}
/**
@@ -6178,17 +6245,17 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry,
*
*/
function isDeprecatedGroupDM(report: OnyxEntry): boolean {
- return Boolean(
+ return !!(
report &&
- !isChatThread(report) &&
- !isTaskReport(report) &&
- !isInvoiceReport(report) &&
- !isMoneyRequestReport(report) &&
- !isArchivedRoom(report) &&
- !Object.values(CONST.REPORT.CHAT_TYPE).some((chatType) => chatType === getChatType(report)) &&
- Object.keys(report.participants ?? {})
- .map(Number)
- .filter((accountID) => accountID !== currentUserAccountID).length > 1,
+ !isChatThread(report) &&
+ !isTaskReport(report) &&
+ !isInvoiceReport(report) &&
+ !isMoneyRequestReport(report) &&
+ !isArchivedRoom(report) &&
+ !Object.values(CONST.REPORT.CHAT_TYPE).some((chatType) => chatType === getChatType(report)) &&
+ Object.keys(report.participants ?? {})
+ .map(Number)
+ .filter((accountID) => accountID !== currentUserAccountID).length > 1
);
}
@@ -6203,7 +6270,7 @@ function isRootGroupChat(report: OnyxEntry): boolean {
* Assume any report without a reportID is unusable.
*/
function isValidReport(report?: OnyxEntry): boolean {
- return Boolean(report?.reportID);
+ return !!report?.reportID;
}
/**
@@ -6269,7 +6336,7 @@ function hasSmartscanError(reportActions: ReportAction[]) {
return false;
}
const IOUReportID = ReportActionsUtils.getIOUReportIDFromReportActionPreview(action);
- const isReportPreviewError = ReportActionsUtils.isReportPreviewAction(action) && hasMissingSmartscanFields(IOUReportID) && !isSettled(IOUReportID);
+ const isReportPreviewError = ReportActionsUtils.isReportPreviewAction(action) && shouldShowRBRForMissingSmartscanFields(IOUReportID) && !isSettled(IOUReportID);
const transactionID = (action.originalMessage as IOUMessage).IOUTransactionID ?? '0';
const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? {};
const isSplitBillError = ReportActionsUtils.isSplitBillAction(action) && TransactionUtils.hasMissingSmartscanFields(transaction as Transaction);
@@ -6376,12 +6443,12 @@ function getNonHeldAndFullAmount(iouReport: OnyxEntry, policy: OnyxEntry
if (hasUpdatedTotal(iouReport, policy) && hasPendingTransaction) {
const unheldTotal = transactions.reduce((currentVal, transaction) => currentVal - (!TransactionUtils.isOnHold(transaction) ? transaction.amount : 0), 0);
- return [CurrencyUtils.convertToDisplayString(unheldTotal, iouReport?.currency ?? ''), CurrencyUtils.convertToDisplayString((iouReport?.total ?? 0) * -1, iouReport?.currency ?? '')];
+ return [CurrencyUtils.convertToDisplayString(unheldTotal, iouReport?.currency), CurrencyUtils.convertToDisplayString((iouReport?.total ?? 0) * -1, iouReport?.currency)];
}
return [
- CurrencyUtils.convertToDisplayString((iouReport?.unheldTotal ?? 0) * -1, iouReport?.currency ?? ''),
- CurrencyUtils.convertToDisplayString((iouReport?.total ?? 0) * -1, iouReport?.currency ?? ''),
+ CurrencyUtils.convertToDisplayString((iouReport?.unheldTotal ?? 0) * -1, iouReport?.currency),
+ CurrencyUtils.convertToDisplayString((iouReport?.total ?? 0) * -1, iouReport?.currency),
];
}
@@ -6605,6 +6672,13 @@ function shouldCreateNewMoneyRequestReport(existingIOUReport: OnyxEntry
return !existingIOUReport || hasIOUWaitingOnCurrentUserBankAccount(chatReport) || !canAddOrDeleteTransactions(existingIOUReport);
}
+function getTripTransactions(tripRoomReportID: string | undefined): Transaction[] {
+ const tripTransactionReportIDs = Object.values(allReports ?? {})
+ .filter((report) => report && report?.parentReportID === tripRoomReportID)
+ .map((report) => report?.reportID);
+ return tripTransactionReportIDs.flatMap((reportID) => TransactionUtils.getAllReportTransactions(reportID));
+}
+
/**
* Checks if report contains actions with errors
*/
@@ -6938,6 +7012,7 @@ export {
hasSmartscanError,
hasUpdatedTotal,
hasViolations,
+ hasWarningTypeViolations,
isActionCreator,
isAdminRoom,
isAdminsOnlyPostingRoom,
@@ -6950,6 +7025,7 @@ export {
isCanceledTaskReport,
isChatReport,
isChatRoom,
+ isTripRoom,
isChatThread,
isChildReport,
isClosedExpenseReportWithNoExpenses,
@@ -7022,6 +7098,7 @@ export {
navigateToDetailsPage,
navigateToPrivateNotes,
parseReportRouteParams,
+ parseReportActionHtmlToText,
reportFieldsEnabled,
requiresAttentionFromCurrentUser,
shouldAutoFocusOnKeyPress,
@@ -7034,11 +7111,13 @@ export {
shouldReportBeInOptionList,
shouldReportShowSubscript,
shouldShowFlagComment,
+ shouldShowRBRForMissingSmartscanFields,
shouldUseFullTitleToDisplay,
sortReportsByLastRead,
updateOptimisticParentReportAction,
updateReportPreview,
temporary_getMoneyRequestOptions,
+ getTripTransactions,
buildOptimisticInvoiceReport,
getInvoiceChatByParticipants,
shouldShowMerchantColumn,
@@ -7050,7 +7129,6 @@ export {
export type {
Ancestor,
DisplayNameWithTooltips,
- ExpenseOriginalMessage,
OptimisticAddCommentReportAction,
OptimisticChatReport,
OptimisticClosedReportAction,
diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts
index c1f4de2e5e35..a5d2ac1d9df2 100644
--- a/src/libs/SearchUtils.ts
+++ b/src/libs/SearchUtils.ts
@@ -5,10 +5,10 @@ import type {ReportListItemType, TransactionListItemType} from '@components/Sele
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type * as OnyxTypes from '@src/types/onyx';
-import type {SearchAccountDetails, SearchDataTypes, SearchTypeToItemMap, SectionsType} from '@src/types/onyx/SearchResults';
+import type {SearchAccountDetails, SearchDataTypes, SearchPersonalDetails, SearchTransaction, SearchTypeToItemMap, SectionsType} from '@src/types/onyx/SearchResults';
import getTopmostCentralPaneRoute from './Navigation/getTopmostCentralPaneRoute';
import navigationRef from './Navigation/navigationRef';
-import type {RootStackParamList, State} from './Navigation/types';
+import type {CentralPaneNavigatorParamList, RootStackParamList, State} from './Navigation/types';
import * as TransactionUtils from './TransactionUtils';
import * as UserUtils from './UserUtils';
@@ -21,7 +21,7 @@ const columnNamesToSortingProperty = {
[CONST.SEARCH_TABLE_COLUMNS.DATE]: 'date' as const,
[CONST.SEARCH_TABLE_COLUMNS.TAG]: 'tag' as const,
[CONST.SEARCH_TABLE_COLUMNS.MERCHANT]: 'formattedMerchant' as const,
- [CONST.SEARCH_TABLE_COLUMNS.TOTAL]: 'formattedTotal' as const,
+ [CONST.SEARCH_TABLE_COLUMNS.TOTAL_AMOUNT]: 'formattedTotal' as const,
[CONST.SEARCH_TABLE_COLUMNS.CATEGORY]: 'category' as const,
[CONST.SEARCH_TABLE_COLUMNS.TYPE]: 'type' as const,
[CONST.SEARCH_TABLE_COLUMNS.ACTION]: 'action' as const,
@@ -30,6 +30,32 @@ const columnNamesToSortingProperty = {
[CONST.SEARCH_TABLE_COLUMNS.RECEIPT]: null,
};
+/**
+ * @private
+ */
+function getTransactionItemCommonFormattedProperties(
+ transactionItem: SearchTransaction,
+ from: SearchPersonalDetails,
+ to: SearchAccountDetails,
+): Pick {
+ const isExpenseReport = transactionItem.reportType === CONST.REPORT.TYPE.EXPENSE;
+
+ const formattedFrom = from?.displayName ?? from?.login ?? '';
+ const formattedTo = to?.name ?? to?.displayName ?? to?.login ?? '';
+ const formattedTotal = TransactionUtils.getAmount(transactionItem, isExpenseReport);
+ const date = transactionItem?.modifiedCreated ? transactionItem.modifiedCreated : transactionItem?.created;
+ const merchant = TransactionUtils.getMerchant(transactionItem);
+ const formattedMerchant = merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || merchant === CONST.TRANSACTION.DEFAULT_MERCHANT ? '' : merchant;
+
+ return {
+ formattedFrom,
+ formattedTo,
+ date,
+ formattedTotal,
+ formattedMerchant,
+ };
+}
+
function isSearchDataType(type: string): type is SearchDataTypes {
const searchDataTypes: string[] = Object.values(CONST.SEARCH_DATA_TYPES);
return searchDataTypes.includes(type);
@@ -50,15 +76,8 @@ function getShouldShowMerchant(data: OnyxTypes.SearchResults['data']): boolean {
});
}
-function getShouldShowColumn(data: OnyxTypes.SearchResults['data'], columnName: ValueOf) {
- return Object.values(data).some((item) => !!item[columnName]);
-}
-
function getTransactionsSections(data: OnyxTypes.SearchResults['data']): TransactionListItemType[] {
const shouldShowMerchant = getShouldShowMerchant(data);
- const shouldShowCategory = getShouldShowColumn(data, CONST.SEARCH_TABLE_COLUMNS.CATEGORY);
- const shouldShowTag = getShouldShowColumn(data, CONST.SEARCH_TABLE_COLUMNS.TAG);
- const shouldShowTax = getShouldShowColumn(data, CONST.SEARCH_TABLE_COLUMNS.TAX_AMOUNT);
return Object.entries(data)
.filter(([key]) => key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION))
@@ -69,12 +88,7 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data']): Transac
? (data[`${ONYXKEYS.COLLECTION.POLICY}${transactionItem.policyID}`] as SearchAccountDetails)
: (data.personalDetailsList?.[transactionItem.managerID] as SearchAccountDetails);
- const formattedFrom = from.displayName ?? from.login ?? '';
- const formattedTo = to?.name ?? to?.displayName ?? to?.login ?? '';
- const formattedTotal = TransactionUtils.getAmount(transactionItem, isExpenseReport);
- const date = transactionItem?.modifiedCreated ? transactionItem.modifiedCreated : transactionItem?.created;
- const merchant = TransactionUtils.getMerchant(transactionItem);
- const formattedMerchant = merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || merchant === CONST.TRANSACTION.DEFAULT_MERCHANT ? '' : merchant;
+ const {formattedFrom, formattedTo, formattedTotal, formattedMerchant, date} = getTransactionItemCommonFormattedProperties(transactionItem, from, to);
return {
...transactionItem,
@@ -82,13 +96,13 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data']): Transac
to,
formattedFrom,
formattedTo,
- date,
formattedTotal,
formattedMerchant,
+ date,
shouldShowMerchant,
- shouldShowCategory,
- shouldShowTag,
- shouldShowTax,
+ shouldShowCategory: true,
+ shouldShowTag: true,
+ shouldShowTax: true,
keyForList: transactionItem.transactionID,
};
});
@@ -96,9 +110,6 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data']): Transac
function getReportSections(data: OnyxTypes.SearchResults['data']): ReportListItemType[] {
const shouldShowMerchant = getShouldShowMerchant(data);
- const shouldShowCategory = getShouldShowColumn(data, CONST.SEARCH_TABLE_COLUMNS.CATEGORY);
- const shouldShowTag = getShouldShowColumn(data, CONST.SEARCH_TABLE_COLUMNS.TAG);
- const shouldShowTax = getShouldShowColumn(data, CONST.SEARCH_TABLE_COLUMNS.TAX_AMOUNT);
const reportIDToTransactions: Record = {};
for (const key in data) {
@@ -119,12 +130,7 @@ function getReportSections(data: OnyxTypes.SearchResults['data']): ReportListIte
? (data[`${ONYXKEYS.COLLECTION.POLICY}${transactionItem.policyID}`] as SearchAccountDetails)
: (data.personalDetailsList?.[transactionItem.managerID] as SearchAccountDetails);
- const formattedFrom = from.displayName ?? from.login ?? '';
- const formattedTo = to?.name ?? to?.displayName ?? to?.login ?? '';
- const formattedTotal = TransactionUtils.getAmount(transactionItem, isExpenseReport);
- const date = transactionItem?.modifiedCreated ? transactionItem.modifiedCreated : transactionItem?.created;
- const merchant = TransactionUtils.getMerchant(transactionItem);
- const formattedMerchant = merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || merchant === CONST.TRANSACTION.DEFAULT_MERCHANT ? '' : merchant;
+ const {formattedFrom, formattedTo, formattedTotal, formattedMerchant, date} = getTransactionItemCommonFormattedProperties(transactionItem, from, to);
const transaction = {
...transactionItem,
@@ -133,12 +139,12 @@ function getReportSections(data: OnyxTypes.SearchResults['data']): ReportListIte
formattedFrom,
formattedTo,
formattedTotal,
- date,
formattedMerchant,
+ date,
shouldShowMerchant,
- shouldShowCategory,
- shouldShowTag,
- shouldShowTax,
+ shouldShowCategory: true,
+ shouldShowTag: true,
+ shouldShowTax: true,
keyForList: transactionItem.transactionID,
};
if (reportIDToTransactions[reportKey]?.transactions) {
@@ -221,8 +227,8 @@ function getSortedTransactionData(data: TransactionListItemType[], sortBy?: Sear
function getSearchParams() {
const topmostCentralPaneRoute = getTopmostCentralPaneRoute(navigationRef.getRootState() as State);
- return topmostCentralPaneRoute?.params;
+ return topmostCentralPaneRoute?.params as CentralPaneNavigatorParamList['Search_Central_Pane'];
}
-export {getListItem, getQueryHash, getSections, getSortedSections, getShouldShowColumn, getShouldShowMerchant, getSearchType, getSearchParams};
+export {getListItem, getQueryHash, getSections, getSortedSections, getShouldShowMerchant, getSearchType, getSearchParams};
export type {SearchColumnType, SortOrder};
diff --git a/src/libs/SelectionScraper/index.ts b/src/libs/SelectionScraper/index.ts
index eda38a43507c..88726aa633b6 100644
--- a/src/libs/SelectionScraper/index.ts
+++ b/src/libs/SelectionScraper/index.ts
@@ -1,7 +1,7 @@
import render from 'dom-serializer';
import type {Node} from 'domhandler';
import {DataNode, Element} from 'domhandler';
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import {parseDocument} from 'htmlparser2';
import CONST from '@src/CONST';
import type GetCurrentSelection from './types';
diff --git a/src/libs/SessionUtils.ts b/src/libs/SessionUtils.ts
index 52521d5146cc..e8854e158b48 100644
--- a/src/libs/SessionUtils.ts
+++ b/src/libs/SessionUtils.ts
@@ -50,7 +50,7 @@ function resetDidUserLogInDuringSession() {
}
function didUserLogInDuringSession() {
- return Boolean(loggedInDuringSession);
+ return !!loggedInDuringSession;
}
export {isLoggingInAsNewUser, didUserLogInDuringSession, resetDidUserLogInDuringSession};
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index 651b3185f9af..3df823db22df 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {ChatReportSelector, PolicySelector, ReportActionsSelector} from '@hooks/useReportIDs';
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index 7b328458c0ac..6e2ee68b4eff 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -12,7 +12,7 @@ import {isCorporateCard, isExpensifyCard} from './CardUtils';
import DateUtils from './DateUtils';
import * as Localize from './Localize';
import * as NumberUtils from './NumberUtils';
-import {getCleanedTagName} from './PolicyUtils';
+import {getCleanedTagName, getCustomUnitRate} from './PolicyUtils';
let allTransactions: OnyxCollection = {};
Onyx.connect({
@@ -40,6 +40,16 @@ Onyx.connect({
callback: (value) => (allReports = value),
});
+let currentUserEmail = '';
+let currentUserAccountID = -1;
+Onyx.connect({
+ key: ONYXKEYS.SESSION,
+ callback: (val) => {
+ currentUserEmail = val?.email ?? '';
+ currentUserAccountID = val?.accountID ?? -1;
+ },
+});
+
function isDistanceRequest(transaction: OnyxEntry): boolean {
// This is used during the expense creation flow before the transaction has been saved to the server
if (lodashHas(transaction, 'iouRequestType')) {
@@ -58,7 +68,7 @@ function isScanRequest(transaction: OnyxEntry): boolean {
return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.SCAN;
}
- return Boolean(transaction?.receipt?.source);
+ return !!transaction?.receipt?.source && transaction?.amount === 0;
}
function getRequestType(transaction: OnyxEntry): IOURequestType {
@@ -509,7 +519,7 @@ function didRceiptScanSucceed(transaction: OnyxEntry): boolean {
* Check if the transaction has a non-smartscanning receipt and is missing required fields
*/
function hasMissingSmartscanFields(transaction: OnyxEntry): boolean {
- return Boolean(transaction && !isDistanceRequest(transaction) && !isReceiptBeingScanned(transaction) && areRequiredFieldsEmpty(transaction));
+ return !!(transaction && !isDistanceRequest(transaction) && !isReceiptBeingScanned(transaction) && areRequiredFieldsEmpty(transaction));
}
/**
@@ -523,9 +533,7 @@ function getTransactionViolations(transactionID: string, transactionViolations:
* Check if there is pending rter violation in transactionViolations.
*/
function hasPendingRTERViolation(transactionViolations?: TransactionViolations | null): boolean {
- return Boolean(
- transactionViolations?.some((transactionViolation: TransactionViolation) => transactionViolation.name === CONST.VIOLATIONS.RTER && transactionViolation.data?.pendingPattern),
- );
+ return !!transactionViolations?.some((transactionViolation: TransactionViolation) => transactionViolation.name === CONST.VIOLATIONS.RTER && transactionViolation.data?.pendingPattern);
}
/**
@@ -623,6 +631,23 @@ function getRecentTransactions(transactions: Record, size = 2):
.slice(0, size);
}
+/**
+ * Check if transaction has duplicatedTransaction violation.
+ * @param transactionID - the transaction to check
+ * @param checkDismissed - whether to check if the violation has already been dismissed as well
+ */
+function isDuplicate(transactionID: string, checkDismissed = false): boolean {
+ const hasDuplicatedViolation = !!allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]?.some(
+ (violation: TransactionViolation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION,
+ );
+ if (!checkDismissed) {
+ return hasDuplicatedViolation;
+ }
+ const didDismissedViolation =
+ allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]?.comment?.dismissedViolations?.duplicatedTransaction?.[currentUserEmail] === `${currentUserAccountID}`;
+ return hasDuplicatedViolation && !didDismissedViolation;
+}
+
/**
* Check if transaction is on hold
*/
@@ -631,7 +656,7 @@ function isOnHold(transaction: OnyxEntry): boolean {
return false;
}
- return !!transaction.comment?.hold;
+ return !!transaction.comment?.hold || isDuplicate(transaction.transactionID, true);
}
/**
@@ -649,8 +674,8 @@ function isOnHoldByTransactionID(transactionID: string): boolean {
* Checks if any violations for the provided transaction are of type 'violation'
*/
function hasViolation(transactionID: string, transactionViolations: OnyxCollection): boolean {
- return Boolean(
- transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some((violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.VIOLATION),
+ return !!transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some(
+ (violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.VIOLATION,
);
}
@@ -658,11 +683,18 @@ function hasViolation(transactionID: string, transactionViolations: OnyxCollecti
* Checks if any violations for the provided transaction are of type 'notice'
*/
function hasNoticeTypeViolation(transactionID: string, transactionViolations: OnyxCollection): boolean {
- return Boolean(transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some((violation: TransactionViolation) => violation.type === 'notice'));
+ return !!transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some((violation: TransactionViolation) => violation.type === 'notice');
+}
+
+/**
+ * Checks if any violations for the provided transaction are of type 'warning'
+ */
+function hasWarningTypeViolation(transactionID: string, transactionViolations: OnyxCollection): boolean {
+ return !!transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some((violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.WARNING);
}
/**
- * this is the formulae to calculate tax
+ * Calculates tax amount from the given expense amount and tax percentage
*/
function calculateTaxAmount(percentage: string, amount: number) {
const divisor = Number(percentage.slice(0, -1)) / 100 + 1;
@@ -683,6 +715,10 @@ function isCustomUnitRateIDForP2P(transaction: OnyxEntry): boolean
return transaction?.comment?.customUnit?.customUnitRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID;
}
+function hasReservationList(transaction: Transaction | undefined | null): boolean {
+ return !!transaction?.receipt?.reservationList && transaction?.receipt?.reservationList.length > 0;
+}
+
/**
* Get rate ID from the transaction object
*/
@@ -691,10 +727,16 @@ function getRateID(transaction: OnyxEntry): string | undefined {
}
/**
- * Gets the tax code based on selected currency.
- * Returns policy default tax rate if transaction is in policy default currency, otherwise returns foreign default tax rate
+ * Gets the tax code based on the type of transaction and selected currency.
+ * If it is distance request, then returns the tax code corresponding to the custom unit rate
+ * Else returns policy default tax rate if transaction is in policy default currency, otherwise foreign default tax rate
*/
function getDefaultTaxCode(policy: OnyxEntry, transaction: OnyxEntry, currency?: string | undefined) {
+ if (isDistanceRequest(transaction)) {
+ const customUnitRateID = getRateID(transaction) ?? '';
+ const customUnitRate = getCustomUnitRate(policy, customUnitRateID);
+ return customUnitRate?.attributes?.taxRateExternalID ?? '';
+ }
const defaultExternalID = policy?.taxRates?.defaultExternalID;
const foreignTaxDefault = policy?.taxRates?.foreignTaxDefault;
return policy?.outputCurrency === (currency ?? getCurrency(transaction)) ? defaultExternalID : foreignTaxDefault;
@@ -739,7 +781,7 @@ function getWorkspaceTaxesSettingsName(policy: OnyxEntry, taxCode: strin
}
/**
- * Gets the tax name
+ * Gets the name corresponding to the taxCode that is displayed to the user
*/
function getTaxName(policy: OnyxEntry, transaction: OnyxEntry) {
const defaultTaxCode = getDefaultTaxCode(policy, transaction);
@@ -788,6 +830,7 @@ export {
isFetchingWaypointsFromServer,
isExpensifyCardTransaction,
isCardTransaction,
+ isDuplicate,
isPending,
isPosted,
isOnHold,
@@ -805,8 +848,10 @@ export {
getWaypointIndex,
waypointHasValidAddress,
getRecentTransactions,
+ hasReservationList,
hasViolation,
hasNoticeTypeViolation,
+ hasWarningTypeViolation,
isCustomUnitRateIDForP2P,
getRateID,
};
diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts
new file mode 100644
index 000000000000..ead786b8eafd
--- /dev/null
+++ b/src/libs/TripReservationUtils.ts
@@ -0,0 +1,27 @@
+import * as Expensicons from '@src/components/Icon/Expensicons';
+import CONST from '@src/CONST';
+import type {Reservation, ReservationType} from '@src/types/onyx/Transaction';
+import type Transaction from '@src/types/onyx/Transaction';
+import type IconAsset from '@src/types/utils/IconAsset';
+
+function getTripReservationIcon(reservationType: ReservationType): IconAsset {
+ switch (reservationType) {
+ case CONST.RESERVATION_TYPE.FLIGHT:
+ return Expensicons.Plane;
+ case CONST.RESERVATION_TYPE.HOTEL:
+ return Expensicons.Bed;
+ case CONST.RESERVATION_TYPE.CAR:
+ return Expensicons.CarWithKey;
+ default:
+ return Expensicons.Luggage;
+ }
+}
+
+function getReservationsFromTripTransactions(transactions: Transaction[]): Reservation[] {
+ return transactions
+ .map((item) => item?.receipt?.reservationList ?? [])
+ .filter((item) => item.length > 0)
+ .flat();
+}
+
+export {getTripReservationIcon, getReservationsFromTripTransactions};
diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts
index 2acebd4636f5..946c92fed19d 100644
--- a/src/libs/UserUtils.ts
+++ b/src/libs/UserUtils.ts
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import type {OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import * as defaultAvatars from '@components/Icon/DefaultAvatars';
diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts
index 13fe326c2c1c..1dc5fa847d72 100644
--- a/src/libs/ValidationUtils.ts
+++ b/src/libs/ValidationUtils.ts
@@ -1,6 +1,5 @@
import {addYears, endOfMonth, format, isAfter, isBefore, isSameDay, isValid, isWithinInterval, parse, parseISO, startOfDay, subYears} from 'date-fns';
-import Str from 'expensify-common/lib/str';
-import {URL_REGEX_WITH_REQUIRED_PROTOCOL} from 'expensify-common/lib/Url';
+import {Str, Url} from 'expensify-common';
import isDate from 'lodash/isDate';
import isEmpty from 'lodash/isEmpty';
import isObject from 'lodash/isObject';
@@ -96,7 +95,7 @@ function isRequiredFulfilled(value?: FormValue | number[] | string[] | Record): Record {
@@ -293,15 +292,15 @@ function isValidUSPhone(phoneNumber = '', isCountryCodeOptional?: boolean): bool
}
function isValidValidateCode(validateCode: string): boolean {
- return Boolean(validateCode.match(CONST.VALIDATE_CODE_REGEX_STRING));
+ return !!validateCode.match(CONST.VALIDATE_CODE_REGEX_STRING);
}
function isValidRecoveryCode(recoveryCode: string): boolean {
- return Boolean(recoveryCode.match(CONST.RECOVERY_CODE_REGEX_STRING));
+ return !!recoveryCode.match(CONST.RECOVERY_CODE_REGEX_STRING);
}
function isValidTwoFactorCode(code: string): boolean {
- return Boolean(code.match(CONST.REGEX.CODE_2FA));
+ return !!code.match(CONST.REGEX.CODE_2FA);
}
/**
@@ -351,7 +350,7 @@ function isValidDisplayName(name: string): boolean {
* Checks that the provided legal name doesn't contain special characters
*/
function isValidLegalName(name: string): boolean {
- const hasAccentedChars = Boolean(name.match(CONST.REGEX.ACCENT_LATIN_CHARS));
+ const hasAccentedChars = !!name.match(CONST.REGEX.ACCENT_LATIN_CHARS);
return CONST.REGEX.ALPHABETIC_AND_LATIN_CHARS.test(name) && !hasAccentedChars;
}
@@ -480,6 +479,14 @@ function isExistingTaxName(taxName: string, taxRates: TaxRates): boolean {
return !!Object.values(taxRates).find((taxRate) => taxRate.name === trimmedTaxName);
}
+/**
+ * Validates the given value if it is correct subscription size.
+ */
+function isValidSubscriptionSize(subscriptionSize: string): boolean {
+ const parsedSubscriptionSize = Number(subscriptionSize);
+ return !Number.isNaN(parsedSubscriptionSize) && parsedSubscriptionSize > 0 && parsedSubscriptionSize <= CONST.SUBSCRIPTION_SIZE_LIMIT;
+}
+
export {
meetsMinimumAgeRequirement,
meetsMaximumAgeRequirement,
@@ -521,4 +528,5 @@ export {
isValidPercentage,
isValidReportName,
isExistingTaxName,
+ isValidSubscriptionSize,
};
diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts
index eb5f0311a37a..2ceecb42dba5 100644
--- a/src/libs/Violations/ViolationsUtils.ts
+++ b/src/libs/Violations/ViolationsUtils.ts
@@ -69,7 +69,7 @@ function getTagViolationsForMultiLevelTags(
const errorIndexes = [];
for (let i = 0; i < policyTagKeys.length; i++) {
const isTagRequired = policyTagList[policyTagKeys[i]].required ?? true;
- const isTagSelected = Boolean(selectedTags[i]);
+ const isTagSelected = !!selectedTags[i];
if (isTagRequired && (!isTagSelected || (selectedTags.length === 1 && selectedTags[0] === ''))) {
errorIndexes.push(i);
}
@@ -87,7 +87,7 @@ function getTagViolationsForMultiLevelTags(
for (let i = 0; i < policyTagKeys.length; i++) {
const selectedTag = selectedTags[i];
const tags = policyTagList[policyTagKeys[i]].tags;
- const isTagInPolicy = Object.values(tags).some((tag) => tag.name === selectedTag && Boolean(tag.enabled));
+ const isTagInPolicy = Object.values(tags).some((tag) => tag.name === selectedTag && !!tag.enabled);
if (!isTagInPolicy) {
newTransactionViolations.push({
name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY,
diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts
index bc6ee1f592e5..ae7fc115ac22 100644
--- a/src/libs/actions/App.ts
+++ b/src/libs/actions/App.ts
@@ -1,5 +1,5 @@
// Issue - https://github.com/Expensify/App/issues/26719
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import type {AppStateStatus} from 'react-native';
import {AppState} from 'react-native';
import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts
index 756ef902d913..9a011d88e582 100644
--- a/src/libs/actions/Card.ts
+++ b/src/libs/actions/Card.ts
@@ -5,7 +5,7 @@ import type {ActivatePhysicalExpensifyCardParams, ReportVirtualExpensifyCardFrau
import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {Response} from '@src/types/onyx';
+import type {ExpensifyCardDetails} from '@src/types/onyx/Card';
type ReplacementReason = 'damaged' | 'stolen';
@@ -158,7 +158,7 @@ function clearCardListErrors(cardID: number) {
*
* @returns promise with card details object
*/
-function revealVirtualCardDetails(cardID: number): Promise {
+function revealVirtualCardDetails(cardID: number): Promise {
return new Promise((resolve, reject) => {
const parameters: RevealExpensifyCardDetailsParams = {cardID};
@@ -170,7 +170,7 @@ function revealVirtualCardDetails(cardID: number): Promise {
reject('cardPage.cardDetailsLoadingFailure');
return;
}
- resolve(response);
+ resolve(response as ExpensifyCardDetails);
})
// eslint-disable-next-line prefer-promise-reject-errors
.catch(() => reject('cardPage.cardDetailsLoadingFailure'));
diff --git a/src/libs/actions/Device/generateDeviceID/index.android.ts b/src/libs/actions/Device/generateDeviceID/index.android.ts
index d662967a9f76..17563e039e6a 100644
--- a/src/libs/actions/Device/generateDeviceID/index.android.ts
+++ b/src/libs/actions/Device/generateDeviceID/index.android.ts
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import DeviceInfo from 'react-native-device-info';
import type GenerateDeviceID from './types';
diff --git a/src/libs/actions/Device/generateDeviceID/index.ts b/src/libs/actions/Device/generateDeviceID/index.ts
index 82ea72ba8180..a88457463f34 100644
--- a/src/libs/actions/Device/generateDeviceID/index.ts
+++ b/src/libs/actions/Device/generateDeviceID/index.ts
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import type GenerateDeviceID from './types';
const uniqueID = Str.guid();
diff --git a/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.native.ts b/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.native.ts
index 16a48348df71..06c425438053 100644
--- a/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.native.ts
+++ b/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.native.ts
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import RNDeviceInfo from 'react-native-device-info';
import type {GetOSAndName} from './types';
diff --git a/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.ts b/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.ts
index 29b004412f64..7111cab41ad1 100644
--- a/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.ts
+++ b/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.ts
@@ -1,4 +1,3 @@
-// Don't import this file with '* as Device'. It's known to make VSCode IntelliSense crash.
-import {getOSAndName} from 'expensify-common/lib/Device';
+import {Device} from 'expensify-common';
-export default getOSAndName;
+export default Device.getOSAndName;
diff --git a/src/libs/actions/ExitSurvey.ts b/src/libs/actions/ExitSurvey.ts
index ef3ecd6d3e31..67ac39d81bd6 100644
--- a/src/libs/actions/ExitSurvey.ts
+++ b/src/libs/actions/ExitSurvey.ts
@@ -65,7 +65,8 @@ function switchToOldDot() {
},
];
- API.write(
+ // eslint-disable-next-line rulesdir/no-api-side-effects-method
+ return API.makeRequestWithSideEffects(
'SwitchToOldDot',
{
reason: exitReason,
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 128ee5b85b7b..1bd4de43acfb 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -1,6 +1,5 @@
import {format} from 'date-fns';
-import fastMerge from 'expensify-common/lib/fastMerge';
-import Str from 'expensify-common/lib/str';
+import {fastMerge, Str} from 'expensify-common';
import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
@@ -14,6 +13,7 @@ import type {
DeleteMoneyRequestParams,
DetachReceiptParams,
EditMoneyRequestParams,
+ PayInvoiceParams,
PayMoneyRequestParams,
ReplaceReceiptParams,
RequestMoneyParams,
@@ -376,8 +376,8 @@ function startMoneyRequest(iouType: ValueOf, reportID: st
}
}
-function setMoneyRequestAmount(transactionID: string, amount: number, currency: string) {
- Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {amount, currency});
+function setMoneyRequestAmount(transactionID: string, amount: number, currency: string, shouldShowOriginalAmount = false) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {amount, currency, shouldShowOriginalAmount});
}
function setMoneyRequestCreated(transactionID: string, created: string, isDraft: boolean) {
@@ -2252,9 +2252,9 @@ function getTrackExpenseInformation(
createdReportActionIDForThread: optimisticCreatedActionForTransactionThread.reportActionID,
actionableWhisperReportActionIDParam: actionableTrackExpenseWhisper?.reportActionID ?? '',
onyxData: {
- optimisticData: [...optimisticData, ...trackExpenseOnyxData[0]],
- successData: [...successData, ...trackExpenseOnyxData[1]],
- failureData: [...failureData, ...trackExpenseOnyxData[2]],
+ optimisticData: optimisticData.concat(trackExpenseOnyxData[0]),
+ successData: successData.concat(trackExpenseOnyxData[1]),
+ failureData: failureData.concat(trackExpenseOnyxData[2]),
},
};
}
@@ -2392,7 +2392,7 @@ function calculateAmountForUpdatedWaypoint(
let updatedMerchant = Localize.translateLocal('iou.fieldPending');
if (!isEmptyObject(transactionChanges?.routes)) {
const customUnitRateID = TransactionUtils.getRateID(transaction) ?? '';
- const mileageRates = DistanceRequestUtils.getMileageRates(policy);
+ const mileageRates = DistanceRequestUtils.getMileageRates(policy, true);
const policyCurrency = policy?.outputCurrency ?? PolicyUtils.getPersonalPolicy()?.outputCurrency ?? CONST.CURRENCY.USD;
const mileageRate = TransactionUtils.isCustomUnitRateIDForP2P(transaction)
? DistanceRequestUtils.getRateForP2P(policyCurrency)
@@ -3513,7 +3513,7 @@ function sendInvoice(
const {senderWorkspaceID, receiver, invoiceRoom, createdChatReportActionID, invoiceReportID, reportPreviewReportActionID, transactionID, transactionThreadReportID, onyxData} =
getSendInvoiceInformation(transaction, currentUserAccountID, invoiceChatReport, receiptFile, policy, policyTagList, policyCategories);
- let parameters: SendInvoiceParams = {
+ const parameters: SendInvoiceParams = {
senderWorkspaceID,
accountID: currentUserAccountID,
amount: transaction?.amount ?? 0,
@@ -3528,20 +3528,9 @@ function sendInvoice(
reportPreviewReportActionID,
transactionID,
transactionThreadReportID,
+ ...(invoiceChatReport?.reportID ? {receiverInvoiceRoomID: invoiceChatReport.reportID} : {receiverEmail: receiver.login ?? ''}),
};
- if (invoiceChatReport) {
- parameters = {
- ...parameters,
- receiverInvoiceRoomID: invoiceChatReport.reportID,
- };
- } else {
- parameters = {
- ...parameters,
- receiverEmail: receiver.login,
- };
- }
-
API.write(WRITE_COMMANDS.SEND_INVOICE, parameters, onyxData);
Navigation.dismissModalWithReport(invoiceRoom);
@@ -3797,6 +3786,8 @@ function createSplitsAndOnyxData(
existingSplitChatReportID = '',
billable = false,
iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL,
+ taxCode = '',
+ taxAmount = 0,
): SplitsAndOnyxData {
const currentUserEmailForIOUSplit = PhoneNumber.addSMSDomainIfPhoneNumber(currentUserLogin);
const participantAccountIDs = participants.map((participant) => Number(participant.accountID));
@@ -3818,8 +3809,8 @@ function createSplitsAndOnyxData(
undefined,
category,
tag,
- undefined,
- undefined,
+ taxCode,
+ taxAmount,
billable,
);
@@ -3974,14 +3965,16 @@ function createSplitsAndOnyxData(
// Loop through participants creating individual chats, iouReports and reportActionIDs as needed
const currentUserAmount = splitShares?.[currentUserAccountID]?.amount ?? IOUUtils.calculateAmount(participants.length, amount, currency, true);
+ const currentUserTaxAmount = IOUUtils.calculateAmount(participants.length, taxAmount, currency, true);
- const splits: Split[] = [{email: currentUserEmailForIOUSplit, accountID: currentUserAccountID, amount: currentUserAmount}];
+ const splits: Split[] = [{email: currentUserEmailForIOUSplit, accountID: currentUserAccountID, amount: currentUserAmount, taxAmount: currentUserTaxAmount}];
const hasMultipleParticipants = participants.length > 1;
participants.forEach((participant) => {
// In a case when a participant is a workspace, even when a current user is not an owner of the workspace
const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(participant);
const splitAmount = splitShares?.[participant.accountID ?? -1]?.amount ?? IOUUtils.calculateAmount(participants.length, amount, currency, false);
+ const splitTaxAmount = IOUUtils.calculateAmount(participants.length, taxAmount, currency, false);
// To exclude someone from a split, the amount can be 0. The scenario for this is when creating a split from a group chat, we have remove the option to deselect users to exclude them.
// We can input '0' next to someone we want to exclude.
@@ -4051,8 +4044,8 @@ function createSplitsAndOnyxData(
undefined,
category,
tag,
- undefined,
- undefined,
+ taxCode,
+ ReportUtils.isExpenseReport(oneOnOneIOUReport) ? -splitTaxAmount : splitTaxAmount,
billable,
);
@@ -4143,6 +4136,7 @@ function createSplitsAndOnyxData(
reportPreviewReportActionID: oneOnOneReportPreviewAction.reportActionID,
transactionThreadReportID: optimisticTransactionThread.reportID,
createdReportActionIDForThread: optimisticCreatedActionForTransactionThread.reportActionID,
+ taxAmount: splitTaxAmount,
};
splits.push(individualSplit);
@@ -4196,6 +4190,8 @@ type SplitBillActionsParams = {
existingSplitChatReportID?: string;
splitShares?: SplitShares;
splitPayerAccountIDs?: number[];
+ taxCode?: string;
+ taxAmount?: number;
};
/**
@@ -4218,6 +4214,8 @@ function splitBill({
existingSplitChatReportID = '',
splitShares = {},
splitPayerAccountIDs = [],
+ taxCode = '',
+ taxAmount = 0,
}: SplitBillActionsParams) {
const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created);
const {splitData, splits, onyxData} = createSplitsAndOnyxData(
@@ -4235,6 +4233,8 @@ function splitBill({
existingSplitChatReportID,
billable,
iouRequestType,
+ taxCode,
+ taxAmount,
);
const parameters: SplitBillParams = {
@@ -4254,6 +4254,8 @@ function splitBill({
policyID: splitData.policyID,
chatType: splitData.chatType,
splitPayerAccountIDs,
+ taxCode,
+ taxAmount,
};
API.write(WRITE_COMMANDS.SPLIT_BILL, parameters, onyxData);
@@ -4280,6 +4282,8 @@ function splitBillAndOpenReport({
iouRequestType = CONST.IOU.REQUEST_TYPE.MANUAL,
splitShares = {},
splitPayerAccountIDs = [],
+ taxCode = '',
+ taxAmount = 0,
}: SplitBillActionsParams) {
const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created);
const {splitData, splits, onyxData} = createSplitsAndOnyxData(
@@ -4297,6 +4301,8 @@ function splitBillAndOpenReport({
'',
billable,
iouRequestType,
+ taxCode,
+ taxAmount,
);
const parameters: SplitBillParams = {
@@ -4316,6 +4322,8 @@ function splitBillAndOpenReport({
policyID: splitData.policyID,
chatType: splitData.chatType,
splitPayerAccountIDs,
+ taxCode,
+ taxAmount,
};
API.write(WRITE_COMMANDS.SPLIT_BILL_AND_OPEN_REPORT, parameters, onyxData);
@@ -4335,6 +4343,8 @@ type StartSplitBilActionParams = {
category: string | undefined;
tag: string | undefined;
currency: string;
+ taxCode: string;
+ taxAmount: number;
};
/** Used exclusively for starting a split expense request that contains a receipt, the split request will be completed once the receipt is scanned
@@ -4353,6 +4363,8 @@ function startSplitBill({
category = '',
tag = '',
currency,
+ taxCode = '',
+ taxAmount = 0,
}: StartSplitBilActionParams) {
const currentUserEmailForIOUSplit = PhoneNumber.addSMSDomainIfPhoneNumber(currentUserLogin);
const participantAccountIDs = participants.map((participant) => Number(participant.accountID));
@@ -4377,8 +4389,8 @@ function startSplitBill({
undefined,
category,
tag,
- undefined,
- undefined,
+ taxCode,
+ taxAmount,
billable,
);
@@ -4620,6 +4632,8 @@ function startSplitBill({
billable,
...(existingSplitChatReport ? {} : {createdReportActionID: splitChatCreatedReportAction.reportActionID}),
chatType: splitChatReport?.chatType,
+ taxCode,
+ taxAmount,
};
API.write(WRITE_COMMANDS.START_SPLIT_BILL, parameters, {optimisticData, successData, failureData});
@@ -4704,9 +4718,11 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA
const splitParticipants: Split[] = updatedTransaction?.comment.splits ?? [];
const amount = updatedTransaction?.modifiedAmount;
const currency = updatedTransaction?.modifiedCurrency;
+ console.debug(updatedTransaction);
// Exclude the current user when calculating the split amount, `calculateAmount` takes it into account
const splitAmount = IOUUtils.calculateAmount(splitParticipants.length - 1, amount ?? 0, currency ?? '', false);
+ const splitTaxAmount = IOUUtils.calculateAmount(splitParticipants.length - 1, updatedTransaction?.taxAmount ?? 0, currency ?? '', false);
const splits: Split[] = [{email: currentUserEmailForIOUSplit}];
splitParticipants.forEach((participant) => {
@@ -4770,8 +4786,8 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA
undefined,
updatedTransaction?.category,
updatedTransaction?.tag,
- undefined,
- undefined,
+ updatedTransaction?.taxCode,
+ isPolicyExpenseChat ? -splitTaxAmount : splitAmount,
updatedTransaction?.billable,
);
@@ -4845,6 +4861,8 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA
comment: transactionComment,
category: transactionCategory,
tag: transactionTag,
+ taxCode: transactionTaxCode,
+ taxAmount: transactionTaxAmount,
} = ReportUtils.getTransactionDetails(updatedTransaction) ?? {};
const parameters: CompleteSplitBillParams = {
@@ -4857,6 +4875,8 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA
category: transactionCategory,
tag: transactionTag,
splits: JSON.stringify(splits),
+ taxCode: transactionTaxCode,
+ taxAmount: transactionTaxAmount,
};
API.write(WRITE_COMMANDS.COMPLETE_SPLIT_BILL, parameters, {optimisticData, successData, failureData});
@@ -5850,6 +5870,8 @@ function getPayMoneyRequestParams(
paymentMethodType: PaymentMethodType,
full: boolean,
): PayMoneyRequestData {
+ const isInvoiceReport = ReportUtils.isInvoiceReport(iouReport);
+
let total = (iouReport.total ?? 0) - (iouReport.nonReimbursableTotal ?? 0);
if (ReportUtils.hasHeldExpenses(iouReport.reportID) && !full && !!iouReport.unheldTotal) {
total = iouReport.unheldTotal;
@@ -5874,9 +5896,12 @@ function getPayMoneyRequestParams(
if (reportPreviewAction) {
optimisticReportPreviewAction = ReportUtils.updateReportPreview(iouReport, reportPreviewAction as ReportPreviewAction, true);
}
-
- const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport.reportID}`] ?? null;
- const optimisticNextStep = NextStepUtils.buildNextStep(iouReport, CONST.REPORT.STATUS_NUM.REIMBURSED, {isPaidWithExpensify: paymentMethodType === CONST.IOU.PAYMENT_TYPE.VBBA});
+ let currentNextStep = null;
+ let optimisticNextStep = null;
+ if (!isInvoiceReport) {
+ currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport.reportID}`];
+ optimisticNextStep = NextStepUtils.buildNextStep(iouReport, CONST.REPORT.STATUS_NUM.REIMBURSED, {isPaidWithExpensify: paymentMethodType === CONST.IOU.PAYMENT_TYPE.VBBA});
+ }
const optimisticData: OnyxUpdate[] = [
{
@@ -6064,6 +6089,7 @@ function canApproveIOU(iouReport: OnyxEntry | EmptyObject, cha
function canIOUBePaid(iouReport: OnyxEntry | EmptyObject, chatReport: OnyxEntry | EmptyObject, policy: OnyxEntry | EmptyObject) {
const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport);
const isChatReportArchived = ReportUtils.isArchivedRoom(chatReport);
+ const iouSettled = ReportUtils.isSettled(iouReport?.reportID);
if (isEmptyObject(iouReport)) {
return false;
@@ -6074,10 +6100,12 @@ function canIOUBePaid(iouReport: OnyxEntry | EmptyObject, chat
}
if (ReportUtils.isInvoiceReport(iouReport)) {
+ if (iouSettled) {
+ return false;
+ }
if (chatReport?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) {
return chatReport?.invoiceReceiver?.accountID === userAccountID;
}
-
return PolicyUtils.getPolicy(chatReport?.invoiceReceiver?.policyID).role === CONST.POLICY.ROLE.ADMIN;
}
@@ -6090,7 +6118,6 @@ function canIOUBePaid(iouReport: OnyxEntry | EmptyObject, chat
);
const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport);
- const iouSettled = ReportUtils.isSettled(iouReport?.reportID);
const {reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(iouReport);
const isAutoReimbursable = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES ? false : ReportUtils.canBeAutoReimbursed(iouReport, policy);
@@ -6487,6 +6514,24 @@ function payMoneyRequest(paymentType: PaymentMethodType, chatReport: OnyxTypes.R
Navigation.dismissModalWithReport(chatReport);
}
+function payInvoice(paymentMethodType: PaymentMethodType, chatReport: OnyxTypes.Report, invoiceReport: OnyxTypes.Report) {
+ const recipient = {accountID: invoiceReport.ownerAccountID};
+ const {
+ optimisticData,
+ successData,
+ failureData,
+ params: {reportActionID},
+ } = getPayMoneyRequestParams(chatReport, invoiceReport, recipient, paymentMethodType, true);
+
+ const params: PayInvoiceParams = {
+ reportID: invoiceReport.reportID,
+ reportActionID,
+ paymentMethodType,
+ };
+
+ API.write(WRITE_COMMANDS.PAY_INVOICE, params, {optimisticData, successData, failureData});
+}
+
function detachReceipt(transactionID: string) {
const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
const newTransaction = transaction ? {...transaction, filename: '', receipt: {}} : null;
@@ -6593,8 +6638,8 @@ function setMoneyRequestTaxRate(transactionID: string, taxCode: string) {
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {taxCode});
}
-function setMoneyRequestTaxAmount(transactionID: string, taxAmount: number, isDraft: boolean) {
- Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {taxAmount});
+function setMoneyRequestTaxAmount(transactionID: string, taxAmount: number | null) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {taxAmount});
}
function setShownHoldUseExplanation() {
@@ -6743,6 +6788,7 @@ function putOnHold(transactionID: string, comment: string, reportID: string) {
comment: {
hold: null,
},
+ errors: ErrorUtils.getMicroSecondOnyxError('iou.genericHoldExpenseFailureMessage'),
},
},
];
@@ -6804,6 +6850,7 @@ function unholdRequest(transactionID: string, reportID: string) {
key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
value: {
pendingAction: null,
+ errors: ErrorUtils.getMicroSecondOnyxError('iou.genericUnholdExpenseFailureMessage'),
},
},
];
@@ -6871,6 +6918,7 @@ export {
initMoneyRequest,
navigateToStartStepIfScanFileCannotBeRead,
payMoneyRequest,
+ payInvoice,
putOnHold,
replaceReceipt,
requestMoney,
diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts
index c5a74bdc6ace..c12f7a042659 100644
--- a/src/libs/actions/PaymentMethods.ts
+++ b/src/libs/actions/PaymentMethods.ts
@@ -3,6 +3,7 @@ import type {MutableRefObject} from 'react';
import type {GestureResponderEvent} from 'react-native';
import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
import * as API from '@libs/API';
import type {AddPaymentCardParams, DeletePaymentCardParams, MakeDefaultPaymentMethodParams, PaymentCardParams, TransferWalletBalanceParams} from '@libs/API/parameters';
import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
@@ -198,6 +199,64 @@ function addPaymentCard(params: PaymentCardParams) {
});
}
+/**
+ * Calls the API to add a new card.
+ *
+ */
+function addSubscriptionPaymentCard(cardData: {
+ cardNumber: string;
+ cardYear: string;
+ cardMonth: string;
+ cardCVV: string;
+ addressName: string;
+ addressZip: string;
+ currency: ValueOf;
+}) {
+ const {cardNumber, cardYear, cardMonth, cardCVV, addressName, addressZip, currency} = cardData;
+
+ const parameters: AddPaymentCardParams = {
+ cardNumber,
+ cardYear,
+ cardMonth,
+ cardCVV,
+ addressName,
+ addressZip,
+ currency,
+ isP2PDebitCard: false,
+ };
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
+ value: {isLoading: true},
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
+ value: {isLoading: false},
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
+ value: {isLoading: false},
+ },
+ ];
+
+ // TODO integrate API for subscription card as a follow up
+ API.write(WRITE_COMMANDS.ADD_PAYMENT_CARD, parameters, {
+ optimisticData,
+ successData,
+ failureData,
+ });
+}
+
/**
* Resets the values for the add debit card form back to their initial states
*/
@@ -373,6 +432,7 @@ export {
makeDefaultPaymentMethod,
kycWallRef,
continueSetup,
+ addSubscriptionPaymentCard,
clearDebitCardFormErrorAndSubmit,
dismissSuccessfulTransferBalancePage,
transferWalletBalance,
diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts
new file mode 100644
index 000000000000..f3f8dfb4f3c7
--- /dev/null
+++ b/src/libs/actions/Policy/DistanceRate.ts
@@ -0,0 +1,681 @@
+import type {NullishDeep, OnyxCollection, OnyxUpdate} from 'react-native-onyx';
+import Onyx from 'react-native-onyx';
+import * as API from '@libs/API';
+import type {
+ CreatePolicyDistanceRateParams,
+ DeletePolicyDistanceRatesParams,
+ EnablePolicyDistanceRatesParams,
+ OpenPolicyDistanceRatesPageParams,
+ SetPolicyDistanceRatesEnabledParams,
+ SetPolicyDistanceRatesUnitParams,
+ UpdatePolicyDistanceRateValueParams,
+} from '@libs/API/parameters';
+import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import getIsNarrowLayout from '@libs/getIsNarrowLayout';
+import * as ReportUtils from '@libs/ReportUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Policy, Report} from '@src/types/onyx';
+import type {ErrorFields} from '@src/types/onyx/OnyxCommon';
+import type {Attributes, CustomUnit, Rate} from '@src/types/onyx/Policy';
+import type {OnyxData} from '@src/types/onyx/Request';
+import {navigateWhenEnableFeature, removePendingFieldsFromCustomUnit} from './Policy';
+
+type NewCustomUnit = {
+ customUnitID: string;
+ name: string;
+ attributes: Attributes;
+ rates: Rate;
+};
+
+const allPolicies: OnyxCollection = {};
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.POLICY,
+ callback: (val, key) => {
+ if (!key) {
+ return;
+ }
+ if (val === null || val === undefined) {
+ // If we are deleting a policy, we have to check every report linked to that policy
+ // and unset the draft indicator (pencil icon) alongside removing any draft comments. Clearing these values will keep the newly archived chats from being displayed in the LHN.
+ // More info: https://github.com/Expensify/App/issues/14260
+ const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, '');
+ const policyReports = ReportUtils.getAllPolicyReports(policyID);
+ const cleanUpMergeQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, NullishDeep> = {};
+ const cleanUpSetQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${string}` | `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${string}`, null> = {};
+ policyReports.forEach((policyReport) => {
+ if (!policyReport) {
+ return;
+ }
+ const {reportID} = policyReport;
+ cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null;
+ cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`] = null;
+ });
+ Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, cleanUpMergeQueries);
+ Onyx.multiSet(cleanUpSetQueries);
+ delete allPolicies[key];
+ return;
+ }
+
+ allPolicies[key] = val;
+ },
+});
+
+/**
+ * Takes array of customUnitRates and removes pendingFields and errorFields from each rate - we don't want to send those via API
+ */
+function prepareCustomUnitRatesArray(customUnitRates: Rate[]): Rate[] {
+ const customUnitRateArray: Rate[] = [];
+ customUnitRates.forEach((rate) => {
+ const cleanedRate = {...rate};
+ delete cleanedRate.pendingFields;
+ delete cleanedRate.errorFields;
+ customUnitRateArray.push(cleanedRate);
+ });
+
+ return customUnitRateArray;
+}
+
+function openPolicyDistanceRatesPage(policyID?: string) {
+ if (!policyID) {
+ return;
+ }
+
+ const params: OpenPolicyDistanceRatesPageParams = {policyID};
+
+ API.read(READ_COMMANDS.OPEN_POLICY_DISTANCE_RATES_PAGE, params);
+}
+
+function enablePolicyDistanceRates(policyID: string, enabled: boolean) {
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ areDistanceRatesEnabled: enabled,
+ pendingFields: {
+ areDistanceRatesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ pendingFields: {
+ areDistanceRatesEnabled: null,
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ areDistanceRatesEnabled: !enabled,
+ pendingFields: {
+ areDistanceRatesEnabled: null,
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters: EnablePolicyDistanceRatesParams = {policyID, enabled};
+
+ API.write(WRITE_COMMANDS.ENABLE_POLICY_DISTANCE_RATES, parameters, onyxData);
+
+ if (enabled && getIsNarrowLayout()) {
+ navigateWhenEnableFeature(policyID);
+ }
+}
+
+function createPolicyDistanceRate(policyID: string, customUnitID: string, customUnitRate: Rate) {
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnitID]: {
+ rates: {
+ [customUnitRate.customUnitRateID ?? '']: {
+ ...customUnitRate,
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ },
+ },
+ },
+ },
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnitID]: {
+ rates: {
+ [customUnitRate.customUnitRateID ?? '']: {
+ pendingAction: null,
+ },
+ },
+ },
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnitID]: {
+ rates: {
+ [customUnitRate.customUnitRateID ?? '']: {
+ errors: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage'),
+ },
+ },
+ },
+ },
+ },
+ },
+ ];
+
+ const params: CreatePolicyDistanceRateParams = {
+ policyID,
+ customUnitID,
+ customUnitRate: JSON.stringify(customUnitRate),
+ };
+
+ API.write(WRITE_COMMANDS.CREATE_POLICY_DISTANCE_RATE, params, {optimisticData, successData, failureData});
+}
+
+function clearCreateDistanceRateItemAndError(policyID: string, customUnitID: string, customUnitRateIDToClear: string) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ customUnits: {
+ [customUnitID]: {
+ rates: {
+ [customUnitRateIDToClear]: null,
+ },
+ },
+ },
+ });
+}
+
+function clearPolicyDistanceRatesErrorFields(policyID: string, customUnitID: string, updatedErrorFields: ErrorFields) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ customUnits: {
+ [customUnitID]: {
+ errorFields: updatedErrorFields,
+ },
+ },
+ });
+}
+
+function clearDeleteDistanceRateError(policyID: string, customUnitID: string, rateID: string) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ customUnits: {
+ [customUnitID]: {
+ rates: {
+ [rateID]: {
+ errors: null,
+ },
+ },
+ },
+ },
+ });
+}
+
+function clearPolicyDistanceRateErrorFields(policyID: string, customUnitID: string, rateID: string, updatedErrorFields: ErrorFields) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ customUnits: {
+ [customUnitID]: {
+ rates: {
+ [rateID]: {
+ errorFields: updatedErrorFields,
+ },
+ },
+ },
+ },
+ });
+}
+
+function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit) {
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [newCustomUnit.customUnitID]: {
+ ...newCustomUnit,
+ pendingFields: {attributes: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
+ },
+ },
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [newCustomUnit.customUnitID]: {
+ pendingFields: {attributes: null},
+ },
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [currentCustomUnit.customUnitID]: {
+ ...currentCustomUnit,
+ errorFields: {attributes: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')},
+ pendingFields: {attributes: null},
+ },
+ },
+ },
+ },
+ ];
+
+ const params: SetPolicyDistanceRatesUnitParams = {
+ policyID,
+ customUnit: JSON.stringify(removePendingFieldsFromCustomUnit(newCustomUnit)),
+ };
+
+ API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT, params, {optimisticData, successData, failureData});
+}
+
+function updatePolicyDistanceRateValue(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) {
+ const currentRates = customUnit.rates;
+ const optimisticRates: Record = {};
+ const successRates: Record = {};
+ const failureRates: Record = {};
+ const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID);
+
+ for (const rateID of Object.keys(customUnit.rates)) {
+ if (rateIDs.includes(rateID)) {
+ const foundRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID);
+ optimisticRates[rateID] = {...foundRate, pendingFields: {rate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}};
+ successRates[rateID] = {...foundRate, pendingFields: {rate: null}};
+ failureRates[rateID] = {
+ ...currentRates[rateID],
+ pendingFields: {rate: null},
+ errorFields: {rate: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')},
+ };
+ }
+ }
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: optimisticRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: successRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: failureRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const params: UpdatePolicyDistanceRateValueParams = {
+ policyID,
+ customUnitID: customUnit.customUnitID,
+ customUnitRateArray: JSON.stringify(prepareCustomUnitRatesArray(customUnitRates)),
+ };
+
+ API.write(WRITE_COMMANDS.UPDATE_POLICY_DISTANCE_RATE_VALUE, params, {optimisticData, successData, failureData});
+}
+
+function setPolicyDistanceRatesEnabled(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) {
+ const currentRates = customUnit.rates;
+ const optimisticRates: Record = {};
+ const successRates: Record = {};
+ const failureRates: Record = {};
+ const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID);
+
+ for (const rateID of Object.keys(currentRates)) {
+ if (rateIDs.includes(rateID)) {
+ const foundRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID);
+ optimisticRates[rateID] = {...foundRate, pendingFields: {enabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}};
+ successRates[rateID] = {...foundRate, pendingFields: {enabled: null}};
+ failureRates[rateID] = {
+ ...currentRates[rateID],
+ pendingFields: {enabled: null},
+ errorFields: {enabled: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')},
+ };
+ }
+ }
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: optimisticRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: successRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: failureRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const params: SetPolicyDistanceRatesEnabledParams = {
+ policyID,
+ customUnitID: customUnit.customUnitID,
+ customUnitRateArray: JSON.stringify(prepareCustomUnitRatesArray(customUnitRates)),
+ };
+
+ API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_ENABLED, params, {optimisticData, successData, failureData});
+}
+
+function deletePolicyDistanceRates(policyID: string, customUnit: CustomUnit, rateIDsToDelete: string[]) {
+ const currentRates = customUnit.rates;
+ const optimisticRates: Record = {};
+ const successRates: Record = {};
+ const failureRates: Record = {};
+
+ for (const rateID of Object.keys(currentRates)) {
+ if (rateIDsToDelete.includes(rateID)) {
+ optimisticRates[rateID] = {
+ ...currentRates[rateID],
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
+ };
+ failureRates[rateID] = {
+ ...currentRates[rateID],
+ pendingAction: null,
+ errors: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage'),
+ };
+ } else {
+ optimisticRates[rateID] = currentRates[rateID];
+ successRates[rateID] = currentRates[rateID];
+ }
+ }
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: optimisticRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: successRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: failureRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const params: DeletePolicyDistanceRatesParams = {
+ policyID,
+ customUnitID: customUnit.customUnitID,
+ customUnitRateID: rateIDsToDelete,
+ };
+
+ API.write(WRITE_COMMANDS.DELETE_POLICY_DISTANCE_RATES, params, {optimisticData, successData, failureData});
+}
+
+function updateDistanceTaxClaimableValue(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) {
+ const currentRates = customUnit.rates;
+ const optimisticRates: Record = {};
+ const successRates: Record = {};
+ const failureRates: Record = {};
+ const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID);
+
+ for (const rateID of Object.keys(customUnit.rates)) {
+ if (rateIDs.includes(rateID)) {
+ const foundRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID);
+ optimisticRates[rateID] = {...foundRate, pendingFields: {taxClaimablePercentage: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}};
+ successRates[rateID] = {...foundRate, pendingFields: {taxClaimablePercentage: null}};
+ failureRates[rateID] = {
+ ...currentRates[rateID],
+ pendingFields: {taxClaimablePercentage: null},
+ errorFields: {taxClaimablePercentage: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')},
+ };
+ }
+ }
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: optimisticRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: successRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: failureRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const params: UpdatePolicyDistanceRateValueParams = {
+ policyID,
+ customUnitID: customUnit.customUnitID,
+ customUnitRateArray: JSON.stringify(prepareCustomUnitRatesArray(customUnitRates)),
+ };
+
+ API.write(WRITE_COMMANDS.UPDATE_DISTANCE_TAX_CLAIMABLE_VALUE, params, {optimisticData, successData, failureData});
+}
+
+function updateDistanceTaxRate(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) {
+ const currentRates = customUnit.rates;
+ const optimisticRates: Record = {};
+ const successRates: Record = {};
+ const failureRates: Record = {};
+ const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID);
+
+ for (const rateID of Object.keys(customUnit.rates)) {
+ if (rateIDs.includes(rateID)) {
+ const foundRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID);
+ optimisticRates[rateID] = {...foundRate, pendingFields: {taxRateExternalID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}};
+ successRates[rateID] = {...foundRate, pendingFields: {taxRateExternalID: null}};
+ failureRates[rateID] = {
+ ...currentRates[rateID],
+ pendingFields: {taxRateExternalID: null},
+ errorFields: {taxRateExternalID: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')},
+ };
+ }
+ }
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: optimisticRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: successRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: failureRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const params: UpdatePolicyDistanceRateValueParams = {
+ policyID,
+ customUnitID: customUnit.customUnitID,
+ customUnitRateArray: JSON.stringify(prepareCustomUnitRatesArray(customUnitRates)),
+ };
+
+ API.write(WRITE_COMMANDS.UPDATE_POLICY_DISTANCE_TAX_RATE_VALUE, params, {optimisticData, successData, failureData});
+}
+
+export {
+ enablePolicyDistanceRates,
+ openPolicyDistanceRatesPage,
+ createPolicyDistanceRate,
+ clearCreateDistanceRateItemAndError,
+ clearDeleteDistanceRateError,
+ setPolicyDistanceRatesUnit,
+ clearPolicyDistanceRatesErrorFields,
+ clearPolicyDistanceRateErrorFields,
+ updatePolicyDistanceRateValue,
+ setPolicyDistanceRatesEnabled,
+ deletePolicyDistanceRates,
+ updateDistanceTaxClaimableValue,
+ updateDistanceTaxRate,
+};
+
+export type {NewCustomUnit};
diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts
new file mode 100644
index 000000000000..ee2f02b68252
--- /dev/null
+++ b/src/libs/actions/Policy/Member.ts
@@ -0,0 +1,809 @@
+import {ExpensiMark} from 'expensify-common';
+import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
+import Onyx from 'react-native-onyx';
+import * as API from '@libs/API';
+import type {
+ AddMembersToWorkspaceParams,
+ DeleteMembersFromWorkspaceParams,
+ OpenWorkspaceMembersPageParams,
+ RequestWorkspaceOwnerChangeParams,
+ UpdateWorkspaceMembersRoleParams,
+} from '@libs/API/parameters';
+import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import Log from '@libs/Log';
+import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
+import * as PhoneNumber from '@libs/PhoneNumber';
+import * as ReportUtils from '@libs/ReportUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {InvitedEmailsToAccountIDs, PersonalDetailsList, Policy, PolicyEmployee, PolicyOwnershipChangeChecks, Report, ReportAction} from '@src/types/onyx';
+import type {PendingAction} from '@src/types/onyx/OnyxCommon';
+import type {OriginalMessageJoinPolicyChangeLog} from '@src/types/onyx/OriginalMessage';
+import type {Attributes, Rate} from '@src/types/onyx/Policy';
+import type {EmptyObject} from '@src/types/utils/EmptyObject';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import {createPolicyExpenseChats} from './Policy';
+
+type AnnounceRoomMembersOnyxData = {
+ onyxOptimisticData: OnyxUpdate[];
+ onyxSuccessData: OnyxUpdate[];
+ onyxFailureData: OnyxUpdate[];
+};
+
+type NewCustomUnit = {
+ customUnitID: string;
+ name: string;
+ attributes: Attributes;
+ rates: Rate;
+};
+
+type WorkspaceMembersRoleData = {
+ accountID: number;
+ email: string;
+ role: typeof CONST.POLICY.ROLE.ADMIN | typeof CONST.POLICY.ROLE.USER;
+};
+
+const allPolicies: OnyxCollection = {};
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.POLICY,
+ callback: (val, key) => {
+ if (!key) {
+ return;
+ }
+ if (val === null || val === undefined) {
+ // If we are deleting a policy, we have to check every report linked to that policy
+ // and unset the draft indicator (pencil icon) alongside removing any draft comments. Clearing these values will keep the newly archived chats from being displayed in the LHN.
+ // More info: https://github.com/Expensify/App/issues/14260
+ const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, '');
+ const policyReports = ReportUtils.getAllPolicyReports(policyID);
+ const cleanUpMergeQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, NullishDeep> = {};
+ const cleanUpSetQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${string}` | `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${string}`, null> = {};
+ policyReports.forEach((policyReport) => {
+ if (!policyReport) {
+ return;
+ }
+ const {reportID} = policyReport;
+ cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null;
+ cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`] = null;
+ });
+ Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, cleanUpMergeQueries);
+ Onyx.multiSet(cleanUpSetQueries);
+ delete allPolicies[key];
+ return;
+ }
+
+ allPolicies[key] = val;
+ },
+});
+
+let sessionEmail = '';
+let sessionAccountID = 0;
+Onyx.connect({
+ key: ONYXKEYS.SESSION,
+ callback: (val) => {
+ sessionEmail = val?.email ?? '';
+ sessionAccountID = val?.accountID ?? -1;
+ },
+});
+
+let allPersonalDetails: OnyxEntry;
+Onyx.connect({
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ callback: (val) => (allPersonalDetails = val),
+});
+
+let policyOwnershipChecks: Record;
+Onyx.connect({
+ key: ONYXKEYS.POLICY_OWNERSHIP_CHANGE_CHECKS,
+ callback: (value) => {
+ policyOwnershipChecks = value ?? {};
+ },
+});
+
+/**
+ * Returns the policy of the report
+ */
+function getPolicy(policyID: string | undefined): Policy | EmptyObject {
+ if (!allPolicies || !policyID) {
+ return {};
+ }
+ return allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] ?? {};
+}
+
+/**
+ * Build optimistic data for adding members to the announcement room
+ */
+function buildAnnounceRoomMembersOnyxData(policyID: string, accountIDs: number[]): AnnounceRoomMembersOnyxData {
+ const announceReport = ReportUtils.getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID);
+ const announceRoomMembers: AnnounceRoomMembersOnyxData = {
+ onyxOptimisticData: [],
+ onyxFailureData: [],
+ onyxSuccessData: [],
+ };
+
+ if (!announceReport) {
+ return announceRoomMembers;
+ }
+
+ const participantAccountIDs = [...Object.keys(announceReport.participants ?? {}).map(Number), ...accountIDs];
+ const pendingChatMembers = ReportUtils.getPendingChatMembers(accountIDs, announceReport?.pendingChatMembers ?? [], CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
+
+ announceRoomMembers.onyxOptimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`,
+ value: {
+ participants: ReportUtils.buildParticipantsFromAccountIDs(participantAccountIDs),
+ pendingChatMembers,
+ },
+ });
+
+ announceRoomMembers.onyxFailureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`,
+ value: {
+ participants: announceReport?.participants ?? null,
+ pendingChatMembers: announceReport?.pendingChatMembers ?? null,
+ },
+ });
+ announceRoomMembers.onyxSuccessData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`,
+ value: {
+ pendingChatMembers: announceReport?.pendingChatMembers ?? null,
+ },
+ });
+ return announceRoomMembers;
+}
+
+/**
+ * Build optimistic data for removing users from the announcement room
+ */
+function removeOptimisticAnnounceRoomMembers(policyID: string, policyName: string, accountIDs: number[]): AnnounceRoomMembersOnyxData {
+ const announceReport = ReportUtils.getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID);
+ const announceRoomMembers: AnnounceRoomMembersOnyxData = {
+ onyxOptimisticData: [],
+ onyxFailureData: [],
+ onyxSuccessData: [],
+ };
+
+ if (!announceReport) {
+ return announceRoomMembers;
+ }
+
+ const pendingChatMembers = ReportUtils.getPendingChatMembers(accountIDs, announceReport?.pendingChatMembers ?? [], CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
+
+ announceRoomMembers.onyxOptimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`,
+ value: {
+ pendingChatMembers,
+ ...(accountIDs.includes(sessionAccountID)
+ ? {
+ statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
+ stateNum: CONST.REPORT.STATE_NUM.APPROVED,
+ oldPolicyName: policyName,
+ }
+ : {}),
+ },
+ });
+ announceRoomMembers.onyxFailureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`,
+ value: {
+ pendingChatMembers: announceReport?.pendingChatMembers ?? null,
+ ...(accountIDs.includes(sessionAccountID)
+ ? {
+ statusNum: announceReport.statusNum,
+ stateNum: announceReport.stateNum,
+ oldPolicyName: announceReport.oldPolicyName,
+ }
+ : {}),
+ },
+ });
+ announceRoomMembers.onyxSuccessData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`,
+ value: {
+ pendingChatMembers: announceReport?.pendingChatMembers ?? null,
+ },
+ });
+
+ return announceRoomMembers;
+}
+
+/**
+ * Remove the passed members from the policy employeeList
+ * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details
+ */
+function removeMembers(accountIDs: number[], policyID: string) {
+ // In case user selects only themselves (admin), their email will be filtered out and the members
+ // array passed will be empty, prevent the function from proceeding in that case as there is no one to remove
+ if (accountIDs.length === 0) {
+ return;
+ }
+
+ const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const;
+ const policy = getPolicy(policyID);
+
+ const workspaceChats = ReportUtils.getWorkspaceChats(policyID, accountIDs);
+ const emailList = accountIDs.map((accountID) => allPersonalDetails?.[accountID]?.login).filter((login) => !!login) as string[];
+ const optimisticClosedReportActions = workspaceChats.map(() => ReportUtils.buildOptimisticClosedReportAction(sessionEmail, policy.name, CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY));
+
+ const announceRoomMembers = removeOptimisticAnnounceRoomMembers(policy.id, policy.name, accountIDs);
+
+ const optimisticMembersState: OnyxCollection = {};
+ const successMembersState: OnyxCollection = {};
+ const failureMembersState: OnyxCollection = {};
+ emailList.forEach((email) => {
+ optimisticMembersState[email] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE};
+ successMembersState[email] = null;
+ failureMembersState[email] = {errors: ErrorUtils.getMicroSecondOnyxError('workspace.people.error.genericRemove')};
+ });
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: policyKey,
+ value: {employeeList: optimisticMembersState},
+ },
+ ...announceRoomMembers.onyxOptimisticData,
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: policyKey,
+ value: {employeeList: successMembersState},
+ },
+ ...announceRoomMembers.onyxSuccessData,
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: policyKey,
+ value: {employeeList: failureMembersState},
+ },
+ ...announceRoomMembers.onyxFailureData,
+ ];
+
+ const pendingChatMembers = ReportUtils.getPendingChatMembers(accountIDs, [], CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
+
+ workspaceChats.forEach((report) => {
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`,
+ value: {
+ statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
+ stateNum: CONST.REPORT.STATE_NUM.APPROVED,
+ oldPolicyName: policy.name,
+ pendingChatMembers,
+ },
+ });
+ successData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`,
+ value: {
+ pendingChatMembers: null,
+ },
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`,
+ value: {
+ pendingChatMembers: null,
+ },
+ });
+ });
+ // comment out for time this issue would be resolved https://github.com/Expensify/App/issues/35952
+ // optimisticClosedReportActions.forEach((reportAction, index) => {
+ // optimisticData.push({
+ // onyxMethod: Onyx.METHOD.MERGE,
+ // key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats?.[index]?.reportID}`,
+ // value: {[reportAction.reportActionID]: reportAction as ReportAction},
+ // });
+ // });
+
+ // If the policy has primaryLoginsInvited, then it displays informative messages on the members page about which primary logins were added by secondary logins.
+ // If we delete all these logins then we should clear the informative messages since they are no longer relevant.
+ if (!isEmptyObject(policy?.primaryLoginsInvited ?? {})) {
+ // Take the current policy members and remove them optimistically
+ const employeeListEmails = Object.keys(allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.employeeList ?? {});
+ const remainingLogins = employeeListEmails.filter((email) => !emailList.includes(email));
+ const invitedPrimaryToSecondaryLogins: Record = {};
+
+ if (policy.primaryLoginsInvited) {
+ Object.keys(policy.primaryLoginsInvited).forEach((key) => (invitedPrimaryToSecondaryLogins[policy.primaryLoginsInvited?.[key] ?? ''] = key));
+ }
+
+ // Then, if no remaining members exist that were invited by a secondary login, clear the informative messages
+ if (!remainingLogins.some((remainingLogin) => !!invitedPrimaryToSecondaryLogins[remainingLogin])) {
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: policyKey,
+ value: {
+ primaryLoginsInvited: null,
+ },
+ });
+ }
+ }
+
+ const filteredWorkspaceChats = workspaceChats.filter((report): report is Report => report !== null);
+
+ filteredWorkspaceChats.forEach(({reportID, stateNum, statusNum, oldPolicyName = null}) => {
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {
+ stateNum,
+ statusNum,
+ oldPolicyName,
+ },
+ });
+ });
+ optimisticClosedReportActions.forEach((reportAction, index) => {
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats?.[index]?.reportID}`,
+ value: {[reportAction.reportActionID]: null},
+ });
+ });
+
+ const params: DeleteMembersFromWorkspaceParams = {
+ emailList: emailList.join(','),
+ policyID,
+ };
+
+ API.write(WRITE_COMMANDS.DELETE_MEMBERS_FROM_WORKSPACE, params, {optimisticData, successData, failureData});
+}
+
+function updateWorkspaceMembersRole(policyID: string, accountIDs: number[], newRole: typeof CONST.POLICY.ROLE.ADMIN | typeof CONST.POLICY.ROLE.USER) {
+ const previousEmployeeList = {...allPolicies?.[policyID]?.employeeList};
+ const memberRoles: WorkspaceMembersRoleData[] = accountIDs.reduce((result: WorkspaceMembersRoleData[], accountID: number) => {
+ if (!allPersonalDetails?.[accountID]?.login) {
+ return result;
+ }
+
+ result.push({
+ accountID,
+ email: allPersonalDetails?.[accountID]?.login ?? '',
+ role: newRole,
+ });
+
+ return result;
+ }, []);
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ employeeList: {
+ ...memberRoles.reduce((member: Record, current) => {
+ // eslint-disable-next-line no-param-reassign
+ member[current.email] = {role: current?.role, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE};
+ return member;
+ }, {}),
+ },
+ errors: null,
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ employeeList: {
+ ...memberRoles.reduce((member: Record, current) => {
+ // eslint-disable-next-line no-param-reassign
+ member[current.email] = {role: current?.role, pendingAction: null};
+ return member;
+ }, {}),
+ },
+ errors: null,
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ employeeList: previousEmployeeList,
+ errors: ErrorUtils.getMicroSecondOnyxError('workspace.editor.genericFailureMessage'),
+ },
+ },
+ ];
+
+ const params: UpdateWorkspaceMembersRoleParams = {
+ policyID,
+ employees: JSON.stringify(memberRoles.map((item) => ({email: item.email, role: item.role}))),
+ };
+
+ API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData, failureData});
+}
+
+function requestWorkspaceOwnerChange(policyID: string) {
+ const policy = getPolicy(policyID);
+ const ownershipChecks = {...policyOwnershipChecks?.[policyID]} ?? {};
+
+ const changeOwnerErrors = Object.keys(policy?.errorFields?.changeOwner ?? {});
+
+ if (changeOwnerErrors && changeOwnerErrors.length > 0) {
+ const currentError = changeOwnerErrors[0];
+ if (currentError === CONST.POLICY.OWNERSHIP_ERRORS.AMOUNT_OWED) {
+ ownershipChecks.shouldClearOutstandingBalance = true;
+ }
+
+ if (currentError === CONST.POLICY.OWNERSHIP_ERRORS.OWNER_OWES_AMOUNT) {
+ ownershipChecks.shouldTransferAmountOwed = true;
+ }
+
+ if (currentError === CONST.POLICY.OWNERSHIP_ERRORS.SUBSCRIPTION) {
+ ownershipChecks.shouldTransferSubscription = true;
+ }
+
+ if (currentError === CONST.POLICY.OWNERSHIP_ERRORS.DUPLICATE_SUBSCRIPTION) {
+ ownershipChecks.shouldTransferSingleSubscription = true;
+ }
+
+ Onyx.merge(ONYXKEYS.POLICY_OWNERSHIP_CHANGE_CHECKS, {
+ [policyID]: ownershipChecks,
+ });
+ }
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ errorFields: null,
+ isLoading: true,
+ isChangeOwnerSuccessful: false,
+ isChangeOwnerFailed: false,
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ isLoading: false,
+ isChangeOwnerSuccessful: true,
+ isChangeOwnerFailed: false,
+ owner: sessionEmail,
+ ownerAccountID: sessionAccountID,
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ isLoading: false,
+ isChangeOwnerSuccessful: false,
+ isChangeOwnerFailed: true,
+ },
+ },
+ ];
+
+ const params: RequestWorkspaceOwnerChangeParams = {
+ policyID,
+ ...ownershipChecks,
+ };
+
+ API.write(WRITE_COMMANDS.REQUEST_WORKSPACE_OWNER_CHANGE, params, {optimisticData, successData, failureData});
+}
+
+function clearWorkspaceOwnerChangeFlow(policyID: string) {
+ Onyx.merge(ONYXKEYS.POLICY_OWNERSHIP_CHANGE_CHECKS, null);
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ errorFields: null,
+ isLoading: false,
+ isChangeOwnerSuccessful: false,
+ isChangeOwnerFailed: false,
+ });
+}
+
+/**
+ * Adds members to the specified workspace/policyID
+ * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details
+ */
+function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, welcomeNote: string, policyID: string) {
+ const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const;
+ const logins = Object.keys(invitedEmailsToAccountIDs).map((memberLogin) => PhoneNumber.addSMSDomainIfPhoneNumber(memberLogin));
+ const accountIDs = Object.values(invitedEmailsToAccountIDs);
+
+ const {newAccountIDs, newLogins} = PersonalDetailsUtils.getNewAccountIDsAndLogins(logins, accountIDs);
+ const newPersonalDetailsOnyxData = PersonalDetailsUtils.getPersonalDetailsOnyxDataForOptimisticUsers(newLogins, newAccountIDs);
+
+ const announceRoomMembers = buildAnnounceRoomMembersOnyxData(policyID, accountIDs);
+
+ // create onyx data for policy expense chats for each new member
+ const membersChats = createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs);
+
+ const optimisticMembersState: OnyxCollection = {};
+ const successMembersState: OnyxCollection = {};
+ const failureMembersState: OnyxCollection = {};
+ logins.forEach((email) => {
+ optimisticMembersState[email] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, role: CONST.POLICY.ROLE.USER};
+ successMembersState[email] = {pendingAction: null};
+ failureMembersState[email] = {
+ errors: ErrorUtils.getMicroSecondOnyxError('workspace.people.error.genericAdd'),
+ };
+ });
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: policyKey,
+
+ // Convert to object with each key containing {pendingAction: ‘add’}
+ value: {
+ employeeList: optimisticMembersState,
+ },
+ },
+ ...newPersonalDetailsOnyxData.optimisticData,
+ ...membersChats.onyxOptimisticData,
+ ...announceRoomMembers.onyxOptimisticData,
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: policyKey,
+ value: {
+ employeeList: successMembersState,
+ },
+ },
+ ...newPersonalDetailsOnyxData.finallyData,
+ ...membersChats.onyxSuccessData,
+ ...announceRoomMembers.onyxSuccessData,
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: policyKey,
+
+ // Convert to object with each key containing the error. We don’t
+ // need to remove the members since that is handled by onClose of OfflineWithFeedback.
+ value: {
+ employeeList: failureMembersState,
+ },
+ },
+ ...membersChats.onyxFailureData,
+ ...announceRoomMembers.onyxFailureData,
+ ];
+
+ const params: AddMembersToWorkspaceParams = {
+ employees: JSON.stringify(logins.map((login) => ({email: login}))),
+ welcomeNote: new ExpensiMark().replace(welcomeNote),
+ policyID,
+ };
+ if (!isEmptyObject(membersChats.reportCreationData)) {
+ params.reportCreationData = JSON.stringify(membersChats.reportCreationData);
+ }
+ API.write(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, params, {optimisticData, successData, failureData});
+}
+
+/**
+ * Invite member to the specified policyID
+ * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details
+ */
+function inviteMemberToWorkspace(policyID: string, inviterEmail: string) {
+ const memberJoinKey = `${ONYXKEYS.COLLECTION.POLICY_JOIN_MEMBER}${policyID}` as const;
+
+ const optimisticMembersState = {policyID, inviterEmail};
+ const failureMembersState = {policyID, inviterEmail};
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: memberJoinKey,
+ value: optimisticMembersState,
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: memberJoinKey,
+ value: {...failureMembersState, errors: ErrorUtils.getMicroSecondOnyxError('common.genericEditFailureMessage')},
+ },
+ ];
+
+ const params = {policyID, inviterEmail};
+
+ API.write(WRITE_COMMANDS.JOIN_POLICY_VIA_INVITE_LINK, params, {optimisticData, failureData});
+}
+
+/**
+ * Removes an error after trying to delete a member
+ */
+function clearDeleteMemberError(policyID: string, accountID: number) {
+ const email = allPersonalDetails?.[accountID]?.login ?? '';
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ employeeList: {
+ [email]: {
+ pendingAction: null,
+ errors: null,
+ },
+ },
+ });
+}
+
+/**
+ * Removes an error after trying to add a member
+ */
+function clearAddMemberError(policyID: string, accountID: number) {
+ const email = allPersonalDetails?.[accountID]?.login ?? '';
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ employeeList: {
+ [email]: null,
+ },
+ });
+ Onyx.merge(`${ONYXKEYS.PERSONAL_DETAILS_LIST}`, {
+ [accountID]: null,
+ });
+}
+
+function openWorkspaceMembersPage(policyID: string, clientMemberEmails: string[]) {
+ if (!policyID || !clientMemberEmails) {
+ Log.warn('openWorkspaceMembersPage invalid params', {policyID, clientMemberEmails});
+ return;
+ }
+
+ const params: OpenWorkspaceMembersPageParams = {
+ policyID,
+ clientMemberEmails: JSON.stringify(clientMemberEmails),
+ };
+
+ API.read(READ_COMMANDS.OPEN_WORKSPACE_MEMBERS_PAGE, params);
+}
+
+function setWorkspaceInviteMembersDraft(policyID: string, invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs) {
+ Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`, invitedEmailsToAccountIDs);
+}
+
+/**
+ * Accept user join request to a workspace
+ */
+function acceptJoinRequest(reportID: string, reportAction: OnyxEntry) {
+ const choice = CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.ACCEPT;
+ if (!reportAction) {
+ return;
+ }
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
+ value: {
+ [reportAction.reportActionID]: {
+ originalMessage: {choice},
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
+ value: {
+ [reportAction.reportActionID]: {
+ originalMessage: {choice},
+ pendingAction: null,
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
+ value: {
+ [reportAction.reportActionID]: {
+ originalMessage: {choice: ''},
+ pendingAction: null,
+ },
+ },
+ },
+ ];
+
+ const parameters = {
+ requests: JSON.stringify({
+ [(reportAction.originalMessage as OriginalMessageJoinPolicyChangeLog['originalMessage']).policyID]: {
+ requests: [{accountID: reportAction?.actorAccountID, adminsRoomMessageReportActionID: reportAction.reportActionID}],
+ },
+ }),
+ };
+
+ API.write(WRITE_COMMANDS.ACCEPT_JOIN_REQUEST, parameters, {optimisticData, failureData, successData});
+}
+
+/**
+ * Decline user join request to a workspace
+ */
+function declineJoinRequest(reportID: string, reportAction: OnyxEntry) {
+ if (!reportAction) {
+ return;
+ }
+ const choice = CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.DECLINE;
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
+ value: {
+ [reportAction.reportActionID]: {
+ originalMessage: {choice},
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
+ value: {
+ [reportAction.reportActionID]: {
+ originalMessage: {choice},
+ pendingAction: null,
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
+ value: {
+ [reportAction.reportActionID]: {
+ originalMessage: {choice: ''},
+ pendingAction: null,
+ },
+ },
+ },
+ ];
+
+ const parameters = {
+ requests: JSON.stringify({
+ [(reportAction.originalMessage as OriginalMessageJoinPolicyChangeLog['originalMessage']).policyID]: {
+ requests: [{accountID: reportAction?.actorAccountID, adminsRoomMessageReportActionID: reportAction.reportActionID}],
+ },
+ }),
+ };
+
+ API.write(WRITE_COMMANDS.DECLINE_JOIN_REQUEST, parameters, {optimisticData, failureData, successData});
+}
+
+export {
+ removeMembers,
+ updateWorkspaceMembersRole,
+ requestWorkspaceOwnerChange,
+ clearWorkspaceOwnerChangeFlow,
+ addMembersToWorkspace,
+ clearDeleteMemberError,
+ clearAddMemberError,
+ openWorkspaceMembersPage,
+ setWorkspaceInviteMembersDraft,
+ inviteMemberToWorkspace,
+ acceptJoinRequest,
+ declineJoinRequest,
+};
+
+export type {NewCustomUnit};
diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts
index 92d843c96ea3..0e168f973078 100644
--- a/src/libs/actions/Policy/Policy.ts
+++ b/src/libs/actions/Policy/Policy.ts
@@ -1,6 +1,4 @@
-import {PUBLIC_DOMAINS} from 'expensify-common/lib/CONST';
-import ExpensiMark from 'expensify-common/lib/ExpensiMark';
-import Str from 'expensify-common/lib/str';
+import {PUBLIC_DOMAINS, Str} from 'expensify-common';
import {escapeRegExp} from 'lodash';
import lodashClone from 'lodash/clone';
import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
@@ -9,44 +7,32 @@ import type {ValueOf} from 'type-fest';
import * as API from '@libs/API';
import type {
AddBillingCardAndRequestWorkspaceOwnerChangeParams,
- AddMembersToWorkspaceParams,
- CreatePolicyDistanceRateParams,
CreateWorkspaceFromIOUPaymentParams,
CreateWorkspaceParams,
- DeleteMembersFromWorkspaceParams,
- DeletePolicyDistanceRatesParams,
DeleteWorkspaceAvatarParams,
DeleteWorkspaceParams,
EnablePolicyConnectionsParams,
- EnablePolicyDistanceRatesParams,
EnablePolicyReportFieldsParams,
EnablePolicyTaxesParams,
EnablePolicyWorkflowsParams,
LeavePolicyParams,
OpenDraftWorkspaceRequestParams,
- OpenPolicyDistanceRatesPageParams,
OpenPolicyMoreFeaturesPageParams,
OpenPolicyTaxesPageParams,
OpenPolicyWorkflowsPageParams,
OpenWorkspaceInvitePageParams,
- OpenWorkspaceMembersPageParams,
OpenWorkspaceParams,
OpenWorkspaceReimburseViewParams,
- RequestWorkspaceOwnerChangeParams,
- SetPolicyDistanceRatesEnabledParams,
- SetPolicyDistanceRatesUnitParams,
SetWorkspaceApprovalModeParams,
SetWorkspaceAutoReportingFrequencyParams,
SetWorkspaceAutoReportingMonthlyOffsetParams,
SetWorkspaceAutoReportingParams,
SetWorkspacePayerParams,
SetWorkspaceReimbursementParams,
- UpdatePolicyDistanceRateValueParams,
UpdateWorkspaceAvatarParams,
UpdateWorkspaceCustomUnitAndRateParams,
UpdateWorkspaceDescriptionParams,
UpdateWorkspaceGeneralSettingsParams,
- UpdateWorkspaceMembersRoleParams,
} from '@libs/API/parameters';
import type UpdatePolicyAddressParams from '@libs/API/parameters/UpdatePolicyAddressParams';
import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
@@ -56,7 +42,6 @@ import getIsNarrowLayout from '@libs/getIsNarrowLayout';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import * as NumberUtils from '@libs/NumberUtils';
-import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as PhoneNumber from '@libs/PhoneNumber';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
@@ -66,32 +51,13 @@ import type {PolicySelector} from '@pages/home/sidebar/SidebarScreen/FloatingAct
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {
- InvitedEmailsToAccountIDs,
- PersonalDetailsList,
- Policy,
- PolicyCategory,
- PolicyEmployee,
- PolicyOwnershipChangeChecks,
- ReimbursementAccount,
- Report,
- ReportAction,
- TaxRatesWithDefault,
- Transaction,
-} from '@src/types/onyx';
-import type {ErrorFields, Errors, PendingAction} from '@src/types/onyx/OnyxCommon';
-import type {OriginalMessageJoinPolicyChangeLog} from '@src/types/onyx/OriginalMessage';
+import type {InvitedEmailsToAccountIDs, PersonalDetailsList, Policy, PolicyCategory, ReimbursementAccount, Report, ReportAction, TaxRatesWithDefault, Transaction} from '@src/types/onyx';
+import type {Errors} from '@src/types/onyx/OnyxCommon';
import type {Attributes, CompanyAddress, CustomUnit, Rate, TaxRate, Unit} from '@src/types/onyx/Policy';
import type {OnyxData} from '@src/types/onyx/Request';
import type {EmptyObject} from '@src/types/utils/EmptyObject';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
-type AnnounceRoomMembersOnyxData = {
- onyxOptimisticData: OnyxUpdate[];
- onyxSuccessData: OnyxUpdate[];
- onyxFailureData: OnyxUpdate[];
-};
-
type ReportCreationData = Record<
string,
{
@@ -114,8 +80,6 @@ type OptimisticCustomUnits = {
outputCurrency: string;
};
-type PoliciesRecord = Record>;
-
type NewCustomUnit = {
customUnitID: string;
name: string;
@@ -123,12 +87,6 @@ type NewCustomUnit = {
rates: Rate;
};
-type WorkspaceMembersRoleData = {
- accountID: number;
- email: string;
- role: typeof CONST.POLICY.ROLE.ADMIN | typeof CONST.POLICY.ROLE.USER;
-};
-
const allPolicies: OnyxCollection = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.POLICY,
@@ -197,14 +155,6 @@ Onyx.connect({
callback: (val) => (reimbursementAccount = val),
});
-let policyOwnershipChecks: Record;
-Onyx.connect({
- key: ONYXKEYS.POLICY_OWNERSHIP_CHANGE_CHECKS,
- callback: (value) => {
- policyOwnershipChecks = value ?? {};
- },
-});
-
/**
* Stores in Onyx the policy ID of the last workspace that was accessed by the user
*/
@@ -388,58 +338,6 @@ function deleteWorkspace(policyID: string, policyName: string) {
}
}
-/**
- * Is the user an admin of a free policy (aka workspace)?
- */
-function isAdminOfFreePolicy(policies?: PoliciesRecord): boolean {
- return Object.values(policies ?? {}).some((policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN);
-}
-
-/**
- * Build optimistic data for adding members to the announcement room
- */
-function buildAnnounceRoomMembersOnyxData(policyID: string, accountIDs: number[]): AnnounceRoomMembersOnyxData {
- const announceReport = ReportUtils.getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID);
- const announceRoomMembers: AnnounceRoomMembersOnyxData = {
- onyxOptimisticData: [],
- onyxFailureData: [],
- onyxSuccessData: [],
- };
-
- if (!announceReport) {
- return announceRoomMembers;
- }
-
- const participantAccountIDs = [...Object.keys(announceReport.participants ?? {}).map(Number), ...accountIDs];
- const pendingChatMembers = ReportUtils.getPendingChatMembers(accountIDs, announceReport?.pendingChatMembers ?? [], CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
-
- announceRoomMembers.onyxOptimisticData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`,
- value: {
- participants: ReportUtils.buildParticipantsFromAccountIDs(participantAccountIDs),
- pendingChatMembers,
- },
- });
-
- announceRoomMembers.onyxFailureData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`,
- value: {
- participants: announceReport?.participants ?? null,
- pendingChatMembers: announceReport?.pendingChatMembers ?? null,
- },
- });
- announceRoomMembers.onyxSuccessData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`,
- value: {
- pendingChatMembers: announceReport?.pendingChatMembers ?? null,
- },
- });
- return announceRoomMembers;
-}
-
function setWorkspaceAutoReporting(policyID: string, enabled: boolean, frequency: ValueOf) {
const policy = getPolicy(policyID);
const optimisticData: OnyxUpdate[] = [
@@ -729,208 +627,6 @@ function clearWorkspaceReimbursementErrors(policyID: string) {
Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errorFields: {reimbursementChoice: null}});
}
-/**
- * Build optimistic data for removing users from the announcement room
- */
-function removeOptimisticAnnounceRoomMembers(policyID: string, policyName: string, accountIDs: number[]): AnnounceRoomMembersOnyxData {
- const announceReport = ReportUtils.getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID);
- const announceRoomMembers: AnnounceRoomMembersOnyxData = {
- onyxOptimisticData: [],
- onyxFailureData: [],
- onyxSuccessData: [],
- };
-
- if (!announceReport) {
- return announceRoomMembers;
- }
-
- const pendingChatMembers = ReportUtils.getPendingChatMembers(accountIDs, announceReport?.pendingChatMembers ?? [], CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
-
- announceRoomMembers.onyxOptimisticData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`,
- value: {
- pendingChatMembers,
- ...(accountIDs.includes(sessionAccountID)
- ? {
- statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
- stateNum: CONST.REPORT.STATE_NUM.APPROVED,
- oldPolicyName: policyName,
- }
- : {}),
- },
- });
- announceRoomMembers.onyxFailureData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`,
- value: {
- pendingChatMembers: announceReport?.pendingChatMembers ?? null,
- ...(accountIDs.includes(sessionAccountID)
- ? {
- statusNum: announceReport.statusNum,
- stateNum: announceReport.stateNum,
- oldPolicyName: announceReport.oldPolicyName,
- }
- : {}),
- },
- });
- announceRoomMembers.onyxSuccessData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`,
- value: {
- pendingChatMembers: announceReport?.pendingChatMembers ?? null,
- },
- });
-
- return announceRoomMembers;
-}
-
-/**
- * Remove the passed members from the policy employeeList
- * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details
- */
-function removeMembers(accountIDs: number[], policyID: string) {
- // In case user selects only themselves (admin), their email will be filtered out and the members
- // array passed will be empty, prevent the function from proceeding in that case as there is no one to remove
- if (accountIDs.length === 0) {
- return;
- }
-
- const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const;
- const policy = getPolicy(policyID);
-
- const workspaceChats = ReportUtils.getWorkspaceChats(policyID, accountIDs);
- const emailList = accountIDs.map((accountID) => allPersonalDetails?.[accountID]?.login).filter((login) => !!login) as string[];
- const optimisticClosedReportActions = workspaceChats.map(() => ReportUtils.buildOptimisticClosedReportAction(sessionEmail, policy.name, CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY));
-
- const announceRoomMembers = removeOptimisticAnnounceRoomMembers(policy.id, policy.name, accountIDs);
-
- const optimisticMembersState: OnyxCollection = {};
- const successMembersState: OnyxCollection = {};
- const failureMembersState: OnyxCollection = {};
- emailList.forEach((email) => {
- optimisticMembersState[email] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE};
- successMembersState[email] = null;
- failureMembersState[email] = {errors: ErrorUtils.getMicroSecondOnyxError('workspace.people.error.genericRemove')};
- });
-
- const optimisticData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: policyKey,
- value: {employeeList: optimisticMembersState},
- },
- ...announceRoomMembers.onyxOptimisticData,
- ];
-
- const successData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: policyKey,
- value: {employeeList: successMembersState},
- },
- ...announceRoomMembers.onyxSuccessData,
- ];
-
- const failureData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: policyKey,
- value: {employeeList: failureMembersState},
- },
- ...announceRoomMembers.onyxFailureData,
- ];
-
- const pendingChatMembers = ReportUtils.getPendingChatMembers(accountIDs, [], CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
-
- workspaceChats.forEach((report) => {
- optimisticData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`,
- value: {
- statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
- stateNum: CONST.REPORT.STATE_NUM.APPROVED,
- oldPolicyName: policy.name,
- pendingChatMembers,
- },
- });
- successData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`,
- value: {
- pendingChatMembers: null,
- },
- });
- failureData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`,
- value: {
- pendingChatMembers: null,
- },
- });
- });
- // comment out for time this issue would be resolved https://github.com/Expensify/App/issues/35952
- // optimisticClosedReportActions.forEach((reportAction, index) => {
- // optimisticData.push({
- // onyxMethod: Onyx.METHOD.MERGE,
- // key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats?.[index]?.reportID}`,
- // value: {[reportAction.reportActionID]: reportAction as ReportAction},
- // });
- // });
-
- // If the policy has primaryLoginsInvited, then it displays informative messages on the members page about which primary logins were added by secondary logins.
- // If we delete all these logins then we should clear the informative messages since they are no longer relevant.
- if (!isEmptyObject(policy?.primaryLoginsInvited ?? {})) {
- // Take the current policy members and remove them optimistically
- const employeeListEmails = Object.keys(allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.employeeList ?? {});
- const remainingLogins = employeeListEmails.filter((email) => !emailList.includes(email));
- const invitedPrimaryToSecondaryLogins: Record = {};
-
- if (policy.primaryLoginsInvited) {
- Object.keys(policy.primaryLoginsInvited).forEach((key) => (invitedPrimaryToSecondaryLogins[policy.primaryLoginsInvited?.[key] ?? ''] = key));
- }
-
- // Then, if no remaining members exist that were invited by a secondary login, clear the informative messages
- if (!remainingLogins.some((remainingLogin) => !!invitedPrimaryToSecondaryLogins[remainingLogin])) {
- optimisticData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: policyKey,
- value: {
- primaryLoginsInvited: null,
- },
- });
- }
- }
-
- const filteredWorkspaceChats = workspaceChats.filter((report): report is Report => report !== null);
-
- filteredWorkspaceChats.forEach(({reportID, stateNum, statusNum, oldPolicyName = null}) => {
- failureData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
- value: {
- stateNum,
- statusNum,
- oldPolicyName,
- },
- });
- });
- optimisticClosedReportActions.forEach((reportAction, index) => {
- failureData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats?.[index]?.reportID}`,
- value: {[reportAction.reportActionID]: null},
- });
- });
-
- const params: DeleteMembersFromWorkspaceParams = {
- emailList: emailList.join(','),
- policyID,
- };
-
- API.write(WRITE_COMMANDS.DELETE_MEMBERS_FROM_WORKSPACE, params, {optimisticData, successData, failureData});
-}
-
function leaveWorkspace(policyID: string) {
const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
const workspaceChats = ReportUtils.getAllWorkspaceReports(policyID);
@@ -1020,103 +716,19 @@ function leaveWorkspace(policyID: string) {
API.write(WRITE_COMMANDS.LEAVE_POLICY, params, {optimisticData, successData, failureData});
}
-function updateWorkspaceMembersRole(policyID: string, accountIDs: number[], newRole: typeof CONST.POLICY.ROLE.ADMIN | typeof CONST.POLICY.ROLE.USER) {
- const previousEmployeeList = {...allPolicies?.[policyID]?.employeeList};
- const memberRoles: WorkspaceMembersRoleData[] = accountIDs.reduce((result: WorkspaceMembersRoleData[], accountID: number) => {
- if (!allPersonalDetails?.[accountID]?.login) {
- return result;
- }
-
- result.push({
- accountID,
- email: allPersonalDetails?.[accountID]?.login ?? '',
- role: newRole,
- });
-
- return result;
- }, []);
-
- const optimisticData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- employeeList: {
- ...memberRoles.reduce((member: Record, current) => {
- // eslint-disable-next-line no-param-reassign
- member[current.email] = {role: current?.role, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE};
- return member;
- }, {}),
- },
- errors: null,
- },
- },
- ];
-
- const successData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- employeeList: {
- ...memberRoles.reduce((member: Record, current) => {
- // eslint-disable-next-line no-param-reassign
- member[current.email] = {role: current?.role, pendingAction: null};
- return member;
- }, {}),
- },
- errors: null,
- },
- },
- ];
-
- const failureData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- employeeList: previousEmployeeList,
- errors: ErrorUtils.getMicroSecondOnyxError('workspace.editor.genericFailureMessage'),
- },
- },
- ];
-
- const params: UpdateWorkspaceMembersRoleParams = {
- policyID,
- employees: JSON.stringify(memberRoles.map((item) => ({email: item.email, role: item.role}))),
- };
-
- API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData, failureData});
-}
-
-function requestWorkspaceOwnerChange(policyID: string) {
- const policy = getPolicy(policyID);
- const ownershipChecks = {...policyOwnershipChecks?.[policyID]} ?? {};
-
- const changeOwnerErrors = Object.keys(policy?.errorFields?.changeOwner ?? {});
-
- if (changeOwnerErrors && changeOwnerErrors.length > 0) {
- const currentError = changeOwnerErrors[0];
- if (currentError === CONST.POLICY.OWNERSHIP_ERRORS.AMOUNT_OWED) {
- ownershipChecks.shouldClearOutstandingBalance = true;
- }
-
- if (currentError === CONST.POLICY.OWNERSHIP_ERRORS.OWNER_OWES_AMOUNT) {
- ownershipChecks.shouldTransferAmountOwed = true;
- }
-
- if (currentError === CONST.POLICY.OWNERSHIP_ERRORS.SUBSCRIPTION) {
- ownershipChecks.shouldTransferSubscription = true;
- }
-
- if (currentError === CONST.POLICY.OWNERSHIP_ERRORS.DUPLICATE_SUBSCRIPTION) {
- ownershipChecks.shouldTransferSingleSubscription = true;
- }
-
- Onyx.merge(ONYXKEYS.POLICY_OWNERSHIP_CHANGE_CHECKS, {
- [policyID]: ownershipChecks,
- });
- }
+function addBillingCardAndRequestPolicyOwnerChange(
+ policyID: string,
+ cardData: {
+ cardNumber: string;
+ cardYear: string;
+ cardMonth: string;
+ cardCVV: string;
+ addressName: string;
+ addressZip: string;
+ currency: string;
+ },
+) {
+ const {cardNumber, cardYear, cardMonth, cardCVV, addressName, addressZip, currency} = cardData;
const optimisticData: OnyxUpdate[] = [
{
@@ -1157,103 +769,32 @@ function requestWorkspaceOwnerChange(policyID: string) {
},
];
- const params: RequestWorkspaceOwnerChangeParams = {
+ const params: AddBillingCardAndRequestWorkspaceOwnerChangeParams = {
policyID,
- ...ownershipChecks,
+ cardNumber,
+ cardYear,
+ cardMonth,
+ cardCVV,
+ addressName,
+ addressZip,
+ currency,
};
- API.write(WRITE_COMMANDS.REQUEST_WORKSPACE_OWNER_CHANGE, params, {optimisticData, successData, failureData});
+ API.write(WRITE_COMMANDS.ADD_BILLING_CARD_AND_REQUEST_WORKSPACE_OWNER_CHANGE, params, {optimisticData, successData, failureData});
}
-function clearWorkspaceOwnerChangeFlow(policyID: string) {
- Onyx.merge(ONYXKEYS.POLICY_OWNERSHIP_CHANGE_CHECKS, null);
- Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
- errorFields: null,
- isLoading: false,
- isChangeOwnerSuccessful: false,
- isChangeOwnerFailed: false,
- });
-}
-
-function addBillingCardAndRequestPolicyOwnerChange(
- policyID: string,
- cardData: {
- cardNumber: string;
- cardYear: string;
- cardMonth: string;
- cardCVV: string;
- addressName: string;
- addressZip: string;
- currency: string;
- },
-) {
- const {cardNumber, cardYear, cardMonth, cardCVV, addressName, addressZip, currency} = cardData;
-
- const optimisticData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- errorFields: null,
- isLoading: true,
- isChangeOwnerSuccessful: false,
- isChangeOwnerFailed: false,
- },
- },
- ];
-
- const successData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- isLoading: false,
- isChangeOwnerSuccessful: true,
- isChangeOwnerFailed: false,
- owner: sessionEmail,
- ownerAccountID: sessionAccountID,
- },
- },
- ];
-
- const failureData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- isLoading: false,
- isChangeOwnerSuccessful: false,
- isChangeOwnerFailed: true,
- },
- },
- ];
-
- const params: AddBillingCardAndRequestWorkspaceOwnerChangeParams = {
- policyID,
- cardNumber,
- cardYear,
- cardMonth,
- cardCVV,
- addressName,
- addressZip,
- currency,
- };
-
- API.write(WRITE_COMMANDS.ADD_BILLING_CARD_AND_REQUEST_WORKSPACE_OWNER_CHANGE, params, {optimisticData, successData, failureData});
-}
-
-/**
- * Optimistically create a chat for each member of the workspace, creates both optimistic and success data for onyx.
- *
- * @returns - object with onyxSuccessData, onyxOptimisticData, and optimisticReportIDs (map login to reportID)
- */
-function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, hasOutstandingChildRequest = false): WorkspaceMembersChats {
- const workspaceMembersChats: WorkspaceMembersChats = {
- onyxSuccessData: [],
- onyxOptimisticData: [],
- onyxFailureData: [],
- reportCreationData: {},
- };
+/**
+ * Optimistically create a chat for each member of the workspace, creates both optimistic and success data for onyx.
+ *
+ * @returns - object with onyxSuccessData, onyxOptimisticData, and optimisticReportIDs (map login to reportID)
+ */
+function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, hasOutstandingChildRequest = false): WorkspaceMembersChats {
+ const workspaceMembersChats: WorkspaceMembersChats = {
+ onyxSuccessData: [],
+ onyxOptimisticData: [],
+ onyxFailureData: [],
+ reportCreationData: {},
+ };
Object.keys(invitedEmailsToAccountIDs).forEach((email) => {
const accountID = invitedEmailsToAccountIDs[email];
@@ -1340,117 +881,6 @@ function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: I
return workspaceMembersChats;
}
-/**
- * Adds members to the specified workspace/policyID
- * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details
- */
-function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, welcomeNote: string, policyID: string) {
- const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const;
- const logins = Object.keys(invitedEmailsToAccountIDs).map((memberLogin) => PhoneNumber.addSMSDomainIfPhoneNumber(memberLogin));
- const accountIDs = Object.values(invitedEmailsToAccountIDs);
-
- const {newAccountIDs, newLogins} = PersonalDetailsUtils.getNewAccountIDsAndLogins(logins, accountIDs);
- const newPersonalDetailsOnyxData = PersonalDetailsUtils.getPersonalDetailsOnyxDataForOptimisticUsers(newLogins, newAccountIDs);
-
- const announceRoomMembers = buildAnnounceRoomMembersOnyxData(policyID, accountIDs);
-
- // create onyx data for policy expense chats for each new member
- const membersChats = createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs);
-
- const optimisticMembersState: OnyxCollection = {};
- const successMembersState: OnyxCollection = {};
- const failureMembersState: OnyxCollection = {};
- logins.forEach((email) => {
- optimisticMembersState[email] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, role: CONST.POLICY.ROLE.USER};
- successMembersState[email] = {pendingAction: null};
- failureMembersState[email] = {
- errors: ErrorUtils.getMicroSecondOnyxError('workspace.people.error.genericAdd'),
- };
- });
-
- const optimisticData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: policyKey,
-
- // Convert to object with each key containing {pendingAction: ‘add’}
- value: {
- employeeList: optimisticMembersState,
- },
- },
- ...newPersonalDetailsOnyxData.optimisticData,
- ...membersChats.onyxOptimisticData,
- ...announceRoomMembers.onyxOptimisticData,
- ];
-
- const successData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: policyKey,
- value: {
- employeeList: successMembersState,
- },
- },
- ...newPersonalDetailsOnyxData.finallyData,
- ...membersChats.onyxSuccessData,
- ...announceRoomMembers.onyxSuccessData,
- ];
-
- const failureData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: policyKey,
-
- // Convert to object with each key containing the error. We don’t
- // need to remove the members since that is handled by onClose of OfflineWithFeedback.
- value: failureMembersState,
- },
- ...membersChats.onyxFailureData,
- ...announceRoomMembers.onyxFailureData,
- ];
-
- const params: AddMembersToWorkspaceParams = {
- employees: JSON.stringify(logins.map((login) => ({email: login}))),
- welcomeNote: new ExpensiMark().replace(welcomeNote),
- policyID,
- };
- if (!isEmptyObject(membersChats.reportCreationData)) {
- params.reportCreationData = JSON.stringify(membersChats.reportCreationData);
- }
- API.write(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, params, {optimisticData, successData, failureData});
-}
-
-/**
- * Invite member to the specified policyID
- * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details
- */
-function inviteMemberToWorkspace(policyID: string, inviterEmail: string) {
- const memberJoinKey = `${ONYXKEYS.COLLECTION.POLICY_JOIN_MEMBER}${policyID}` as const;
-
- const optimisticMembersState = {policyID, inviterEmail};
- const failureMembersState = {policyID, inviterEmail};
-
- const optimisticData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: memberJoinKey,
- value: optimisticMembersState,
- },
- ];
-
- const failureData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: memberJoinKey,
- value: {...failureMembersState, errors: ErrorUtils.getMicroSecondOnyxError('common.genericEditFailureMessage')},
- },
- ];
-
- const params = {policyID, inviterEmail};
-
- API.write(WRITE_COMMANDS.JOIN_POLICY_VIA_INVITE_LINK, params, {optimisticData, failureData});
-}
-
/**
* Updates a workspace avatar image
*/
@@ -1567,14 +997,14 @@ function clearAvatarErrors(policyID: string) {
*/
function updateGeneralSettings(policyID: string, name: string, currencyValue?: string) {
const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
- const distanceUnit = Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
- const customUnitID = distanceUnit?.customUnitID;
- const currency = currencyValue ?? policy?.outputCurrency ?? CONST.CURRENCY.USD;
-
if (!policy) {
return;
}
+ const distanceUnit = PolicyUtils.getCustomUnit(policy);
+ const customUnitID = distanceUnit?.customUnitID;
+ const currency = currencyValue ?? policy?.outputCurrency ?? CONST.CURRENCY.USD;
+
const currentRates = distanceUnit?.rates ?? {};
const optimisticRates: Record = {};
const finallyRates: Record = {};
@@ -1893,36 +1323,6 @@ function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: C
API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT_AND_RATE, params, {optimisticData, successData, failureData});
}
-/**
- * Removes an error after trying to delete a member
- */
-function clearDeleteMemberError(policyID: string, accountID: number) {
- const email = allPersonalDetails?.[accountID]?.login ?? '';
- Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
- employeeList: {
- [email]: {
- pendingAction: null,
- errors: null,
- },
- },
- });
-}
-
-/**
- * Removes an error after trying to add a member
- */
-function clearAddMemberError(policyID: string, accountID: number) {
- const email = allPersonalDetails?.[accountID]?.login ?? '';
- Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
- employeeList: {
- [email]: null,
- },
- });
- Onyx.merge(`${ONYXKEYS.PERSONAL_DETAILS_LIST}`, {
- [accountID]: null,
- });
-}
-
/**
* Removes an error after trying to delete a workspace
*/
@@ -2007,6 +1407,7 @@ function buildOptimisticCustomUnits(): OptimisticCustomUnits {
customUnitRateID,
name: CONST.CUSTOM_UNITS.DEFAULT_RATE,
rate: CONST.CUSTOM_UNITS.MILEAGE_IRS_RATE * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET,
+ enabled: true,
currency,
},
},
@@ -2601,20 +2002,6 @@ function openWorkspace(policyID: string, clientMemberAccountIDs: number[]) {
API.read(READ_COMMANDS.OPEN_WORKSPACE, params);
}
-function openWorkspaceMembersPage(policyID: string, clientMemberEmails: string[]) {
- if (!policyID || !clientMemberEmails) {
- Log.warn('openWorkspaceMembersPage invalid params', {policyID, clientMemberEmails});
- return;
- }
-
- const params: OpenWorkspaceMembersPageParams = {
- policyID,
- clientMemberEmails: JSON.stringify(clientMemberEmails),
- };
-
- API.read(READ_COMMANDS.OPEN_WORKSPACE_MEMBERS_PAGE, params);
-}
-
function openPolicyTaxesPage(policyID: string) {
if (!policyID) {
Log.warn('openPolicyTaxesPage invalid params', {policyID});
@@ -2648,10 +2035,6 @@ function openDraftWorkspaceRequest(policyID: string) {
API.read(READ_COMMANDS.OPEN_DRAFT_WORKSPACE_REQUEST, params);
}
-function setWorkspaceInviteMembersDraft(policyID: string, invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs) {
- Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`, invitedEmailsToAccountIDs);
-}
-
function setWorkspaceInviteMessageDraft(policyID: string, message: string | null) {
Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT}${policyID}`, message);
}
@@ -3098,133 +2481,6 @@ function createWorkspaceFromIOUPayment(iouReport: Report | EmptyObject): string
return policyID;
}
-/**
- * Accept user join request to a workspace
- */
-function acceptJoinRequest(reportID: string, reportAction: OnyxEntry) {
- const choice = CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.ACCEPT;
- if (!reportAction) {
- return;
- }
-
- const optimisticData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
- value: {
- [reportAction.reportActionID]: {
- originalMessage: {choice},
- pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
- },
- },
- },
- ];
-
- const successData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
- value: {
- [reportAction.reportActionID]: {
- originalMessage: {choice},
- pendingAction: null,
- },
- },
- },
- ];
-
- const failureData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
- value: {
- [reportAction.reportActionID]: {
- originalMessage: {choice: ''},
- pendingAction: null,
- },
- },
- },
- ];
-
- const parameters = {
- requests: JSON.stringify({
- [(reportAction.originalMessage as OriginalMessageJoinPolicyChangeLog['originalMessage']).policyID]: {
- requests: [{accountID: reportAction?.actorAccountID, adminsRoomMessageReportActionID: reportAction.reportActionID}],
- },
- }),
- };
-
- API.write(WRITE_COMMANDS.ACCEPT_JOIN_REQUEST, parameters, {optimisticData, failureData, successData});
-}
-
-/**
- * Decline user join request to a workspace
- */
-function declineJoinRequest(reportID: string, reportAction: OnyxEntry) {
- if (!reportAction) {
- return;
- }
- const choice = CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.DECLINE;
- const optimisticData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
- value: {
- [reportAction.reportActionID]: {
- originalMessage: {choice},
- pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
- },
- },
- },
- ];
-
- const successData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
- value: {
- [reportAction.reportActionID]: {
- originalMessage: {choice},
- pendingAction: null,
- },
- },
- },
- ];
-
- const failureData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
- value: {
- [reportAction.reportActionID]: {
- originalMessage: {choice: ''},
- pendingAction: null,
- },
- },
- },
- ];
-
- const parameters = {
- requests: JSON.stringify({
- [(reportAction.originalMessage as OriginalMessageJoinPolicyChangeLog['originalMessage']).policyID]: {
- requests: [{accountID: reportAction?.actorAccountID, adminsRoomMessageReportActionID: reportAction.reportActionID}],
- },
- }),
- };
-
- API.write(WRITE_COMMANDS.DECLINE_JOIN_REQUEST, parameters, {optimisticData, failureData, successData});
-}
-
-function openPolicyDistanceRatesPage(policyID?: string) {
- if (!policyID) {
- return;
- }
-
- const params: OpenPolicyDistanceRatesPageParams = {policyID};
-
- API.read(READ_COMMANDS.OPEN_POLICY_DISTANCE_RATES_PAGE, params);
-}
-
function navigateWhenEnableFeature(policyID: string) {
setTimeout(() => {
Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(policyID));
@@ -3273,50 +2529,6 @@ function enablePolicyConnections(policyID: string, enabled: boolean) {
const parameters: EnablePolicyConnectionsParams = {policyID, enabled};
API.write(WRITE_COMMANDS.ENABLE_POLICY_CONNECTIONS, parameters, onyxData);
-}
-
-function enablePolicyDistanceRates(policyID: string, enabled: boolean) {
- const onyxData: OnyxData = {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- areDistanceRatesEnabled: enabled,
- pendingFields: {
- areDistanceRatesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
- },
- },
- },
- ],
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- pendingFields: {
- areDistanceRatesEnabled: null,
- },
- },
- },
- ],
- failureData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- areDistanceRatesEnabled: !enabled,
- pendingFields: {
- areDistanceRatesEnabled: null,
- },
- },
- },
- ],
- };
-
- const parameters: EnablePolicyDistanceRatesParams = {policyID, enabled};
-
- API.write(WRITE_COMMANDS.ENABLE_POLICY_DISTANCE_RATES, parameters, onyxData);
if (enabled && getIsNarrowLayout()) {
navigateWhenEnableFeature(policyID);
@@ -3565,125 +2777,66 @@ function enablePolicyWorkflows(policyID: string, enabled: boolean) {
}
}
-function openPolicyMoreFeaturesPage(policyID: string) {
- const params: OpenPolicyMoreFeaturesPageParams = {policyID};
-
- API.read(READ_COMMANDS.OPEN_POLICY_MORE_FEATURES_PAGE, params);
-}
-
-function createPolicyDistanceRate(policyID: string, customUnitID: string, customUnitRate: Rate) {
- const optimisticData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- customUnits: {
- [customUnitID]: {
- rates: {
- [customUnitRate.customUnitRateID ?? '']: {
- ...customUnitRate,
- pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
- },
+function enableDistanceRequestTax(policyID: string, customUnitName: string, customUnitID: string, attributes: Attributes) {
+ const policy = getPolicy(policyID);
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnitID]: {
+ attributes,
},
},
+ pendingFields: {
+ customUnits: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
},
},
- },
- ];
-
- const successData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- customUnits: {
- [customUnitID]: {
- rates: {
- [customUnitRate.customUnitRateID ?? '']: {
- pendingAction: null,
- },
- },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ pendingFields: {
+ customUnits: null,
},
},
},
- },
- ];
-
- const failureData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- customUnits: {
- [customUnitID]: {
- rates: {
- [customUnitRate.customUnitRateID ?? '']: {
- errors: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage'),
- },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnitID]: {
+ attributes: policy.customUnits ? policy.customUnits[customUnitID].attributes : null,
},
},
},
},
- },
- ];
+ ],
+ };
- const params: CreatePolicyDistanceRateParams = {
+ const params = {
policyID,
- customUnitID,
- customUnitRate: JSON.stringify(customUnitRate),
+ customUnit: JSON.stringify({
+ customUnitName,
+ customUnitID,
+ attributes,
+ }),
};
-
- API.write(WRITE_COMMANDS.CREATE_POLICY_DISTANCE_RATE, params, {optimisticData, successData, failureData});
-}
-
-function clearCreateDistanceRateItemAndError(policyID: string, customUnitID: string, customUnitRateIDToClear: string) {
- Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
- customUnits: {
- [customUnitID]: {
- rates: {
- [customUnitRateIDToClear]: null,
- },
- },
- },
- });
-}
-
-function clearPolicyDistanceRatesErrorFields(policyID: string, customUnitID: string, updatedErrorFields: ErrorFields) {
- Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
- customUnits: {
- [customUnitID]: {
- errorFields: updatedErrorFields,
- },
- },
- });
+ API.write(WRITE_COMMANDS.ENABLE_DISTANCE_REQUEST_TAX, params, onyxData);
}
-function clearDeleteDistanceRateError(policyID: string, customUnitID: string, rateID: string) {
- Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
- customUnits: {
- [customUnitID]: {
- rates: {
- [rateID]: {
- errors: null,
- },
- },
- },
- },
- });
-}
+function openPolicyMoreFeaturesPage(policyID: string) {
+ const params: OpenPolicyMoreFeaturesPageParams = {policyID};
-function clearPolicyDistanceRateErrorFields(policyID: string, customUnitID: string, rateID: string, updatedErrorFields: ErrorFields) {
- Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
- customUnits: {
- [customUnitID]: {
- rates: {
- [rateID]: {
- errorFields: updatedErrorFields,
- },
- },
- },
- },
- });
+ API.read(READ_COMMANDS.OPEN_POLICY_MORE_FEATURES_PAGE, params);
}
/**
@@ -3698,291 +2851,6 @@ function removePendingFieldsFromCustomUnit(customUnit: CustomUnit): CustomUnit {
return cleanedCustomUnit;
}
-function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit) {
- const optimisticData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- customUnits: {
- [newCustomUnit.customUnitID]: {
- ...newCustomUnit,
- pendingFields: {attributes: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
- },
- },
- },
- },
- ];
-
- const successData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- customUnits: {
- [newCustomUnit.customUnitID]: {
- pendingFields: {attributes: null},
- },
- },
- },
- },
- ];
-
- const failureData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- customUnits: {
- [currentCustomUnit.customUnitID]: {
- ...currentCustomUnit,
- errorFields: {attributes: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')},
- pendingFields: {attributes: null},
- },
- },
- },
- },
- ];
-
- const params: SetPolicyDistanceRatesUnitParams = {
- policyID,
- customUnit: JSON.stringify(removePendingFieldsFromCustomUnit(newCustomUnit)),
- };
-
- API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT, params, {optimisticData, successData, failureData});
-}
-
-/**
- * Takes array of customUnitRates and removes pendingFields and errorFields from each rate - we don't want to send those via API
- */
-function prepareCustomUnitRatesArray(customUnitRates: Rate[]): Rate[] {
- const customUnitRateArray: Rate[] = [];
- customUnitRates.forEach((rate) => {
- const cleanedRate = {...rate};
- delete cleanedRate.pendingFields;
- delete cleanedRate.errorFields;
- customUnitRateArray.push(cleanedRate);
- });
-
- return customUnitRateArray;
-}
-
-function updatePolicyDistanceRateValue(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) {
- const currentRates = customUnit.rates;
- const optimisticRates: Record = {};
- const successRates: Record = {};
- const failureRates: Record = {};
- const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID);
-
- for (const rateID of Object.keys(customUnit.rates)) {
- if (rateIDs.includes(rateID)) {
- const foundRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID);
- optimisticRates[rateID] = {...foundRate, pendingFields: {rate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}};
- successRates[rateID] = {...foundRate, pendingFields: {rate: null}};
- failureRates[rateID] = {
- ...currentRates[rateID],
- pendingFields: {rate: null},
- errorFields: {rate: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')},
- };
- }
- }
-
- const optimisticData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- customUnits: {
- [customUnit.customUnitID]: {
- rates: optimisticRates,
- },
- },
- },
- },
- ];
-
- const successData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- customUnits: {
- [customUnit.customUnitID]: {
- rates: successRates,
- },
- },
- },
- },
- ];
-
- const failureData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- customUnits: {
- [customUnit.customUnitID]: {
- rates: failureRates,
- },
- },
- },
- },
- ];
-
- const params: UpdatePolicyDistanceRateValueParams = {
- policyID,
- customUnitID: customUnit.customUnitID,
- customUnitRateArray: JSON.stringify(prepareCustomUnitRatesArray(customUnitRates)),
- };
-
- API.write(WRITE_COMMANDS.UPDATE_POLICY_DISTANCE_RATE_VALUE, params, {optimisticData, successData, failureData});
-}
-
-function setPolicyDistanceRatesEnabled(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) {
- const currentRates = customUnit.rates;
- const optimisticRates: Record = {};
- const successRates: Record = {};
- const failureRates: Record = {};
- const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID);
-
- for (const rateID of Object.keys(currentRates)) {
- if (rateIDs.includes(rateID)) {
- const foundRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID);
- optimisticRates[rateID] = {...foundRate, pendingFields: {enabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}};
- successRates[rateID] = {...foundRate, pendingFields: {enabled: null}};
- failureRates[rateID] = {
- ...currentRates[rateID],
- pendingFields: {enabled: null},
- errorFields: {enabled: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')},
- };
- }
- }
-
- const optimisticData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- customUnits: {
- [customUnit.customUnitID]: {
- rates: optimisticRates,
- },
- },
- },
- },
- ];
-
- const successData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- customUnits: {
- [customUnit.customUnitID]: {
- rates: successRates,
- },
- },
- },
- },
- ];
-
- const failureData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- customUnits: {
- [customUnit.customUnitID]: {
- rates: failureRates,
- },
- },
- },
- },
- ];
-
- const params: SetPolicyDistanceRatesEnabledParams = {
- policyID,
- customUnitID: customUnit.customUnitID,
- customUnitRateArray: JSON.stringify(prepareCustomUnitRatesArray(customUnitRates)),
- };
-
- API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_ENABLED, params, {optimisticData, successData, failureData});
-}
-
-function deletePolicyDistanceRates(policyID: string, customUnit: CustomUnit, rateIDsToDelete: string[]) {
- const currentRates = customUnit.rates;
- const optimisticRates: Record = {};
- const successRates: Record = {};
- const failureRates: Record = {};
-
- for (const rateID of Object.keys(currentRates)) {
- if (rateIDsToDelete.includes(rateID)) {
- optimisticRates[rateID] = {
- ...currentRates[rateID],
- pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
- };
- failureRates[rateID] = {
- ...currentRates[rateID],
- pendingAction: null,
- errors: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage'),
- };
- } else {
- optimisticRates[rateID] = currentRates[rateID];
- successRates[rateID] = currentRates[rateID];
- }
- }
-
- const optimisticData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- customUnits: {
- [customUnit.customUnitID]: {
- rates: optimisticRates,
- },
- },
- },
- },
- ];
-
- const successData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- customUnits: {
- [customUnit.customUnitID]: {
- rates: successRates,
- },
- },
- },
- },
- ];
-
- const failureData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- customUnits: {
- [customUnit.customUnitID]: {
- rates: failureRates,
- },
- },
- },
- },
- ];
-
- const params: DeletePolicyDistanceRatesParams = {
- policyID,
- customUnitID: customUnit.customUnitID,
- customUnitRateID: rateIDsToDelete,
- };
-
- API.write(WRITE_COMMANDS.DELETE_POLICY_DISTANCE_RATES, params, {optimisticData, successData, failureData});
-}
-
function setPolicyCustomTaxName(policyID: string, customTaxName: string) {
const policy = getPolicy(policyID);
const originalCustomTaxName = policy?.taxRates?.name;
@@ -4140,14 +3008,8 @@ function setForeignCurrencyDefault(policyID: string, taxCode: string) {
}
export {
- removeMembers,
leaveWorkspace,
- updateWorkspaceMembersRole,
- requestWorkspaceOwnerChange,
- clearWorkspaceOwnerChangeFlow,
addBillingCardAndRequestPolicyOwnerChange,
- addMembersToWorkspace,
- isAdminOfFreePolicy,
hasActiveChatEnabledPolicies,
setWorkspaceErrors,
clearCustomUnitErrors,
@@ -4156,8 +3018,6 @@ export {
updateAddress,
updateWorkspaceCustomUnitAndRate,
updateLastAccessedWorkspace,
- clearDeleteMemberError,
- clearAddMemberError,
clearDeleteWorkspaceError,
openWorkspaceReimburseView,
setPolicyIDForReimburseView,
@@ -4172,13 +3032,11 @@ export {
clearAvatarErrors,
generatePolicyID,
createWorkspace,
- openWorkspaceMembersPage,
openPolicyTaxesPage,
openWorkspaceInvitePage,
openWorkspace,
removeWorkspace,
createWorkspaceFromIOUPayment,
- setWorkspaceInviteMembersDraft,
clearErrors,
dismissAddedWithPrimaryLoginMessages,
openDraftWorkspaceRequest,
@@ -4189,24 +3047,16 @@ export {
setWorkspaceAutoReportingFrequency,
setWorkspaceAutoReportingMonthlyOffset,
updateWorkspaceDescription,
- inviteMemberToWorkspace,
- acceptJoinRequest,
- declineJoinRequest,
setWorkspacePayer,
setWorkspaceReimbursement,
openPolicyWorkflowsPage,
enablePolicyConnections,
- enablePolicyDistanceRates,
enablePolicyReportFields,
enablePolicyTaxes,
enablePolicyWorkflows,
- openPolicyDistanceRatesPage,
+ enableDistanceRequestTax,
openPolicyMoreFeaturesPage,
generateCustomUnitID,
- createPolicyDistanceRate,
- clearCreateDistanceRateItemAndError,
- clearDeleteDistanceRateError,
- setPolicyDistanceRatesUnit,
clearQBOErrorField,
clearXeroErrorField,
clearWorkspaceReimbursementErrors,
@@ -4215,16 +3065,12 @@ export {
setPolicyCustomTaxName,
clearPolicyErrorField,
isCurrencySupportedForDirectReimbursement,
- clearPolicyDistanceRatesErrorFields,
- clearPolicyDistanceRateErrorFields,
- updatePolicyDistanceRateValue,
- setPolicyDistanceRatesEnabled,
- deletePolicyDistanceRates,
getPrimaryPolicy,
createDraftWorkspace,
buildPolicyData,
navigateWhenEnableFeature,
removePendingFieldsFromCustomUnit,
+ createPolicyExpenseChats,
};
export type {NewCustomUnit};
diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts
index 7760f29d6703..85080d741011 100644
--- a/src/libs/actions/Policy/Tag.ts
+++ b/src/libs/actions/Policy/Tag.ts
@@ -1,7 +1,7 @@
import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import * as API from '@libs/API';
-import type {EnablePolicyTagsParams, OpenPolicyTagsPageParams, SetPolicyTagsEnabled} from '@libs/API/parameters';
+import type {EnablePolicyTagsParams, OpenPolicyTagsPageParams, RenamePolicyTaglistParams, RenamePolicyTagsParams, SetPolicyTagsEnabled} from '@libs/API/parameters';
import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
import * as ErrorUtils from '@libs/ErrorUtils';
import getIsNarrowLayout from '@libs/getIsNarrowLayout';
@@ -359,28 +359,30 @@ function clearPolicyTagErrors(policyID: string, tagName: string) {
});
}
-function renamePolicyTag(policyID: string, policyTag: {oldName: string; newName: string}) {
- const tagListName = Object.keys(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})[0];
+function renamePolicyTag(policyID: string, policyTag: {oldName: string; newName: string}, tagListIndex: number) {
+ const tagList = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[tagListIndex] ?? {};
+ const tag = tagList.tags?.[policyTag.oldName];
+
const oldTagName = policyTag.oldName;
const newTagName = PolicyUtils.escapeTagName(policyTag.newName);
- const oldTag = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]?.[tagListName]?.tags?.[oldTagName] ?? {};
const onyxData: OnyxData = {
optimisticData: [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
value: {
- [tagListName]: {
+ [tagList.name]: {
tags: {
[oldTagName]: null,
[newTagName]: {
- ...oldTag,
+ ...tag,
name: newTagName,
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
pendingFields: {
name: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
},
previousTagName: oldTagName,
+ errors: null,
},
},
},
@@ -392,10 +394,9 @@ function renamePolicyTag(policyID: string, policyTag: {oldName: string; newName:
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
value: {
- [tagListName]: {
+ [tagList.name]: {
tags: {
[newTagName]: {
- errors: null,
pendingAction: null,
pendingFields: {
name: null,
@@ -411,11 +412,15 @@ function renamePolicyTag(policyID: string, policyTag: {oldName: string; newName:
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
value: {
- [tagListName]: {
+ [tagList.name]: {
tags: {
[newTagName]: null,
[oldTagName]: {
- ...oldTag,
+ ...tag,
+ pendingAction: null,
+ pendingFields: {
+ name: null,
+ },
errors: ErrorUtils.getMicroSecondOnyxError('workspace.tags.genericFailureMessage'),
},
},
@@ -425,10 +430,11 @@ function renamePolicyTag(policyID: string, policyTag: {oldName: string; newName:
],
};
- const parameters = {
+ const parameters: RenamePolicyTagsParams = {
policyID,
oldName: oldTagName,
newName: newTagName,
+ tagListIndex,
};
API.write(WRITE_COMMANDS.RENAME_POLICY_TAG, parameters, onyxData);
@@ -472,6 +478,27 @@ function enablePolicyTags(policyID: string, enabled: boolean) {
},
],
};
+ const policyTagList = allPolicyTags?.[policyID];
+ if (!policyTagList) {
+ const defaultTagList: PolicyTagList = {
+ Tag: {
+ name: 'Tag',
+ orderWeight: 0,
+ required: false,
+ tags: {},
+ },
+ };
+ onyxData.optimisticData?.push({
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
+ value: defaultTagList,
+ });
+ onyxData.failureData?.push({
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
+ value: null,
+ });
+ }
const parameters: EnablePolicyTagsParams = {policyID, enabled};
@@ -482,7 +509,7 @@ function enablePolicyTags(policyID: string, enabled: boolean) {
}
}
-function renamePolicyTaglist(policyID: string, policyTagListName: {oldName: string; newName: string}, policyTags: OnyxEntry) {
+function renamePolicyTaglist(policyID: string, policyTagListName: {oldName: string; newName: string}, policyTags: OnyxEntry, tagListIndex: number) {
const newName = policyTagListName.newName;
const oldName = policyTagListName.oldName;
const oldPolicyTags = policyTags?.[oldName] ?? {};
@@ -522,10 +549,11 @@ function renamePolicyTaglist(policyID: string, policyTagListName: {oldName: stri
},
],
};
- const parameters = {
+ const parameters: RenamePolicyTaglistParams = {
policyID,
oldName,
newName,
+ tagListIndex,
};
API.write(WRITE_COMMANDS.RENAME_POLICY_TAG_LIST, parameters, onyxData);
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index 7c73ef4a1eac..7326b70edb61 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -1,6 +1,5 @@
import {format as timezoneFormat, utcToZonedTime} from 'date-fns-tz';
-import ExpensiMark from 'expensify-common/lib/ExpensiMark';
-import Str from 'expensify-common/lib/str';
+import {ExpensiMark, Str} from 'expensify-common';
import isEmpty from 'lodash/isEmpty';
import {DeviceEventEmitter, InteractionManager, Linking} from 'react-native';
import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
@@ -164,14 +163,6 @@ Onyx.connect({
},
});
-let guideCalendarLink: string | undefined;
-Onyx.connect({
- key: ONYXKEYS.ACCOUNT,
- callback: (value) => {
- guideCalendarLink = value?.guideCalendarLink ?? undefined;
- },
-});
-
let preferredSkinTone: number = CONST.EMOJI_DEFAULT_SKIN_TONE;
Onyx.connect({
key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
@@ -1158,8 +1149,11 @@ function expandURLPreview(reportID: string, reportActionID: string) {
API.read(READ_COMMANDS.EXPAND_URL_PREVIEW, parameters);
}
-/** Marks the new report actions as read */
-function readNewestAction(reportID: string) {
+/** Marks the new report actions as read
+ * @param shouldResetUnreadMarker Indicates whether the unread indicator should be reset.
+ * Currently, the unread indicator needs to be reset only when users mark a report as read.
+ */
+function readNewestAction(reportID: string, shouldResetUnreadMarker = false) {
const lastReadTime = DateUtils.getDBTime();
const optimisticData: OnyxUpdate[] = [
@@ -1178,7 +1172,9 @@ function readNewestAction(reportID: string) {
};
API.write(WRITE_COMMANDS.READ_NEWEST_ACTION, parameters, {optimisticData});
- DeviceEventEmitter.emit(`readNewestAction_${reportID}`, lastReadTime);
+ if (shouldResetUnreadMarker) {
+ DeviceEventEmitter.emit(`readNewestAction_${reportID}`, lastReadTime);
+ }
}
/**
@@ -2731,12 +2727,17 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: InvitedEmails
...newPersonalDetailsOnyxData.optimisticData,
];
+ const successPendingChatMembers = report?.pendingChatMembers
+ ? report?.pendingChatMembers?.filter(
+ (pendingMember) => !(inviteeAccountIDs.includes(Number(pendingMember.accountID)) && pendingMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE),
+ )
+ : null;
const successData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
value: {
- pendingChatMembers: report?.pendingChatMembers ?? null,
+ pendingChatMembers: successPendingChatMembers,
},
},
...newPersonalDetailsOnyxData.finallyData,
@@ -3154,7 +3155,6 @@ function completeOnboarding(
typeof task.description === 'function'
? task.description({
adminsRoomLink: `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}${ROUTES.REPORT_WITH_ID.getRoute(adminsChatReportID ?? '')}`,
- guideCalendarLink: guideCalendarLink ?? CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL,
})
: task.description;
const currentTask = ReportUtils.buildOptimisticTaskReport(
diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts
index 43f3e2697e8a..bf94de9c52b2 100644
--- a/src/libs/actions/Search.ts
+++ b/src/libs/actions/Search.ts
@@ -4,6 +4,16 @@ import * as API from '@libs/API';
import type {SearchParams} from '@libs/API/parameters';
import {READ_COMMANDS} from '@libs/API/types';
import ONYXKEYS from '@src/ONYXKEYS';
+import type {SearchTransaction} from '@src/types/onyx/SearchResults';
+import * as Report from './Report';
+
+let currentUserEmail: string;
+Onyx.connect({
+ key: ONYXKEYS.SESSION,
+ callback: (val) => {
+ currentUserEmail = val?.email ?? '';
+ },
+});
function search({hash, query, policyIDs, offset, sortBy, sortOrder}: SearchParams) {
const optimisticData: OnyxUpdate[] = [
@@ -32,7 +42,23 @@ function search({hash, query, policyIDs, offset, sortBy, sortOrder}: SearchParam
API.read(READ_COMMANDS.SEARCH, {hash, query, offset, policyIDs, sortBy, sortOrder}, {optimisticData, finallyData});
}
-export {
- // eslint-disable-next-line import/prefer-default-export
- search,
-};
+
+/**
+ * It's possible that we return legacy transactions that don't have a transaction thread created yet.
+ * In that case, when users select the search result row, we need to create the transaction thread on the fly and update the search result with the new transactionThreadReport
+ */
+function createTransactionThread(hash: number, transactionID: string, reportID: string, moneyRequestReportActionID: string) {
+ Report.openReport(reportID, '', [currentUserEmail], {}, moneyRequestReportActionID);
+
+ const onyxUpdate: Record>> = {
+ data: {
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: {
+ transactionThreadReportID: reportID,
+ },
+ },
+ };
+
+ Onyx.merge(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, onyxUpdate);
+}
+
+export {search, createTransactionThread};
diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts
index 303517558206..558f4c9e027a 100644
--- a/src/libs/actions/Session/index.ts
+++ b/src/libs/actions/Session/index.ts
@@ -16,6 +16,7 @@ import type {
RequestNewValidateCodeParams,
RequestUnlinkValidationLinkParams,
SignInUserWithLinkParams,
+ SignUpUserParams,
UnlinkLoginParams,
ValidateTwoFactorAuthParams,
} from '@libs/API/parameters';
@@ -187,7 +188,7 @@ function isAnonymousUser(sessionParam?: OnyxEntry): boolean {
}
function hasStashedSession(): boolean {
- return Boolean(stashedSession.authToken && stashedCredentials.autoGeneratedLogin && stashedCredentials.autoGeneratedLogin !== '');
+ return !!(stashedSession.authToken && stashedCredentials.autoGeneratedLogin && stashedCredentials.autoGeneratedLogin !== '');
}
/**
@@ -405,11 +406,52 @@ function signInAttemptState(): OnyxData {
function beginSignIn(email: string) {
const {optimisticData, successData, failureData} = signInAttemptState();
- const params: BeginSignInParams = {email};
+ const params: BeginSignInParams = {email, useNewBeginSignIn: true};
API.read(READ_COMMANDS.BEGIN_SIGNIN, params, {optimisticData, successData, failureData});
}
+/**
+ * Creates an account for the new user and signs them into the application with the newly created account.
+ *
+ */
+function signUpUser() {
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ ...CONST.DEFAULT_ACCOUNT_DATA,
+ isLoading: true,
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ isLoading: false,
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ isLoading: false,
+ },
+ },
+ ];
+
+ const params: SignUpUserParams = {email: credentials.login, preferredLocale};
+
+ API.write(WRITE_COMMANDS.SIGN_UP_USER, params, {optimisticData, successData, failureData});
+}
+
/**
* Given an idToken from Sign in with Apple, checks the API to see if an account
* exists for that email address and signs the user in if so.
@@ -1004,4 +1046,5 @@ export {
signInWithSupportAuthToken,
isSupportAuthToken,
hasStashedSession,
+ signUpUser,
};
diff --git a/src/libs/actions/Subscription.ts b/src/libs/actions/Subscription.ts
new file mode 100644
index 000000000000..c7732575aaa6
--- /dev/null
+++ b/src/libs/actions/Subscription.ts
@@ -0,0 +1,14 @@
+import * as API from '@libs/API';
+import {READ_COMMANDS} from '@libs/API/types';
+
+/**
+ * Fetches data when the user opens the SubscriptionSettingsPage
+ */
+function openSubscriptionPage() {
+ API.read(READ_COMMANDS.OPEN_SUBSCRIPTION_PAGE, null);
+}
+
+export {
+ // eslint-disable-next-line import/prefer-default-export
+ openSubscriptionPage,
+};
diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts
index f7e90f775b65..8c9d4391bb46 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -22,6 +22,7 @@ import type {
ValidateSecondaryLoginParams,
} from '@libs/API/parameters';
import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
+import DateUtils from '@libs/DateUtils';
import * as ErrorUtils from '@libs/ErrorUtils';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
@@ -399,6 +400,7 @@ function validateSecondaryLogin(contactMethod: string, validateCode: string) {
key: ONYXKEYS.LOGIN_LIST,
value: {
[contactMethod]: {
+ validatedDate: DateUtils.getDBTime(),
pendingFields: {
validateLogin: null,
},
@@ -413,6 +415,13 @@ function validateSecondaryLogin(contactMethod: string, validateCode: string) {
key: ONYXKEYS.ACCOUNT,
value: {isLoading: false},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.USER,
+ value: {
+ validated: true,
+ },
+ },
];
const failureData: OnyxUpdate[] = [
diff --git a/src/libs/actions/Wallet.ts b/src/libs/actions/Wallet.ts
index 045cc34f39ef..3dd3f01c0703 100644
--- a/src/libs/actions/Wallet.ts
+++ b/src/libs/actions/Wallet.ts
@@ -61,10 +61,6 @@ function setAdditionalDetailsErrors(errorFields: OnyxCommon.ErrorFields) {
Onyx.merge(ONYXKEYS.WALLET_ADDITIONAL_DETAILS, {errorFields});
}
-function setAdditionalDetailsErrorMessage(additionalErrorMessage: string) {
- Onyx.merge(ONYXKEYS.WALLET_ADDITIONAL_DETAILS, {additionalErrorMessage});
-}
-
/**
* Save the source that triggered the KYC wall and optionally the chat report ID associated with the IOU
*/
@@ -304,7 +300,6 @@ export {
openInitialSettingsPage,
openEnablePaymentsPage,
setAdditionalDetailsErrors,
- setAdditionalDetailsErrorMessage,
setAdditionalDetailsQuestions,
updateCurrentStep,
answerQuestionsForWallet,
diff --git a/src/libs/actions/connections/index.ts b/src/libs/actions/connections/index.ts
index 185cd089d1e9..45a4e4f6819c 100644
--- a/src/libs/actions/connections/index.ts
+++ b/src/libs/actions/connections/index.ts
@@ -137,6 +137,7 @@ function syncConnection(policyID: string, connectionName: PolicyConnectionName |
value: {
stageInProgress: isQBOConnection ? CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.STARTING_IMPORT_QBO : CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.STARTING_IMPORT_XERO,
connectionName,
+ timestamp: new Date().toISOString(),
},
},
];
diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts
index 6517a7a28642..f058ce0f80d8 100644
--- a/src/libs/fileDownload/FileUtils.ts
+++ b/src/libs/fileDownload/FileUtils.ts
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import {Alert, Linking, Platform} from 'react-native';
import ImageSize from 'react-native-image-size';
import type {FileObject} from '@components/AttachmentModal';
diff --git a/src/libs/isReportMessageAttachment.ts b/src/libs/isReportMessageAttachment.ts
index 330ba4470097..9ef4125ee8bf 100644
--- a/src/libs/isReportMessageAttachment.ts
+++ b/src/libs/isReportMessageAttachment.ts
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import CONST from '@src/CONST';
import type {Message} from '@src/types/onyx/ReportAction';
diff --git a/src/libs/models/BankAccount.ts b/src/libs/models/BankAccount.ts
index 611d77c99927..a86a30b57e86 100644
--- a/src/libs/models/BankAccount.ts
+++ b/src/libs/models/BankAccount.ts
@@ -1,7 +1,7 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import type {ValueOf} from 'type-fest';
import CONST from '@src/CONST';
-import type {AdditionalData} from '@src/types/onyx/BankAccount';
+import type {BankAccountAdditionalData} from '@src/types/onyx/BankAccount';
import type BankAccountJSON from '@src/types/onyx/BankAccount';
type State = ValueOf;
@@ -153,7 +153,7 @@ class BankAccount {
* Return whether this bank account has been risk checked
*/
isRiskChecked() {
- return Boolean(this.json.accountData?.riskChecked);
+ return !!this.json.accountData?.riskChecked;
}
/**
@@ -194,7 +194,7 @@ class BankAccount {
/**
* Get the additional data of a bankAccount
*/
- getAdditionalData(): Partial {
+ getAdditionalData(): Partial {
return this.json.accountData?.additionalData ?? {};
}
diff --git a/src/pages/AddPersonalBankAccountPage.tsx b/src/pages/AddPersonalBankAccountPage.tsx
index 5cd0f3ef8026..ac02cd26879b 100644
--- a/src/pages/AddPersonalBankAccountPage.tsx
+++ b/src/pages/AddPersonalBankAccountPage.tsx
@@ -80,7 +80,7 @@ function AddPersonalBankAccountPage({personalBankAccount, plaidData}: AddPersona
) : (
;
-
- /** Session info for the currently logged in user. */
- session: OnyxEntry;
-};
-
-type DetailsPageProps = DetailsPageOnyxProps & StackScreenProps;
-
-/**
- * Gets the phone number to display for SMS logins
- */
-const getPhoneNumber = ({login = '', displayName = ''}: PersonalDetails): string | undefined => {
- // If the user hasn't set a displayName, it is set to their phone number, so use that
- const parsedPhoneNumber = parsePhoneNumber(displayName);
- if (parsedPhoneNumber.possible) {
- return parsedPhoneNumber?.number?.e164;
- }
-
- // If the user has set a displayName, get the phone number from the SMS login
- return login ? Str.removeSMSDomain(login) : '';
-};
-
-function DetailsPage({personalDetails, route, session}: DetailsPageProps) {
- const styles = useThemeStyles();
- const {translate, formatPhoneNumber} = useLocalize();
- const login = route.params?.login ?? '';
- const sessionAccountID = session?.accountID ?? 0;
-
- let details = Object.values(personalDetails ?? {}).find((personalDetail) => personalDetail?.login === login.toLowerCase());
-
- if (!details) {
- const optimisticAccountID = UserUtils.generateAccountID(login);
- details = {
- accountID: optimisticAccountID,
- login,
- displayName: login,
- };
- }
-
- const isSMSLogin = details.login ? Str.isSMSLogin(details.login) : false;
-
- const shouldShowLocalTime = !ReportUtils.hasAutomatedExpensifyAccountIDs([details.accountID]) && details.timezone;
- let pronouns = details.pronouns;
-
- if (pronouns && pronouns.startsWith(CONST.PRONOUNS.PREFIX)) {
- const localeKey = pronouns.replace(CONST.PRONOUNS.PREFIX, '');
- pronouns = translate(`pronouns.${localeKey}` as TranslationPaths);
- }
-
- const phoneNumber = getPhoneNumber(details);
- const phoneOrEmail = isSMSLogin ? getPhoneNumber(details) : details.login;
- const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(details, '', false);
-
- const isCurrentUser = sessionAccountID === details.accountID;
-
- return (
-
-
-
-
- {details ? (
-
-
-
- {({show}) => (
-
-
-
-
-
- )}
-
- {Boolean(displayName) && (
-
- {displayName}
-
- )}
- {details.login ? (
-
-
- {translate(isSMSLogin ? 'common.phoneNumber' : 'common.email')}
-
-
-
- {isSMSLogin ? formatPhoneNumber(phoneNumber ?? '') : details.login}
-
-
-
- ) : null}
- {pronouns ? (
-
-
- {translate('profilePage.preferredPronouns')}
-
- {pronouns}
-
- ) : null}
- {shouldShowLocalTime && }
-
- {!isCurrentUser && (
-
- ) : null}
-
-
-
- );
-}
-
-DetailsPage.displayName = 'DetailsPage';
-
-export default withOnyx({
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
- session: {
- key: ONYXKEYS.SESSION,
- },
-})(DetailsPage);
diff --git a/src/pages/EditReportFieldPage.tsx b/src/pages/EditReportFieldPage.tsx
index 53b8043c9162..33502cc357b4 100644
--- a/src/pages/EditReportFieldPage.tsx
+++ b/src/pages/EditReportFieldPage.tsx
@@ -1,4 +1,4 @@
-import Str from 'expensify-common/lib/str';
+import {Str} from 'expensify-common';
import React, {useState} from 'react';
import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
@@ -123,6 +123,7 @@ function EditReportFieldPage({route, policy, report}: EditReportFieldPageProps)
confirmText={translate('common.delete')}
cancelText={translate('common.cancel')}
danger
+ shouldEnableNewFocusManagement
/>
{(reportField.type === 'text' || isReportFieldTitle) && (
diff --git a/src/pages/EnablePayments/FeesAndTerms/substeps/TermsStep.tsx b/src/pages/EnablePayments/FeesAndTerms/substeps/TermsStep.tsx
index fe17ea7e1afb..1dde89a2988a 100644
--- a/src/pages/EnablePayments/FeesAndTerms/substeps/TermsStep.tsx
+++ b/src/pages/EnablePayments/FeesAndTerms/substeps/TermsStep.tsx
@@ -101,7 +101,7 @@ function TermsStep() {
Navigation.navigate(ROUTES.SETTINGS_WALLET);
}}
message={errorMessage}
- isAlertVisible={error || Boolean(errorMessage)}
+ isAlertVisible={error || !!errorMessage}
isLoading={!!walletTerms?.isLoading}
containerStyles={[styles.mh0, styles.mv5]}
/>
diff --git a/src/pages/EnablePayments/OnfidoPrivacy.tsx b/src/pages/EnablePayments/OnfidoPrivacy.tsx
index cf6e6837df16..ace91f315e32 100644
--- a/src/pages/EnablePayments/OnfidoPrivacy.tsx
+++ b/src/pages/EnablePayments/OnfidoPrivacy.tsx
@@ -72,7 +72,7 @@ function OnfidoPrivacy({walletOnfidoData = DEFAULT_WALLET_ONFIDO_DATA}: OnfidoPr
{
formRef.current?.scrollTo({y: 0, animated: true});
diff --git a/src/pages/EnablePayments/TermsPage/LongTermsForm.tsx b/src/pages/EnablePayments/TermsPage/LongTermsForm.tsx
index e1b6a0ff3365..760bd0c03b88 100644
--- a/src/pages/EnablePayments/TermsPage/LongTermsForm.tsx
+++ b/src/pages/EnablePayments/TermsPage/LongTermsForm.tsx
@@ -71,11 +71,11 @@ function LongTermsForm() {
{section.title}
- {Boolean(section.subTitle) && {section.subTitle}}
+ {!!section.subTitle && {section.subTitle}}
{section.rightText}
- {Boolean(section.subRightText) && {section.subRightText}}
+ {!!section.subRightText && {section.subRightText}}
{section.details}
diff --git a/src/pages/EnablePayments/TermsStep.tsx b/src/pages/EnablePayments/TermsStep.tsx
index 916a5200a2e0..47b941108c43 100644
--- a/src/pages/EnablePayments/TermsStep.tsx
+++ b/src/pages/EnablePayments/TermsStep.tsx
@@ -113,7 +113,7 @@ function TermsStep(props: TermsStepProps) {
});
}}
message={errorMessage}
- isAlertVisible={error || Boolean(errorMessage)}
+ isAlertVisible={error || !!errorMessage}
isLoading={!!props.walletTerms?.isLoading}
containerStyles={[styles.mh0, styles.mv4]}
/>
diff --git a/src/pages/FlagCommentPage.tsx b/src/pages/FlagCommentPage.tsx
index 53aac0ce2a8b..98fe3973ff9d 100644
--- a/src/pages/FlagCommentPage.tsx
+++ b/src/pages/FlagCommentPage.tsx
@@ -19,7 +19,6 @@ import * as ReportUtils from '@libs/ReportUtils';
import * as Report from '@userActions/Report';
import * as Session from '@userActions/Session';
import CONST from '@src/CONST';
-import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import type * as OnyxTypes from '@src/types/onyx';
import withReportAndReportActionOrNotFound from './home/report/withReportAndReportActionOrNotFound';
@@ -160,14 +159,7 @@ function FlagCommentPage({parentReportAction, route, report, parentReport, repor
>
{({safeAreaPaddingBottomStyle}) => (
- {
- Navigation.goBack();
- Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report?.reportID ?? ''));
- }}
- />
+ (null);
// Any existing participants and Expensify emails should not be eligible for invitation
- const excludedUsers = useMemo(() => [...PersonalDetailsUtils.getLoginsByAccountIDs(ReportUtils.getParticipantAccountIDs(report?.reportID ?? '')), ...CONST.EXPENSIFY_EMAILS], [report]);
+ const excludedUsers = useMemo(
+ () => [...PersonalDetailsUtils.getLoginsByAccountIDs(ReportUtils.getParticipantAccountIDs(report?.reportID ?? '', true)), ...CONST.EXPENSIFY_EMAILS],
+ [report],
+ );
useEffect(() => {
const inviteOptions = OptionsListUtils.getMemberInviteOptions(options.personalDetails, betas ?? [], searchTerm, excludedUsers, false, options.reports, true);
@@ -184,13 +186,28 @@ function InviteReportParticipantsPage({betas, personalDetails, report, didScreen
) {
return translate('messages.userIsAlreadyMember', {login: searchValue, name: reportName ?? ''});
}
- return OptionsListUtils.getHeaderMessage(invitePersonalDetails.length !== 0, Boolean(userToInvite), searchValue);
+ return OptionsListUtils.getHeaderMessage(invitePersonalDetails.length !== 0, !!userToInvite, searchValue);
}, [searchTerm, userToInvite, excludedUsers, invitePersonalDetails, translate, reportName]);
+ const footerContent = useMemo(
+ () => (
+
+ ),
+ [selectedOptions.length, inviteUsers, translate, styles],
+ );
+
return (
-
-
-
-
-
-
+
+
);
}
diff --git a/src/pages/NewChatConfirmPage.tsx b/src/pages/NewChatConfirmPage.tsx
index 8152a61d31e9..2c94dbbc7840 100644
--- a/src/pages/NewChatConfirmPage.tsx
+++ b/src/pages/NewChatConfirmPage.tsx
@@ -1,4 +1,4 @@
-import React, {useMemo, useRef} from 'react';
+import React, {useCallback, useMemo, useRef} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
@@ -35,6 +35,14 @@ type NewChatConfirmPageOnyxProps = {
type NewChatConfirmPageProps = NewChatConfirmPageOnyxProps;
+function navigateBack() {
+ Navigation.goBack(ROUTES.NEW_CHAT);
+}
+
+function navigateToEditChatName() {
+ Navigation.navigate(ROUTES.NEW_CHAT_EDIT_NAME);
+}
+
function NewChatConfirmPage({newGroupDraft, allPersonalDetails}: NewChatConfirmPageProps) {
const optimisticReportID = useRef(ReportUtils.generateReportID());
const fileRef = useRef();
@@ -79,30 +87,25 @@ function NewChatConfirmPage({newGroupDraft, allPersonalDetails}: NewChatConfirmP
/**
* Removes a selected option from list if already selected.
*/
- const unselectOption = (option: ListItem) => {
- if (!newGroupDraft) {
- return;
- }
- const newSelectedParticipants = (newGroupDraft.participants ?? []).filter((participant) => participant.login !== option.login);
- Report.setGroupDraft({participants: newSelectedParticipants});
- };
+ const unselectOption = useCallback(
+ (option: ListItem) => {
+ if (!newGroupDraft) {
+ return;
+ }
+ const newSelectedParticipants = (newGroupDraft.participants ?? []).filter((participant) => participant.login !== option.login);
+ Report.setGroupDraft({participants: newSelectedParticipants});
+ },
+ [newGroupDraft],
+ );
- const createGroup = () => {
+ const createGroup = useCallback(() => {
if (!newGroupDraft) {
return;
}
const logins: string[] = (newGroupDraft.participants ?? []).map((participant) => participant.login);
Report.navigateToAndOpenReport(logins, true, newGroupDraft.reportName ?? '', newGroupDraft.avatarUri ?? '', fileRef.current, optimisticReportID.current, true);
- };
-
- const navigateBack = () => {
- Navigation.goBack(ROUTES.NEW_CHAT);
- };
-
- const navigateToEditChatName = () => {
- Navigation.navigate(ROUTES.NEW_CHAT_EDIT_NAME);
- };
+ }, [newGroupDraft]);
const stashedLocalAvatarImage = newGroupDraft?.avatarUri;
return (
@@ -146,7 +149,7 @@ function NewChatConfirmPage({newGroupDraft, allPersonalDetails}: NewChatConfirmP
sections={[{title: translate('common.members'), data: sections}]}
ListItem={InviteMemberListItem}
onSelectRow={unselectOption}
- showConfirmButton={Boolean(selectedOptions.length)}
+ showConfirmButton={!!selectedOptions.length}
confirmButtonText={translate('newChatPage.startGroup')}
onConfirm={createGroup}
shouldHideListOnInitialRender={false}
diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx
index b2008ecc2cca..2910ccadd7ee 100755
--- a/src/pages/NewChatPage.tsx
+++ b/src/pages/NewChatPage.tsx
@@ -1,6 +1,6 @@
import isEmpty from 'lodash/isEmpty';
import reject from 'lodash/reject';
-import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
import KeyboardAvoidingView from '@components/KeyboardAvoidingView';
@@ -11,7 +11,7 @@ import ReferralProgramCTA from '@components/ReferralProgramCTA';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectCircle from '@components/SelectCircle';
import SelectionList from '@components/SelectionList';
-import type {ListItem} from '@components/SelectionList/types';
+import type {ListItem, SelectionListHandle} from '@components/SelectionList/types';
import UserListItem from '@components/SelectionList/UserListItem';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useDebouncedState from '@hooks/useDebouncedState';
@@ -75,7 +75,7 @@ function useOptions({isGroupChat}: NewChatPageProps) {
const headerMessage = OptionsListUtils.getHeaderMessage(
filteredOptions.personalDetails.length + filteredOptions.recentReports.length !== 0,
- Boolean(filteredOptions.userToInvite),
+ !!filteredOptions.userToInvite,
debouncedSearchTerm.trim(),
selectedOptions.some((participant) => participant?.searchText?.toLowerCase?.().includes(debouncedSearchTerm.trim().toLowerCase())),
);
@@ -128,6 +128,7 @@ function NewChatPage({isGroupChat}: NewChatPageProps) {
const personalData = useCurrentUserPersonalDetails();
const {insets} = useStyledSafeAreaInsets();
const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false});
+ const selectionListRef = useRef(null);
const {headerMessage, searchTerm, debouncedSearchTerm, setSearchTerm, selectedOptions, setSelectedOptions, recentReports, personalDetails, userToInvite, areOptionsInitialized} =
useOptions({
@@ -223,6 +224,8 @@ function NewChatPage({isGroupChat}: NewChatPageProps) {
newSelectedOptions = [...selectedOptions, {...option, isSelected: true, selected: true, reportID: option.reportID ?? ''}];
}
+ selectionListRef?.current?.clearInputAfterSelect?.();
+
setSelectedOptions(newSelectedOptions);
}
@@ -301,6 +304,7 @@ function NewChatPage({isGroupChat}: NewChatPageProps) {
keyboardVerticalOffset={variables.contentHeaderHeight + (insets?.top ?? 0) + variables.tabSelectorButtonHeight + variables.tabSelectorButtonPadding}
>