diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index c1238d6805aa..459a780ca8b4 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -51,7 +51,10 @@ For example:
1. Click on the text input to bring it into focus
2. Upload an image via copy paste
3. Verify a modal appears displaying a preview of that image
+
+It's acceptable to write "Same as tests" if the QA team is able to run the tests in the above "Tests" section.
--->
+// TODO: These must be filled out, or the issue title must include "[No QA]."
- [ ] Verify that no errors appear in the JS console
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 59f41bd12526..d578621930a7 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -182,6 +182,7 @@ jobs:
submodules: true
path: 'Mobile-Expensify'
token: ${{ secrets.OS_BOTIFY_TOKEN }}
+ # fetch-depth: 0 is required in order to fetch the correct submodule branch
fetch-depth: 0
- name: Update submodule
@@ -276,6 +277,13 @@ jobs:
name: android-hybrid-build-artifact
path: ${{ env.aabPath }}
+ - name: Upload Android sourcemap artifact
+ if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: android-hybrid-sourcemap-artifact
+ path: /home/runner/work/App/App/Mobile-Expensify/Android/build/generated/sourcemaps/react/release/index.android.bundle.map
+
- name: Set current App version in Env
run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV"
@@ -482,6 +490,7 @@ jobs:
submodules: true
path: 'Mobile-Expensify'
token: ${{ secrets.OS_BOTIFY_TOKEN }}
+ # fetch-depth: 0 is required in order to fetch the correct submodule branch
fetch-depth: 0
- name: Update submodule
@@ -597,6 +606,13 @@ jobs:
name: ios-hybrid-build-artifact
path: ${{ env.ipaPath }}
+ - name: Upload iOS sourcemap artifact
+ if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: ios-hybrid-sourcemap-artifact
+ path: /Users/runner/work/App/App/Mobile-Expensify/main.jsbundle.map
+
- name: Warn deployers if iOS production deploy failed
if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
uses: 8398a7/action-slack@v3
@@ -802,19 +818,25 @@ jobs:
"./android-sourcemaps-artifact/index.android.bundle.map#android-sourcemap.js.map"
"./android-aab-artifact/app-production-release.aab#android.aab"
"./android-hybrid-build-artifact/Expensify-release.aab#android-hybrid.aab"
+ "./android-hybrid-sourcemap-artifact/index.android.bundle.map#android-hybrid-sourcemap.js.map"
"./desktop-staging-sourcemaps-artifact/desktop-staging-merged-source-map.js.map#desktop-staging-sourcemap.js.map"
"./desktop-staging-build-artifact/NewExpensify.dmg#desktop-staging.dmg"
"./ios-sourcemaps-artifact/main.jsbundle.map#ios-sourcemap.js.map"
"./ios-build-artifact/New Expensify.ipa#ios.ipa"
"./ios-hybrid-build-artifact/Expensify.ipa#ios-hybrid.ipa"
- "./web-staging-sourcemaps-artifact/web-staging-sourcemap.js.map#web-staging-sourcemap.js.map"
+ "./ios-hybrid-sourcemap-artifact/main.jsbundle.map#ios-hybrid-sourcemap.js.map"
+ "./web-staging-sourcemaps-artifact/web-staging-merged-source-map.js.map#web-staging-sourcemap.js.map"
"./web-staging-build-tar-gz-artifact/webBuild.tar.gz#web-staging.tar.gz"
"./web-staging-build-zip-artifact/webBuild.zip#web-staging.zip"
)
-
+
# Loop through each file and upload individually (so if one fails, we still have other platforms uploaded)
for file_entry in "${files[@]}"; do
- gh release upload ${{ needs.prep.outputs.APP_VERSION }} --repo ${{ github.repository }} --clobber "$file_entry"
+ gh release upload ${{ needs.prep.outputs.APP_VERSION }} --repo ${{ github.repository }} --clobber "$file_entry" || {
+ echo "Failed to upload $file_entry. Continuing with the next file."
+ continue
+ }
+ echo "Successfully uploaded $file_entry."
done
env:
GITHUB_TOKEN: ${{ github.token }}
@@ -870,10 +892,14 @@ jobs:
"./web-build-tar-gz-artifact/webBuild.tar.gz#web-production.tar.gz"
"./web-build-zip-artifact/webBuild.zip#web-production.zip"
)
-
+
# Loop through each file and upload individually (so if one fails, we still have other platforms uploaded)
for file_entry in "${files[@]}"; do
- gh release upload ${{ needs.prep.outputs.APP_VERSION }} --repo ${{ github.repository }} --clobber "$file_entry"
+ gh release upload ${{ needs.prep.outputs.APP_VERSION }} --repo ${{ github.repository }} --clobber "$file_entry" || {
+ echo "Failed to upload $file_entry. Continuing with the next file."
+ continue
+ }
+ echo "Successfully uploaded $file_entry."
done
env:
GITHUB_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/deployNewHelp.yml b/.github/workflows/deployNewHelp.yml
index 2d2f551482d2..8e455979a50e 100644
--- a/.github/workflows/deployNewHelp.yml
+++ b/.github/workflows/deployNewHelp.yml
@@ -53,7 +53,7 @@ jobs:
# Install Node for _scripts/*.js
- name: Set up Node.js
- uses: actions/setup-node@v3
+ uses: actions/setup-node@v4
with:
node-version: '20.18.0'
diff --git a/Gemfile.lock b/Gemfile.lock
index 35920fc3e988..71f4fd64bc0c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -20,20 +20,20 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
- aws-partitions (1.979.0)
- aws-sdk-core (3.209.1)
+ aws-partitions (1.1001.0)
+ aws-sdk-core (3.211.0)
aws-eventstream (~> 1, >= 1.3.0)
- aws-partitions (~> 1, >= 1.651.0)
+ aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
- aws-sdk-kms (1.94.0)
- aws-sdk-core (~> 3, >= 3.207.0)
+ aws-sdk-kms (1.95.0)
+ aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
- aws-sdk-s3 (1.166.0)
- aws-sdk-core (~> 3, >= 3.207.0)
+ aws-sdk-s3 (1.169.0)
+ aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
- aws-sigv4 (1.10.0)
+ aws-sigv4 (1.10.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
@@ -89,7 +89,7 @@ GEM
escape (0.0.4)
ethon (0.16.0)
ffi (>= 1.15.0)
- excon (0.111.0)
+ excon (0.112.0)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
@@ -119,7 +119,7 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.3.1)
- fastlane (2.222.0)
+ fastlane (2.225.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
@@ -135,6 +135,7 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
+ fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
@@ -164,6 +165,8 @@ GEM
apktools (~> 0.7)
aws-sdk-s3 (~> 1)
mime-types (~> 3.3)
+ fastlane-sirp (1.0.0)
+ sysrandom (~> 1.0)
ffi (1.17.0)
ffi (1.17.0-arm64-darwin)
ffi (1.17.0-x86_64-darwin)
@@ -214,8 +217,8 @@ GEM
i18n (1.14.5)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
- json (2.7.2)
- jwt (2.9.1)
+ json (2.7.6)
+ jwt (2.9.3)
base64
mime-types (3.5.1)
mime-types-data (~> 3.2015)
@@ -226,7 +229,7 @@ GEM
molinillo (0.8.0)
multi_json (1.15.0)
multipart-post (2.4.1)
- nanaimo (0.3.0)
+ nanaimo (0.4.0)
nap (1.1.0)
naturally (2.2.1)
netrc (0.11.0)
@@ -241,7 +244,7 @@ GEM
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
- rexml (3.3.7)
+ rexml (3.3.9)
rouge (2.0.7)
ruby-macho (2.5.1)
ruby2_keywords (0.0.5)
@@ -255,6 +258,7 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
+ sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@@ -270,13 +274,13 @@ GEM
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
- xcodeproj (1.25.0)
+ xcodeproj (1.27.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
- nanaimo (~> 0.3.0)
- rexml (>= 3.3.2, < 4.0)
+ nanaimo (~> 0.4.0)
+ rexml (>= 3.3.6, < 4.0)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)
diff --git a/android/app/build.gradle b/android/app/build.gradle
index e0876839e4bd..dc48f3137f27 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -110,8 +110,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1009005607
- versionName "9.0.56-7"
+ versionCode 1009005801
+ versionName "9.0.58-1"
// 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/binoculars.svg b/assets/images/binoculars.svg
new file mode 100644
index 000000000000..64977dee38b5
--- /dev/null
+++ b/assets/images/binoculars.svg
@@ -0,0 +1,25 @@
+
+
\ No newline at end of file
diff --git a/desktop/package-lock.json b/desktop/package-lock.json
index 75cb080f1349..926fb1e24d22 100644
--- a/desktop/package-lock.json
+++ b/desktop/package-lock.json
@@ -9,7 +9,7 @@
"dependencies": {
"electron-context-menu": "^2.3.0",
"electron-log": "^4.4.8",
- "electron-updater": "^6.3.8",
+ "electron-updater": "^6.3.9",
"mime-types": "^2.1.35",
"node-machine-id": "^1.1.12"
},
@@ -59,9 +59,9 @@
}
},
"node_modules/builder-util-runtime": {
- "version": "9.2.9",
- "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.9.tgz",
- "integrity": "sha512-DWeHdrRFVvNnVyD4+vMztRpXegOGaQHodsAjyhstTbUNBIjebxM1ahxokQL+T1v8vpW8SY7aJ5is/zILH82lAw==",
+ "version": "9.2.10",
+ "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz",
+ "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4",
@@ -156,12 +156,12 @@
"integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA=="
},
"node_modules/electron-updater": {
- "version": "6.3.8",
- "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.8.tgz",
- "integrity": "sha512-OFsA2vyuOZgsqq4EW6tgW8X8e521ybDmQyIYLqss7HdXev+Ak90YatzpIECOBJXpmro5YDq4yZ2xFsKXqPt1DQ==",
+ "version": "6.3.9",
+ "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.9.tgz",
+ "integrity": "sha512-2PJNONi+iBidkoC5D1nzT9XqsE8Q1X28Fn6xRQhO3YX8qRRyJ3mkV4F1aQsuRnYPqq6Hw+E51y27W75WgDoofw==",
"license": "MIT",
"dependencies": {
- "builder-util-runtime": "9.2.9",
+ "builder-util-runtime": "9.2.10",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"lazy-val": "^1.0.5",
@@ -469,9 +469,9 @@
"integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="
},
"builder-util-runtime": {
- "version": "9.2.9",
- "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.9.tgz",
- "integrity": "sha512-DWeHdrRFVvNnVyD4+vMztRpXegOGaQHodsAjyhstTbUNBIjebxM1ahxokQL+T1v8vpW8SY7aJ5is/zILH82lAw==",
+ "version": "9.2.10",
+ "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz",
+ "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==",
"requires": {
"debug": "^4.3.4",
"sax": "^1.2.4"
@@ -538,11 +538,11 @@
"integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA=="
},
"electron-updater": {
- "version": "6.3.8",
- "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.8.tgz",
- "integrity": "sha512-OFsA2vyuOZgsqq4EW6tgW8X8e521ybDmQyIYLqss7HdXev+Ak90YatzpIECOBJXpmro5YDq4yZ2xFsKXqPt1DQ==",
+ "version": "6.3.9",
+ "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.9.tgz",
+ "integrity": "sha512-2PJNONi+iBidkoC5D1nzT9XqsE8Q1X28Fn6xRQhO3YX8qRRyJ3mkV4F1aQsuRnYPqq6Hw+E51y27W75WgDoofw==",
"requires": {
- "builder-util-runtime": "9.2.9",
+ "builder-util-runtime": "9.2.10",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"lazy-val": "^1.0.5",
diff --git a/desktop/package.json b/desktop/package.json
index 326d6f24f740..ac66df7e9aed 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -6,7 +6,7 @@
"dependencies": {
"electron-context-menu": "^2.3.0",
"electron-log": "^4.4.8",
- "electron-updater": "^6.3.8",
+ "electron-updater": "^6.3.9",
"mime-types": "^2.1.35",
"node-machine-id": "^1.1.12"
},
diff --git a/docs/Hidden/Expensify-Lounge.md b/docs/Hidden/Expensify-Lounge.md
new file mode 100644
index 000000000000..716040ba2078
--- /dev/null
+++ b/docs/Hidden/Expensify-Lounge.md
@@ -0,0 +1,66 @@
+---
+title: Expensify Lounge
+description: Explore the Expensify Lounge - A stylish space to work, relax, and connect.
+---
+
+The Expensify Lounge is a place where people come to Get Shit Done. With beautiful surroundings, great coffee, and a collaborative community, it's the perfect environment to fuel productivity. Check out this guide on how to make the most of the Expensify Lounge!
+
+# The Two Rules
+
+## Rule #1 - Get Shit Done
+The Lounge is designed to help you focus, collaborate, and bring your boldest ideas to life. To keep this environment productive, we ask our members to remember:
+
+- **#focus** - Use the space as it’s intended, without disrupting others. The Lounge is social and collaborative but ultimately meant to support productive work.
+- **#urgency** - Remote work is fantastic, but face-to-face collaboration is unmatched. Use the Lounge to meet co-workers in person and drive your projects forward.
+- **#results** - Don’t confuse time spent with effort or effort with results. Visualize what you want to accomplish and don’t leave until it’s done.
+
+## Rule #2 - Don’t Ruin It for Everyone Else
+We want the Lounge to be an incredible, ever-evolving space. To achieve this, please follow these guidelines:
+
+- **#writeitdown** - If you can share knowledge, do it! Write a blog post, document, or post in Expensify Chat to help others learn from your experience. Suggestions to improve the Lounge are always welcome.
+- **#showup** - Be fully present when you’re here. Engage with others and collaborate in social spaces. This is a community built to get shit done; the more you contribute, the more you gain.
+- **#oneteam** - Inclusivity is a priority. We do not tolerate any form of discrimination. Make an effort to include those who want to join.
+- **#nocreeps** - Don’t make others feel uncomfortable with your words or actions. If you feel uncomfortable or notice it happening to someone else, use the escalation process in the FAQ.
+
+---
+
+# How to Use the Expensify Lounge
+With these two rules in mind, here’s how to get the most from the Lounge:
+
+## Rule #1 - Getting Shit Done
+- **Order drinks from Concierge** - Contact Concierge here to ask questions or order beverages, and they’ll deliver your order to you.
+- **Using an office** - Offices are first-come, first-serve, and ideal for brief calls or meetings. Please keep usage to under an hour. Offices cannot be reserved.
+- **Lounge hours** - The Lounge is open from 8am-6pm PT, Monday through Friday, and closed on some major holidays. Check our Google Maps profile for holiday hours.
+- **Suggest improvements** - Post any ideas to enhance the Lounge experience in #announce - Expensify Lounge.
+
+## Rule #2 - Not Ruining It for Everyone Else
+- **Offices are for calls** - Only use an office if you have a call or meeting, and try to keep it under an hour.
+- **Respect others** - Avoid being too loud or distracting while others work. When collaborating in Expensify Chat, be respectful and maintain a positive environment.
+- **Stay home if you’re sick** - If you’re feeling unwell, please skip the Lounge or wear a mask in public areas.
+- **If you see something, say something** - If you feel uncomfortable or notice others in discomfort, notify Concierge. In Expensify Chat, you can also use our moderation tools (outlined in the FAQ).
+
+We’re thrilled to have you here to live richly, have fun, and help save the world with us. Now, go enjoy the Expensify Lounge, and let’s Get Shit Done!
+
+---
+
+{% include faq-begin.md %}
+
+## What is Concierge?
+Concierge is our automated system that answers member questions in real-time. Local lounge questions are routed to the Lounge’s Concierge. Message Concierge for drink requests or general inquiries—they’ll handle it for you!
+
+## Who is invited to the Expensify Lounge?
+Everyone is invited! Whether you’re a current customer or just need a productive space, we’d love to have you.
+
+## How do I escalate something that’s making me or someone else uncomfortable?
+In Expensify Chat, use the escalation feature to flag messages as:
+
+- **Spam or Inconsiderate**: This sends a whisper to the sender and flags the message. These flags are visible to all users but not reviewed by Concierge.
+- **Intimidating or Bullying**: The message is hidden and reviewed. If confirmed, it will remain hidden, and we’ll communicate the violation to the sender.
+- **Harassment or Assault**: The message is hidden immediately, and our team reviews it. The sender receives a warning, and Concierge may block the user if needed.
+
+In person, please notify Concierge with your lounge location, and they’ll escalate the issue accordingly.
+
+## Where are other Expensify Lounge locations?
+Currently, we only have the San Francisco Lounge, but stay tuned for more locations coming soon!
+{% include faq-end.md %}
+
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md
index afe366fb1dbe..41dc52a4239c 100644
--- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md
+++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md
@@ -14,7 +14,7 @@ Before a report can be reimbursed via direct deposit:
To reimburse a report via direct deposit (USD):
1. Open the report.
-2. Click the **Reimburse** button and select **Via Direct Deposit (ACH)**.
+2. Click the **Reimburse** button and select **Via Direct Deposit**.
3. Confirm that the correct bank account is listed in the dropdown menu.
4. Click **Accept Terms & Pay**.
@@ -27,7 +27,7 @@ Before a report can be reimbursed via global reimbursement:
To reimburse a report via global reimbursement:
1. Open the report.
-2. Click the **Reimburse** button and select **Via Direct Deposit (ACH)**.
+2. Click the **Reimburse** button and select **Via Direct Deposit**.
3. Confirm that the correct bank account is listed in the dropdown menu.
4. Click **Accept Terms & Pay**.
diff --git a/docs/articles/expensify-classic/connections/quickbooks-desktop/Connect-To-QuickBooks-Desktop.md b/docs/articles/expensify-classic/connections/quickbooks-desktop/Connect-To-QuickBooks-Desktop.md
index bda84eb0a49f..30785330a9ad 100644
--- a/docs/articles/expensify-classic/connections/quickbooks-desktop/Connect-To-QuickBooks-Desktop.md
+++ b/docs/articles/expensify-classic/connections/quickbooks-desktop/Connect-To-QuickBooks-Desktop.md
@@ -52,6 +52,10 @@ For this step, it is key to ensure that the correct company file is open in Quic
![The Web Connector pop-up, where you will need to click "Yes"](https://help.expensify.com/assets/images/QBO_desktop_07.png){:width="100%"}
+{% include info.html %}
+Be sure to securely save this password in a trusted password manager. You'll need it for future configuration updates or troubleshooting. Having it easily accessible will help avoid delays and ensure a smoother workflow.
+{% include end-info.html %}
+
# FAQ
## What are the hardware and software requirements for the QuickBooks Desktop connector?
diff --git a/docs/articles/expensify-classic/expenses/Add-an-expense.md b/docs/articles/expensify-classic/expenses/Add-an-expense.md
index 92a96e989013..5f40ff377be6 100644
--- a/docs/articles/expensify-classic/expenses/Add-an-expense.md
+++ b/docs/articles/expensify-classic/expenses/Add-an-expense.md
@@ -2,7 +2,6 @@
title: Add an expense
description: Create a new expense in Expensify
---
-
You can add an expense automatically with SmartScan or enter the expense details manually.
@@ -41,63 +40,189 @@ You can open any receipt and click **Fill out details myself** to add or edit th
{% include end-selector.html %}
-# Email a receipt
-
You can also email receipts to SmartScan by sending them to receipts@expensify.com from an email address tied to your Expensify account (either a primary or secondary email). SmartScan will automatically pull all of the details from the receipt, fill them in for you, and add the receipt to the Expenses tab on your account.
{% include info.html %}
**For copilots**: To ensure a receipt is routed to the Expensify account you are copiloting instead of your own account, email the receipt to receipts@expensify.com with the email address of the account you are copiloting as the subject line of the email.
{% include end-info.html %}
-# Add an expense manually
+# Add a per diem expense
+
+A per diem (also called “per diem allowance” or “daily allowance”) is a fixed daily payment provided by an employer to cover expenses during business or work-related travel. These allowances simplify travel expense tracking and reimbursement for meals, lodging, and incidental expenses.
+
+{% include info.html %}
+Before you can add a per diem expense, a Workspace Admin must [enable per diem expenses](https://help.expensify.com/articles/expensify-classic/workspaces/Enable-per-diem-expenses) for the workspace and add the per diem rates. If you do not see an option for per diem rates, it is currently unavailable for your workspace, and you’ll need to reach out to one of your Workspace Admins for guidance.
+{% include end-info.html %}
+
+To add a per diem expense,
+
+1. Click the **Expenses** tab.
+2. Click **New Expense** and choose **Per Diem**.
+3. Select your travel destination.
+ - If your trip involves multiple stops, create a separate per diem expense for each destination.
+4. Select the start date, end date, start time, and end time for the trip.
+5. Select a sub-rate. The available sub-rates are dependent on the trip duration.
+ - You can include meal deductions or overnight lodging costs if allowed by your workspace.
+6. Enter any other required coding information, such as the category, description, or report, and click **Save**.
+
+# Add a mileage expense
+
+You can track your mileage-related expenses by logging your trips in Expensify. You have a couple of different options for logging distance:
+
+- Web app:
+ - **Manually create**: Manually enter the number of miles for the trip
+ - **Create from map**: Automatically determine the trip distance based on the start and end location.
+- Mobile app:
+ - **Manually create**: Manually enter the miles for the trip and your mileage rate
+ - **Odometer**: Enter your odometer reading before and after the trip
+ - **Start GPS**: Currently under development and unavailable for use.
+
+{% include info.html %}
+When adding a distance expense, the rates available are determined by the rates set in your [workspace rate settings](https://help.expensify.com/articles/expensify-classic/workspaces/Set-time-and-distance-rates). To update these rates or add a new rate, you must be a Workspace Admin.
+{% include end-info.html %}
{% include selector.html values="desktop, mobile" %}
{% include option.html value="desktop" %}
1. Click the **Expenses** tab.
-2. Click the + icon in the top right.
-3. Select the type of expense.
- - **Manually create**: Manually enter receipt details.
- - **Scan receipt**: Upload a saved image of a receipt.
- - **Create multiple**: Manually enter multiple expenses at once.
- - **Time**: Create an expense based on hours.
- - **Distance**: Create an expense based on distance.
- - Manually Create: Manually enter the distance details for the expense.
- - Create from Map: Enter the start and end destination and Expensify will help you create a receipt for the trip.
-4. Click **Save**.
+2. Click **New Expense**.
+3. Select the expense type.
+ - **Manually create**:
+ - Enter the number of miles for the trip.
+ - Select your rate.
+ - If desired, select the category, add a description, or select a report to add the expense to.
+ - Click **Save**.
+ - **Create from map**:
+ - Add your start location as point A.
+ - Add your end location as point B.
+ - If applicable, click **Add Destination** to add additional stops.
+ - To generate a map receipt, leave the Create Receipt checkbox selected.
+ - Click **Save**.
+ - Select your rate.
+ - If desired, select the category, add a description, or select a report to add the expense to.
+ - Click **Save**.
+
{% include end-option.html %}
{% include option.html value="mobile" %}
-1. Tap the ☰ menu icon in the top left.
-2. Tap **Expenses**.
-3. Tap the + icon in the top right.
-4. Tap the correct expense type and enter the expense details.
- - **Manually create**: Manually enter receipt details.
- - **Time**: Enter work time and rate.
- - **Manually create (Distance)**: Manually enter trip details by total distance.
- - **Odometer**: Manually enter trip details by start and end odometer readings.
- - **Start GPS**: Track distance while using the Expensify app to automatically calculate the distance in real time during the trip.
-5. Tap **Save**.
+1. Click the + icon in the top right corner.
+2. Under the Distance section, select the expense type.
+ - **Manually create**:
+ - Enter your mileage.
+ - Select your rate.
+ - If desired, click **More Options** to select the category, add a description, or select a report to add the expense to.
+ - Click **Save**.
+ - **Odometer**:
+ - Enter your vehicle’s odometer reading before the trip.
+ - Enter your vehicle’s odometer reading after the trip.
+ - Select your rate.
+ - If desired, click **More Options** to select the category, add a description, or select a report to add the expense to.
+ - Click **Save**.
{% include end-option.html %}
{% include end-selector.html %}
+# Add a group expense
+
+Capture group and event expenses with Attendee Tracking by documenting who attended and the cost per attendee. The amount is always divided evenly between all attendees—different amounts cannot be allocated to specific attendees. To divide the amounts differently, you’ll first have to split the expense.
+
{% include info.html %}
-If you are an employee under a company workspace, you may not see all of the different expense type options depending on your company’s workspace settings.
+Attendees added to an expense will not be notified that they were added to an expense, nor will they share in the expense or be requested to pay for any portion of the expense.
{% include end-info.html %}
+{% include selector.html values="desktop, mobile" %}
+
+{% include option.html value="desktop" %}
+1. Click the **Expenses** tab.
+2. Click the expense you want to add attendees to.
+3. Click the attendees field and enter the name or email address of the attendee.
+ - If the attendee is a member of your workspace, you can select their name from the list.
+ - If the attendee is not a member of your workspace, enter their full name or email address and press Enter on your keyboard to add them as a new attendee.
+4. Click **Save**.
+
+Once added, you’ll also see the list of attendees in the expense overview on the Expenses tab. To see the cost per employee, hover over the receipt total. These details are also available on any report that you add the expense to.
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+1. Tap the **Expenses** tab.
+2. Tap the expense you want to add attendees to.
+3. Scroll down to the bottom and tap **More Options**.
+4. Tap the attendees field and enter the name or email address of the attendee.
+ - If the attendee is a member of your workspace, you can select their name from the list.
+ - If the attendee is not a member of your workspace, enter their full name or email address and press Enter on your keyboard to add them as a new attendee.
+5. Tap **Save**.
+
+Attendees will also be listed on any report that you add the expense to.
+
+{% include end-option.html %}
+
+{% include end-selector.html %}
+
+# Add expenses in bulk
+
+You can upload bulk receipt images or add receipt details in bulk.
+
+## SmartScan receipt images in bulk
+
+{% include selector.html values="desktop, mobile" %}
+
+{% include option.html value="desktop" %}
+1. Click the **Expenses** tab.
+2. Drag and drop up to 10 images or PDF receipts at once from your computer’s files. You can drop them anywhere on the Expense page where you see a green plus icon next to your mouse cursor.
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+1. Open the mobile app and tap the camera icon in the bottom right corner.
+2. Tap the camera icon in the right corner to select the Rapid Fire mode.
+3. Take a clear photo of each receipt.
+4. When all receipts are captured, tap the X in the left corner to close the camera.
+{% include end-option.html %}
+
+{% include end-selector.html %}
+
+## Manually add receipt details in bulk
+
+*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.*
+
+1. Click the **Expenses** tab.
+2. Click **New Expense** and select **Create Multiple**.
+3. Enter the expense details for up to 10 expenses and click **Save**.
+
+## Upload personal expenses via CSV, XLS, etc.
+
+*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.*
+
+1. Hover over Settings, then click **Account**.
+2. Click the **Credit Card Import** tab.
+3. Under Personal Cards, click **Import Transactions from File**.
+4. Click **Upload** and select a .csv, .xls, .ofx, or a .qfx file.
+
{% include faq-begin.md %}
**What’s the difference between a reimbursable and non-reimbursable expense?**
-- Reimbursable expenses are things that you pay for with your own money that the company has agreed to pay you back for (like business travel paid for with personal funds).
-- Non-reimbursable expenses are things you pay for with company money that need to be documented for accounting purposes (like a lunch paid for with a company card).
+- **Reimbursable expenses**: Expenses that the company has agreed to pay you back for. This may include:
+ - Cash & personal card: Expenses paid for by the employee on behalf of the business.
+ - Per diem: Expenses for a daily or partial daily rate [configured in your Workspace](https://help.expensify.com/articles/expensify-classic/workspaces/Enable-per-diem-expenses).
+ - Time: An hourly rate for your employees or jobs as [set for your workspace](https://help.expensify.com/articles/expensify-classic/workspaces/Set-time-and-distance-rates). This expense type is usually used by contractors or small businesses billing the customer via [Expensify Invoicing](https://help.expensify.com/articles/expensify-classic/workspaces/Set-Up-Invoicing).
+ - Distance: Expenses related to business travel.
+- **Non-reimbursable expenses**: Expenses are things you pay for with company money that need to be documented for accounting purposes (like a lunch paid for with a company card).
+- **Billable expenses**: Business or employee expenses that must be billed to a specific client or vendor. This option is for tracking expenses for invoicing to customers, clients, or other departments. Any kind of expense can be billable, in _addition_ to being either reimbursable or non-reimbursable.
+
+You can also see a breakdown of these expense types on your report and can even organize the report by them.
{% include info.html %}
If you are an employee under a company workspace, your expenses may automatically be configured as reimbursable or non-reimbursable depending on the details that are entered. If an expense is incorrectly labeled, you must reach out to an admin to have it corrected.
{% include end-info.html %}
+**Why don't I see the option for one of these types of expenses?**
+
+If you are an employee under a company workspace, you may not see all of the different expense type options depending on your company’s workspace settings.
+
+**How do I edit my per diem expenses?**
+
+Per diem expenses cannot be amended. To make changes, you must delete the expense and recreate it.
+
{% include faq-end.md %}
-
diff --git a/docs/articles/expensify-classic/expenses/Add-expenses-in-bulk.md b/docs/articles/expensify-classic/expenses/Add-expenses-in-bulk.md
deleted file mode 100644
index 6ee84e1ead15..000000000000
--- a/docs/articles/expensify-classic/expenses/Add-expenses-in-bulk.md
+++ /dev/null
@@ -1,44 +0,0 @@
----
-title: Add expenses in bulk
-description: Add multiple expenses at one time
----
-
-
-You can upload bulk receipt images or add receipt details in bulk.
-
-# SmartScan receipt images in bulk
-
-{% include selector.html values="desktop, mobile" %}
-
-{% include option.html value="desktop" %}
-1. Click the **Expenses** tab.
-2. Drag and drop up to 10 images or PDF receipts at once from your computer’s files. You can drop them anywhere on the Expense page where you see a green plus icon next to your mouse cursor.
-{% include end-option.html %}
-
-{% include option.html value="mobile" %}
-1. Open the mobile app and tap the camera icon in the bottom right corner.
-2. Tap the camera icon in the right corner to select the Rapid Fire mode.
-3. Take a clear photo of each receipt.
-4. When all receipts are captured, tap the X in the left corner to close the camera.
-{% include end-option.html %}
-
-{% include end-selector.html %}
-
-# Manually add receipt details in bulk
-
-*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.*
-
-1. Click the **Expenses** tab.
-2. Click **New Expense** and select **Create Multiple**.
-3. Enter the expense details for up to 10 expenses and click **Save**.
-
-# Upload personal expenses via CSV, XLS, etc.
-
-*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.*
-
-1. Hover over Settings, then click **Account**.
-2. Click the **Credit Card Import** tab.
-3. Under Personal Cards, click **Import Transactions from File**.
-4. Click **Upload** and select a .csv, .xls, .ofx, or a .qfx file.
-
-
diff --git a/docs/articles/expensify-classic/expenses/Track-group-expenses.md b/docs/articles/expensify-classic/expenses/Track-group-expenses.md
deleted file mode 100644
index 82921b0e8cd3..000000000000
--- a/docs/articles/expensify-classic/expenses/Track-group-expenses.md
+++ /dev/null
@@ -1,41 +0,0 @@
----
-title: Track group expenses
-description: Use Attendee Tracking to track group expenses
----
-
-
-Capture group and event expenses with Attendee Tracking by documenting who attended and the cost per attendee. The amount is always divided evenly between all attendees—different amounts cannot be allocated to specific attendees. To divide the amounts differently, you’ll first have to split the expense.
-
-{% include info.html %}
-Attendees added to an expense will not be notified that they were added to an expense, nor will they share in the expense or be requested to pay for any portion of the expense.
-{% include end-info.html %}
-
-{% include selector.html values="desktop, mobile" %}
-
-{% include option.html value="desktop" %}
-1. Click the **Expenses** tab.
-2. Click the expense you want to add attendees to.
-3. Click the attendees field and enter the name or email address of the attendee.
- - If the attendee is a member of your workspace, you can select their name from the list.
- - If the attendee is not a member of your workspace, enter their full name or email address and press Enter on your keyboard to add them as a new attendee.
-4. Click **Save**.
-
-Once added, you’ll also see the list of attendees in the expense overview on the Expenses tab. To see the cost per employee, hover over the receipt total. These details are also available on any report that you add the expense to.
-{% include end-option.html %}
-
-{% include option.html value="mobile" %}
-1. Tap the **Expenses** tab.
-2. Tap the expense you want to add attendees to.
-3. Scroll down to the bottom and tap **More Options**.
-4. Tap the attendees field and enter the name or email address of the attendee.
- - If the attendee is a member of your workspace, you can select their name from the list.
- - If the attendee is not a member of your workspace, enter their full name or email address and press Enter on your keyboard to add them as a new attendee.
-5. Tap **Save**.
-
-Attendees will also be listed on any report that you add the expense to.
-
-{% include end-option.html %}
-
-{% include end-selector.html %}
-
-
-
-You can track your mileage-related expenses by logging your trips in Expensify. You have a couple of different options for logging distance:
-
-- Web app:
- - **Manually create**: Manually enter the number of miles for the trip
- - **Create from map**: Automatically determine the trip distance based on the start and end location.
-- Mobile app:
- - **Manually create**: Manually enter the miles for the trip and your mileage rate
- - **Odometer**: Enter your odometer reading before and after the trip
- - **Start GPS**: Currently under development and unavailable for use.
-
-{% include info.html %}
-When adding a distance expense, the rates available are determined by the rates set in your workspace rate settings. To update these rates or add a new rate, you must be a Workspace Admin.
-{% include end-info.html %}
-
-{% include selector.html values="desktop, mobile" %}
-
-{% include option.html value="desktop" %}
-
-1. Click the **Expenses** tab.
-2. Click **New Expense**.
-3. Select the expense type.
- - **Manually create**:
- - Enter the number of miles for the trip.
- - Select your rate.
- - If desired, select the category, add a description, or select a report to add the expense to.
- - Click **Save**.
- - **Create from map**:
- - Add your start location as point A.
- - Add your end location as point B.
- - If applicable, click **Add Destination** to add additional stops.
- - To generate a map receipt, leave the Create Receipt checkbox selected.
- - Click **Save**.
- - Select your rate.
- - If desired, select the category, add a description, or select a report to add the expense to.
- - Click **Save**.
-
-{% include end-option.html %}
-
-{% include option.html value="mobile" %}
-
-1. Click the + icon in the top right corner.
-2. Under the Distance section, select the expense type.
- - **Manually create**:
- - Enter your mileage.
- - Select your rate.
- - If desired, click **More Options** to select the category, add a description, or select a report to add the expense to.
- - Click **Save**.
- - **Odometer**:
- - Enter your vehicle’s odometer reading before the trip.
- - Enter your vehicle’s odometer reading after the trip.
- - Select your rate.
- - If desired, click **More Options** to select the category, add a description, or select a report to add the expense to.
- - Click **Save**.
-{% include end-option.html %}
-
-{% include end-selector.html %}
-
-
-
diff --git a/docs/articles/expensify-classic/expenses/Track-per-diem-expenses.md b/docs/articles/expensify-classic/expenses/Track-per-diem-expenses.md
deleted file mode 100644
index 88dd91997592..000000000000
--- a/docs/articles/expensify-classic/expenses/Track-per-diem-expenses.md
+++ /dev/null
@@ -1,34 +0,0 @@
----
-title: Track per diem expenses
-description: Add daily allowance expenses for business travel
----
-
-
-A per diem (also called “per diem allowance” or “daily allowance”) is a fixed daily payment provided by an employer to cover expenses during business or work-related travel. These allowances simplify travel expense tracking and reimbursement for meals, lodging, and incidental expenses.
-
-{% include info.html %}
-Before you can add a per diem expense, a Workspace Admin must enable per diem expenses for the workspace and add the per diem rates. If you do not see an option for per diem rates, it is currently unavailable for your workspace, and you’ll need to reach out to one of your Workspace Admins for guidance.
-{% include end-info.html %}
-
-To add a per diem expense,
-
-1. Click the **Expenses** tab.
-2. Click **New Expense** and choose **Per Diem**.
-3. Select your travel destination.
- - If your trip involves multiple stops, create a separate per diem expense for each destination.
-4. Select the start date, end date, start time, and end time for the trip.
-5. Select a sub-rate. The available sub-rates are dependent on the trip duration.
- - You can include meal deductions or overnight lodging costs if allowed by your workspace.
-6. Enter any other required coding information, such as the category, description, or report, and click **Save**.
-
-# FAQs
-
-**How do I edit my per diem expenses?**
-
-Per diem expenses cannot be amended. To make changes, you must delete the expense and recreate it.
-
-**What if my admin requires daily per diem submissions?**
-
-No problem! Create a separate per diem expense for each day of your trip.
-
-
diff --git a/docs/articles/new-expensify/expensify-card/Use-your-Expensify-Card.md b/docs/articles/new-expensify/expensify-card/Use-your-Expensify-Card.md
index 6c7457641ce6..8915778962a0 100644
--- a/docs/articles/new-expensify/expensify-card/Use-your-Expensify-Card.md
+++ b/docs/articles/new-expensify/expensify-card/Use-your-Expensify-Card.md
@@ -4,13 +4,13 @@ description: Use your physical or virtual Expensify Card
---
-As soon as you receive your physical Expensify Visa® Commercial Card, you can start using it right away by swiping it like you would with any other card, or you can link your card to your Apple or Google Pay mobile wallet to make in-person, contactless payments. You can also use your virtual Expensify Card for online and in-app purchases.
+As soon as you receive your physical Expensify Visa® Commercial Card, you can start using it right away by swiping it like you would with any other card. You can also link your card to your Apple or Google Pay mobile wallet to make in-person, contactless payments. You can also use your virtual Expensify Card for online and in-app purchases.
A virtual card is a digital card that can be used for online transactions. Virtual cards have the same details as physical cards, but they offer several additional benefits:
-- **Flexibility**: Virtual cards can be created or deleted instantly. You can use them for individual transactions with predetermined amounts or recurring payments and subscriptions.
+- **Flexibility:** Virtual cards can be created or deleted instantly. They can be used for individual transactions with predetermined amounts or recurring payments and subscriptions.
- **Customizable limits**: You can set spending limits for each virtual card.
-- **Security**: Admins have the option to issue virtual cards for a single-use (e.g. for one of expenses) or fixed-use (e.g. for recurring expenses). Since you have placed a limit on their usage, it makes them less susceptible to unauthorized transactions.
-- **Insights**: You can easily track recurring spend for specific vendors when assigning a virtual card to a team, department, or vendor.
+- **Security**: Admins have the option to issue virtual cards for a single-use (e.g., for one of the expenses) or fixed-use (e.g., for recurring expenses). Since you have placed a limit on their usage, it makes them less susceptible to unauthorized transactions.
+- **Insights**: When assigning a virtual card to a team, department, or vendor, you can easily track recurring spending for specific vendors.
# View your virtual card details
@@ -34,7 +34,7 @@ A virtual card is a digital card that can be used for online transactions. Virtu
{% include faq-begin.md %}
-**Why did my transaction get declined?**
+## Why did my transaction get declined?
Here are some reasons why an Expensify Card transaction might be declined:
@@ -43,7 +43,13 @@ Here are some reasons why an Expensify Card transaction might be declined:
- **Incorrect card details**: Your card information was entered incorrectly with the merchant. Entering incorrect card information, such as the CVC, ZIP, or expiration date, will also lead to declines. There was suspicious activity
- **Fraudulent or risky activity**: If Expensify detects unusual or suspicious activity, we may block transactions as a security measure. This could happen due to irregular spending patterns, attempted purchases from risky vendors, or multiple rapid transactions. Check your Expensify Home page to approve unusual merchants and try again. If the spending looks suspicious, we may complete a manual due diligence check, and our team will do this as quickly as possible - your cards will all be locked while this happens. The merchant is located in a restricted country
-**How do I report my Expensify Card expenses?**
+## Where can I use my Expensify Card?
+
+Generally, the Expensify Card can be used anywhere Visa is accepted. However, the Expensify Card program is based in the US, so we are bound by US sanctions and other international limitations.
+
+Expensify Card purchases will be declined if a merchant is physically located in, or has its headquarters or billing address, in the following countries -- Belarus, Burundi, Cambodia, Central African Republic, Democratic Republic of the Congo, Cuba, Iran, Iraq, North Korea, Lebanon, Libya, Russia, Somalia, South Sudan, Syrian Arab Republic, Tanzania, Ukraine, Venezuela, Yemen, Zimbabwe
+
+## How do I report my Expensify Card expenses?
You can report and submit Expensify Card expenses just like any other expenses, and you’ll want to submit them regularly to ensure you have a sufficient spending amount available on the card. As your expenses are approved, your Smart Limit updates accordingly.
diff --git a/docs/assets/images/SageConfigureIntegrationConfigureButton.png b/docs/assets/images/SageConfigureIntegrationConfigureButton.png
new file mode 100644
index 000000000000..e3ec52bacbb0
Binary files /dev/null and b/docs/assets/images/SageConfigureIntegrationConfigureButton.png differ
diff --git a/docs/assets/images/SageConfigureUserDefinedDimensionsFilter.png b/docs/assets/images/SageConfigureUserDefinedDimensionsFilter.png
new file mode 100644
index 000000000000..f126bb10dc51
Binary files /dev/null and b/docs/assets/images/SageConfigureUserDefinedDimensionsFilter.png differ
diff --git a/docs/assets/images/SageConnectCreatingWorkspace.png b/docs/assets/images/SageConnectCreatingWorkspace.png
new file mode 100644
index 000000000000..6084d0a8c7fb
Binary files /dev/null and b/docs/assets/images/SageConnectCreatingWorkspace.png differ
diff --git a/docs/assets/images/SageConnectEnableSage.png b/docs/assets/images/SageConnectEnableSage.png
new file mode 100644
index 000000000000..25b43a510c15
Binary files /dev/null and b/docs/assets/images/SageConnectEnableSage.png differ
diff --git a/docs/assets/images/SageConnectEnterCredentials.png b/docs/assets/images/SageConnectEnterCredentials.png
new file mode 100644
index 000000000000..63772972290d
Binary files /dev/null and b/docs/assets/images/SageConnectEnterCredentials.png differ
diff --git a/docs/assets/images/SageConnectSettingUpWebServicesUser.png b/docs/assets/images/SageConnectSettingUpWebServicesUser.png
new file mode 100644
index 000000000000..0fd3bb68c3d2
Binary files /dev/null and b/docs/assets/images/SageConnectSettingUpWebServicesUser.png differ
diff --git a/docs/assets/images/SageConnectSubscriptionSettings.png b/docs/assets/images/SageConnectSubscriptionSettings.png
new file mode 100644
index 000000000000..2e74d27c71e6
Binary files /dev/null and b/docs/assets/images/SageConnectSubscriptionSettings.png differ
diff --git a/docs/assets/images/SageConnectTimeandExpenseSequenceNumbers.png b/docs/assets/images/SageConnectTimeandExpenseSequenceNumbers.png
new file mode 100644
index 000000000000..8750c1ed596b
Binary files /dev/null and b/docs/assets/images/SageConnectTimeandExpenseSequenceNumbers.png differ
diff --git a/docs/assets/images/SageConnectWebServicesAuthorizations.png b/docs/assets/images/SageConnectWebServicesAuthorizations.png
new file mode 100644
index 000000000000..d0b9a786d1cc
Binary files /dev/null and b/docs/assets/images/SageConnectWebServicesAuthorizations.png differ
diff --git a/docs/redirects.csv b/docs/redirects.csv
index 06fd7c1ef502..bb6729245f83 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -591,3 +591,9 @@ https://help.expensify.com/articles/expensify-classic/articles/expensify-classic
https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills
https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/add-a-payment-card-and-view-your-subscription,https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription
https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Billing-page-coming-soon,https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Billing-page
+https://help.expensify.com/articles/expensify-classic/expenses/Add-expenses-in-bulk,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense
+https://help.expensify.com/articles/expensify-classic/expenses/Track-group-expenses,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense
+https://help.expensify.com/articles/expensify-classic/expenses/Track-mileage-expenses,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense
+https://help.expensify.com/articles/expensify-classic/expenses/Track-per-diem-expenses,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense
+https://community.expensify.com/discussion/5116/faq-where-can-i-use-the-expensify-card,https://help.expensify.com/articles/new-expensify/expensify-card/Use-your-Expensify-Card#where-can-i-use-my-expensify-card
+https://help.expensify.com/articles/other/Expensify-Lounge,https://help.expensify.com/Hidden/Expensify-Lounge
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index 74bcff5bf320..e90fdbe50255 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -426,6 +426,7 @@ platform :ios do
api_key_path: "./ios/ios-fastlane-json-key.json",
distribute_external: true,
notify_external_testers: true,
+ reject_build_waiting_for_review: true,
changelog: "Thank you for beta testing New Expensify, this version includes bug fixes and improvements.",
groups: ["Applause", "Beta Testers", "Expensify Employees"],
demo_account_required: true,
diff --git a/help/GUIDELINES.md b/help/GUIDELINES.md
new file mode 100644
index 000000000000..7fbf693e6830
--- /dev/null
+++ b/help/GUIDELINES.md
@@ -0,0 +1,150 @@
+# New Help Guidelines
+This file outlines a series of specific rules. Whenever editing any file on this site, please verify your changes comply with these rules.
+
+## General Philosophy
+In general, this help site is built around a few common principles:
+
+* **Consistency** - Every page of the site should follow a common pattern, as should every chapter on the page, and every section in the chapter
+* **Focus** - Every section should focus as much as possible on a single self-contained subset of the page, with complex subsets being broken into section groups rather than large singular sections
+* **Plain language** - All writing should target a high-school reading level, with very common language and simple phrasings.
+
+
+## Structure Rules
+To avoid ambiguity, let's establish the following terms:
+
+* **Site** - All of the pages combine to create a single help "site" providing comprehensive details on the Expensify Superapp, which is a collection of multiple products combined into a single app.
+
+* **Page** - Each help "page" is devoted to a single product within a tightly integrated suite. Accordingly, while each product page can refer to other products, each product page should only provide detailed definitions on a single product to avoid redundancy between product pages. Each product is split into multiple
+
+* **Chapter** - Each page is split into a standard set of "chapters", each of which contains multiple sections.
+
+* **Section** - Each chapter has three or more "sections", consisting of a header and body.
+[Fr
+* **Header** - Each section has a "header", which describes the contents of that section.
+
+* **Body** - Each section has a "body", which contains the contents of that section.
+
+
+## Chapter Rules
+Every page has exactly four "top level" chapters, which are given `##` (H2) headers:
+
+* **Introduction** - This chapter is devoted to very high level, jargon-free marketing language explaining the benefits of the product in clear and simple prose. The Introduction chapter has exactly three sections:
+
+ * *Main uses* - This section has a definition list summarizing the key scenarios in which this product would be used.
+
+ * *Core users* - This section has a definition list summarizing the key audiences that use this product.
+
+ * *Key advantages* - This section has a definition list summarizing the major benefits of this product over the competition.
+
+* **Concepts** - This chapter is devoted to establishing a clear, unambiguous lexicon for discussing this product. It contains three or more definition list sections or section groups. It does not contain any how-to or FAQ sections, the Concepts section is entirely focused on establishing the concepts themselves, not explaining how to use them.
+
+* **Tutorials** - This chapter is devoted to providing detailed step-by-step instructions on how to accomplish certain goals. This chapter contains three or more how-to sections or section groups. Everything in the Tutorial should be consistent with the language established in the Concepts.
+
+* **FAQ** - This chapter provides focused answers to very specific questions that are easily misunderstood or otherwise don't fit perfectly in the above chapters. This chapter contains three or more FAQ-style sections or section groups. The FAQ does not define any new terms (only the Concepts section does that), and does not give any step-by-step instructions (only the Tutorials section does that).
+
+Anything outside of these four chapters should be moved within the relevant chapter, following the section guidelines for that chapter.
+
+
+## Header Rules
+There are two kinds of headers:
+
+* **Short headers** - These are titles that are limited to 1-3 short words, such that it will fit into the "left hand nav" containing the table of contents, without "wrapping" around. Short titles capitalize major words. For example, this would be a short title:
+
+ ```
+ # Platforms
+ ```
+
+* **Long headers** - These are longer titles (4+ words), prefixed with a short title in square brackets. This allows for longer and more descriptive titles, while still providing a short title that fits into the left-hand nav comfortably. Long titles ask a complete question, and are capitalized and punctuated like a normal sentence. For example, this would be a long title:
+
+ ```
+ # [Platforms] Where can I use the Expensify App?
+ ```
+
+* To avoid confusion, no two sections in the same chapter or section group should have the same short or long title.
+
+* Headers that contain questions should be asked from the customer's perspective (ie, "How do I X?" not "How do you do X?")
+
+
+## Section Rules
+There are three kinds of sections:
+
+### Definition List Sections
+A "definition list" type section break a high level concept into smaller pieces, and consists of:
+
+* A "long header" describing the topic being deconstructed and defined, generally starting with "What", but never "How" or "Why".
+* 1-2 introductory sentences, explaining the theme of the list
+* An unnumbered bullet list, where each bullet consists of:
+ * A bolded term of 1-3 words
+ * A clear definition or description of the term, in 1-3 complete sentences.
+* Nothing should exist in the section after the bullet list
+
+An example of a definition list section follows:
+
+ ```
+ # [Fruit] What are the best fruits?
+ It's well known that these are the best fruits:
+
+ * **Apples** - The king of fruit. So crispy.
+ * **Oranges** - Often seen as diametrically opposed. But still delish.
+ * **Tomato** - Some people don't know this is a fruit. But it is.
+ ```
+
+### How-to List Sections
+A "how-to list" type section gives sequential steps to accomplish a goal, and consists of:
+
+* A "long header" describing the goal of the tutorial, starting with "How".
+* 1-2 introductory sentences, explaining the goal of the tutorial
+* A numbered list, where each step consists of a single sentence covering:
+ * A specific UI element to press or type into, if any, in bold
+ * An explanation of the benefit of doing this
+ * Each step describes exactly one user action; do not combine multiple actions into a single step
+* Confirm the sum of the steps accomplishes the clearly stated goal
+* Confirm every concept mentioned in the tutorial has a corresponding definition in the Concepts section
+* Nothing should exist in the section after the numbered list
+
+An example of a how-to section follows:
+
+ ```
+ # [Email] How do I send an email?
+ Email is the easiest way to write someone. To send an email:
+
+ 1. Press the **Email** app icon, to open the app.
+ 2. Press the **Compose** button, to start writing the email.
+ 3. Enter the address you want to send to into the **To** field, so it gets to the right person.
+ 4. Provide a subject of the email in the **Subject** field, to entice them to open the email.
+ 5. Write the email into the large blank body, to detail the message.
+ 6. Press the **Send** button, to deliver it to its addressed recipient.
+ ```
+
+### Frequently Asked Question (FAQ) Sections
+A "FAQ" type section gives a detailed answer to a single question, often to explain the non-obvious reasoning behind something, and consists of:
+
+* A "long header", asking a specific question, generally starting with "Why"
+ * Note: A FAQ cannot ask a "How do I...?" question -- move this to the Tutorials chapter and use a HowTo section
+* 1 paragraph answering the question, in 2-4 comprehensive sentences.
+ * Note: A FAQ cannot have a bullet list -- move this to the Concepts chapter and use a definition list section
+ * Note: A FAQ cannot have a numbered list -- move this to the Tutorials chapter and use a HowTo section
+
+
+## Section Group Rules
+When the Concepts, Tutorials, or FAQ chapters have 6 or more sections, those sections can optionally be split into two or more "section groups". Each section group is given a "H3" header (`###`), and consists of:
+
+* A short header, named after the common theme of the sections of the section group
+* 3-6 sections, of any type
+
+
+## Cross Platform Rules
+All instructions should be written in a fashion to work across all platforms (web, mobile, desktop, native, etc). Accordingly, the language should to the greatest degree possible be written in such a fashion that works across all platforms. Specifically:
+
+* Where possible, use a cross-platform verb. For example, do not say "click" or "tap", say "press"
+* If there is no suitable cross-platform term, briefly explain how to do the equivalent action on both platforms. For example, "right-click or long-tap to open the context menu..."
+* For anything that has no equivalent, clarify which platform the instruction refers to. For example: "If you have a mouse, hover over the chat to see the hover menu..."
+
+## General Language Rules
+To ensure that the content always sounds consistent:
+
+* "You" always refers to the reader, who is a user and customer of Expensify
+* "We" refers to the company Expensify, who is the author of the superapp this is documenting.
+* Any use of "we" could be replaced with "Expensify" and would still work.
+* The help documentation is in effect the product/company talking directly to the user, in the first person.
+
diff --git a/help/_config.yml b/help/_config.yml
index 11091b1a8b7c..407dfe9fea91 100644
--- a/help/_config.yml
+++ b/help/_config.yml
@@ -8,3 +8,4 @@ github_username: expensify
# Ignore what's only used for the Github repo
exclude:
- README.md
+ - GUIDELINES.md
diff --git a/help/_layouts/default.html b/help/_layouts/default.html
index cf8c7feeaea0..8a4605807355 100644
--- a/help/_layouts/default.html
+++ b/help/_layouts/default.html
@@ -85,13 +85,27 @@
.toc-sidebar li {
margin-left: 0;
- padding-left: 10px;
+ padding-left: 0;
+ }
+
+ .js-toc > ul > li > a {
+ font-weight: bold;
+ font-size: 18px;
+
}
.js-toc > ul > li > ul > li {
margin-top: 25px;
}
+ .js-toc > ul > li > ul > li > a {
+ font-weight: bold;
+ }
+
+ .js-toc > ul > li > ul > li > ul > li > ul > li {
+ padding-left: 10px;
+ }
+
.toc-sidebar a {
word-wrap: break-word;
display: block;
@@ -110,20 +124,9 @@
.toc-sidebar .is-active-link {
background-color: #eaf5ff;
color: #0366d6;
- font-weight: bold;
border-radius: 6px;
}
- a:has(+ ul.is-collapsible)::after {
- content: '∧'; /* Use the logical AND symbol */
- display: inline; /* Ensure the caret appears directly after the content */
- margin-left: 5px; /* Add some space between the text and the caret */
- transform: rotate(180deg); /* Rotate the caret 180 degrees */
- display: inline-block; /* Required to apply transform */
- position: relative; /* Enables positioning adjustments */
- top: 3px; /* Moves the caret down 3 pixels */
- }
-
/* Main content area */
main {
margin-left: 300px;
@@ -161,7 +164,7 @@
}
.is-active-link {
- font-weight: bold;
+ font-weight: normal;
}
.scroll-spacer {
diff --git a/help/card.md b/help/card.md
index c6a457629643..1ed51daf7713 100644
--- a/help/card.md
+++ b/help/card.md
@@ -5,14 +5,14 @@ title: Expensify Cards
## Introduction
The Expensify Card is a corporate payment card that integrates seamlessly with Expensify Expense, allowing you to manage company spending in real-time. By enforcing your company’s expense policy at the point of sale, the Expensify Card eliminates the need for manual receipt tracking, reduces fraud, and ensures compliance with expense policies. Whether you’re looking for simplified expense management, real-time control, or cashback rewards, the Expensify Card is designed to meet your needs.
-### [Main uses] When should I use the Expensify Card?
+### Main uses
The Expensify Card is ideal for any business looking to streamline its expense management and control employee spending. Key use cases include:
* **Enforcing company policy** - Ensure that purchases are compliant with your company’s expense policy automatically at the point of sale.
* **Automating expense tracking** - Eliminate manual receipt entry by capturing expenses automatically with every card transaction.
* **Real-time spending control** - Gain immediate insight into employee spending, and control purchases with dynamic card limits.
* **Earning cashback** - Get rewarded for company spending with up to 2% cashback on all purchases.
-### [Core users] Who uses the Expensify Card?
+### Core users
The Expensify Card is a valuable tool for companies of all sizes, from startups to large enterprises. Some common users include:
* **Small businesses** - Manage corporate spending efficiently without the complexity of traditional corporate cards.
* **Enterprises** - Gain full visibility into employee spending and ensure compliance across all transactions.
@@ -20,7 +20,7 @@ The Expensify Card is a valuable tool for companies of all sizes, from startups
* **Nonprofits** - Track and control organizational spending while ensuring that all expenses align with donor guidelines.
* **Accountants** - Streamline reimbursement and auditing processes by eliminating manual entry and simplifying receipt management.
-### [Key advantages] Why should I use the Expensify Card?
+### Key advantages
The Expensify Card offers unique advantages for companies looking to optimize their expense management:
* **Policy enforcement at the point of sale** - Automatically enforce your company’s expense policy when employees use the card, ensuring that only approved purchases go through.
* **Real-time visibility** - See employee spending as it happens, with every transaction instantly visible in Expensify Expense.
@@ -31,11 +31,10 @@ The Expensify Card offers unique advantages for companies looking to optimize th
* **Fraud reduction** - Reduce fraud by limiting card use to specific categories or vendors, and by gaining full visibility into all transactions.
## Concepts
-Expensify Cards introduce several concepts that redefine corporate spending management.
### [Policy enforcement] How does the Expensify Card enforce company policy?
The Expensify Card is designed to automatically enforce your company’s expense policy:
-* **Policy-based approvals** - Transactions are approved or denied based on predefined expense categories, amounts, or vendor types. For example, purchases outside of approved categories (e.g., entertainment, personal items) can be blocked in real-time.
+* **Policy-based approvals** - Transactions are approved or denied based on predefined expense categories, amounts, or vendor types.
* **Spending limits** - Set individual or department-level spending limits that the card will automatically enforce.
* **Real-time monitoring** - Managers and admins can view all transactions as they happen, allowing them to flag or approve expenses in real-time.
@@ -62,61 +61,232 @@ The Expensify Card includes several features that reduce the risk of fraud:
* **Real-time visibility** - Track all card transactions as they happen, making it easy to identify and address suspicious activity.
* **Dynamic limits** - Adjust spending limits in real-time, so if an employee is in a situation where they need additional funds, it can be handled securely.
-## Platforms
-The Expensify Card works seamlessly across all platforms, ensuring that your company’s expense management is fully integrated:
-* **Web app** - Manage Expensify Cards and monitor transactions from the Expensify web app.
-* **Mobile app** - Employees can use the Expensify mobile app to track expenses, view transactions, and manage their cards on the go.
-* **Desktop app** - Full control of Expensify Cards is available through the Expensify desktop app for Mac and Windows, making it easy for admins to manage policies and review transactions.
+### [Virtual Cards] What are the benefits of using virtual Expensify Cards?
+Virtual cards are digital cards designed for online transactions with several benefits:
+* **Flexibility** - Create or delete virtual cards instantly for transactions with predetermined amounts or recurring payments.
+* **Customizable limits** - Set spending limits for each virtual card.
+* **Security** - Issue virtual cards for single-use or recurring expenses to reduce the risk of unauthorized transactions.
+* **Insights** - Track recurring spend for specific vendors by assigning a virtual card to a team, department, or vendor.
## Tutorials
-### [Issue a card] How do I issue an Expensify Card to an employee?
-1. Go to **Settings** > **Cards** in the Expensify app.
-2. Press **Issue Card** and select the employee from the list.
-3. Set an initial spending limit, and assign the card to the employee’s workspace.
-4. The employee will receive an email with instructions to activate their card.
+### Getting Started
+#### [Enable Expensify Card] How do I enable the Expensify Card for my workspace?
+To enable the Expensify Card for your workspace, you must be a Workspace Admin. Follow these steps:
+
+1. Press your profile image or icon in the menu.
+2. Scroll and press **Workspaces** in the menu.
+3. Select the workspace you want to enable Expensify Cards for.
+4. Press **More features** in the menu.
+5. Under the **Spend** section, enable the Expensify Card toggle.
+
+#### [Select a bank account] How do I select a bank account for the Expensify Card?
+Before issuing Expensify Cards, connect them with a bank account. Here's how:
+
+1. Press **Expensify Card** in the menu.
+2. Press **Issue new card**.
+3. Select an existing bank account or follow the steps to add a new one.
+
+### Card Management
+#### [Issue a card] How do I issue an Expensify Card to an employee?
+To issue an Expensify Card to an employee, follow these steps:
+
+1. Press **Issue card**.
+2. Select the employee you want to issue the card to.
+3. Choose to issue a physical or virtual card.
+4. Pick a smart, monthly, or fixed limit.
+5. Enter the limit amount and add a card name.
+6. Press **Issue card** to confirm and issue the card.
+
+#### [Adjust limits] How do I adjust spending limits on an Expensify Card?
+To adjust spending limits on an Expensify Card, follow these steps:
-### [Adjust limits] How do I adjust spending limits on an Expensify Card?
1. Go to **Settings** > **Cards**.
2. Select the employee’s card from the list.
3. Press **Edit Limits** and adjust the spending limit for the card.
4. Press **Save** to apply the new limit.
-### [View transactions] How do I track Expensify Card transactions?
+#### [Manage Expensify Cards] How do I manage my issued Expensify Cards?
+To manage your issued Expensify Cards, you must be a Workspace Admin. Follow these steps:
+
+1. Press your profile image or icon in the bottom left menu.
+2. Scroll down and press **Workspaces** in the left menu.
+3. Select the workspace containing the desired Expensify Cards.
+4. Press **Expensify Card** in the left menu to see a list of all issued cards.
+5. Press a card row to view details or adjust the card limit, limit type, name, or deactivate it.
+6. Press **Settings** in the top right to adjust the settlement account or change the settlement frequency.
+
+### Transactions and Tracking
+#### [View transactions] How do I track Expensify Card transactions?
+To track Expensify Card transactions, follow these steps:
+
1. Navigate to the **Expenses** section in the Expensify app.
2. Filter by **Expensify Card** to view all transactions made using the card.
3. Select any transaction to view the details, including receipts and categorization.
-### [Manage policies] How do I enforce a company policy using Expensify Cards?
-1. Go to **Settings** > **Policies**.
-2. Select the policy to apply to your Expensify Cards.
-3. Under **Spending Rules**, set category and spending restrictions.
-4. Press **Save** to ensure all Expensify Card transactions follow these rules.
+#### [Dispute a transaction] How do I dispute an Expensify Card transaction?
+If you encounter a transaction error, you can dispute it by following these steps:
-## FAQ
+1. Contact the merchant to try and resolve the issue directly.
+2. If unresolved, contact Expensify by opening a chat with Expensify Concierge or emailing concierge@expensify.com with details of the disputed charge and supporting documentation.
+3. If you suspect fraud, immediately deactivate your card by pressing your profile image, selecting **Wallet**, pressing your Expensify Card, and then **Report card fraud**. Follow the prompts to deactivate and request a new card.
+4. Enable [Two-Factor Authentication (2FA)](https://help.expensify.com/articles/new-expensify/settings/Enable-Two-Factor-Authentication) for added security.
-### How do I set up the Expensify Card for my company?
-To set up the Expensify Card:
-1. Go to **Settings** > **Cards**.
-2. Follow the prompts to enable the Expensify Card for your company.
-3. Issue cards to employees, set spending limits, and define company policies for card usage.
+### Digital Wallet and Notifications
+#### [Add to Wallet] How do I add the Expensify Card to my digital wallet?
+To use your Expensify Card for contactless payments, add it to your Apple or Google Pay digital wallet:
+
+**Apple Pay**
+
+1. Open the **Wallet** app on your device.
+2. Press the **+** button to add a new card.
+3. Select **Debit or Credit Card**.
+4. Press **Continue** and follow the instructions to add your virtual Expensify Card.
+
+**Google Pay**
+
+1. Open the **Google Pay** app on your device.
+2. Press **Add to Wallet**.
+3. Select **Payment Card** and then **Add new debit or credit card**.
+4. Enter your virtual Expensify Card details to complete the process.
+
+#### [Enable Notifications] How do I enable notifications for my Expensify Card?
+To receive real-time notifications for spending activity on your Expensify Card, follow these steps:
+
+1. From your Expensify Chat inbox, press the dropdown on the logo or avatar in the top left corner.
+2. Select the workspace you want to update the notification settings for.
+3. Press the workspace chat in your inbox (the chat with your workspace’s name as the title).
+4. Press the header at the top of the chat.
+5. Press **Settings**.
+6. Press **Notify me about new messages** and select **Immediately**.
+
+Then, enable notifications on your device:
+
+**iPhone**
+
+1. Go to your device settings.
+2. Find and tap **New Expensify**.
+3. Tap **Notifications** and enable notifications.
+4. Customize your alerts. Depending on your phone model, you may have extra options to customize the types of notifications you receive.
+
+**Android**
+
+1. Go to your device settings.
+2. Tap **Notifications** and select **Apps notifications**.
+3. Find and tap **New Expensify**.
+4. Enable notifications.
+5. Customize your alerts. Depending on your phone model, you may have extra options to customize the types of notifications you receive.
+
+You will now receive real-time spend notifications to your mobile device.
-### How does the Expensify Card enforce my company’s expense policy?
-The Expensify Card automatically enforces your company’s expense policy by:
-* Blocking purchases outside of approved categories.
-* Enforcing spending limits in real-time.
-* Providing real-time visibility into employee spending for managers.
+### Card Details and Limits
+#### [Update Mailing Address] How do I update my Expensify Card mailing address?
+To update your mailing address for your Expensify Card, follow these steps:
-### How do employees submit expenses with the Expensify Card?
+1. Hover over **Settings** and press **Account**.
+2. Press the **Credit Card Import** tab.
+3. Press **Request a New Card** on your physical card pending activation.
+4. Select **I lost my card**. If you’re updating your address to receive your new Expensify Visa® Commercial Card, select this option even though you have not lost a card.
+5. Confirm your details and press **Continue**.
+6. Update your address and press **Continue**. If the new card has already been shipped to an incorrect address, proceed to the next step to resend the card to the newly updated address.
+7. Proceed with the card replacement. Your new card will arrive in 2-3 business days.
+
+#### [Check Card Limit] How do I check my Expensify Card limit?
+The Smart Limit of your Expensify Card updates automatically after each purchase. To check your available Smart Limit, follow these steps:
+
+1. Press your profile image or icon in the menu.
+2. Press **Wallet**.
+3. Press your Expensify Card to see the available Smart Limit.
+
+### Upgrading and Virtual Card Details
+#### [Upgrade Cards] How do I upgrade to the new Expensify Visa® Commercial Card?
+To upgrade your company’s Expensify Cards to the new Expensify Visa® Commercial Card, follow these steps:
+
+1. On the **Home** page, press the task titled "Upgrade to the new and improved Expensify Card."
+2. Review and agree to the **Terms of Service**.
+3. Press **Get the new card** to automatically mail new physical cards to existing cardholders with limits greater than $0 and issue virtual cards for immediate use.
+4. If Positive Pay is enabled, contact your bank to whitelist the new ACH ID: 2270239450.
+5. Remind employees to update payment information for recurring charges to their virtual card information.
+
+Existing cards remain active until deactivated by a Domain Admin or the cardholder. Cards won't be issued to employees who don't currently have them; 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) for them.
+
+#### [View Virtual Card Details] How do I view my virtual Expensify Card details?
+To view your virtual card details in Expensify, follow these steps:
+
+1. Press your profile image or icon in the menu.
+2. Press **Wallet**.
+3. Press your Expensify Card.
+4. Press **Reveal Details** to view your virtual Expensify Card number, expiration date, CVV, and address.
+
+## FAQs
+
+### Usage and Setup
+#### Why should I use Expensify Expense for my business?
+The Expensify Card is a corporate payment card that integrates seamlessly with Expensify Expense, providing simplified expense management, real-time control, and cashback rewards.
+
+#### How do I set up the Expensify Card for my company?
+To set up the Expensify Card, go to **Settings** > **Cards**, follow the prompts to enable the Expensify Card for your company, issue cards to employees, set spending limits, and define company policies for card usage.
+
+#### How does the Expensify Card enforce my company’s expense policy?
+The Expensify Card automatically enforces your company’s expense policy by blocking purchases outside of approved categories, enforcing spending limits in real-time, and providing real-time visibility into employee spending for managers.
+
+### Expense Submission and Tracking
+#### How do employees submit expenses with the Expensify Card?
Employees don’t need to manually submit expenses with the Expensify Card. Each transaction is automatically recorded, categorized, and attached to an expense report. Receipts are automatically captured and matched with transactions, eliminating the need for manual entry.
-### Can I track transactions in real-time?
+#### Can I track transactions in real-time?
Yes, the Expensify Card provides real-time visibility into all transactions. Admins and managers can monitor employee spending as it happens, ensuring full control and oversight.
-### What rewards do I earn with the Expensify Card?
+### Rewards and Benefits
+#### What rewards do I earn with the Expensify Card?
The Expensify Card offers up to 2% cashback on all purchases. Cashback can be applied directly to reduce your monthly Expensify bill, or used to offset other company expenses.
-### How do I control where employees can use their Expensify Cards?
+#### How do I control where employees can use their Expensify Cards?
You can control employee card usage by setting vendor and category restrictions. For example, you can restrict cards to be used only for travel-related purchases, or limit spending to certain vendors. These restrictions are enforced at the point of sale.
+### Transaction Issues
+#### Why did my transaction get declined?
+Here are some reasons why an Expensify Card transaction might be declined:
+ - **Insufficient card limit**: If a transaction exceeds your Expensify Card’s available limit, the transaction will be declined. Submitting expenses and getting them approved will free up your limit for more spending.
+ - **Inactive card**: Your card isn’t active yet or it was disabled by your Domain Admin.
+ - **Incorrect card details**: Your card information was entered incorrectly with the merchant. Entering incorrect card information, such as the CVC, ZIP, or expiration date, will also lead to declines.
+ - **Fraudulent or risky activity**: If Expensify detects unusual or suspicious activity, we may block transactions as a security measure.
+
+### Expense Reporting
+#### How do I report my Expensify Card expenses?
+You can report and submit Expensify Card expenses just like any other expenses, and you’ll want to submit them regularly to ensure you have a sufficient spending amount available on the card. As your expenses are approved, your Smart Limit updates accordingly.
+
+SmartScanned receipts should automatically attach to the related Expensify Card expense. Expensify also automatically generates an IRS-compliant eReceipt for every transaction as long as the expense isn’t lodging-related. If your organization doesn’t require itemized receipts, you can rely on eReceipts instead.
+
+### Fraud Protection
+#### How am I protected from fraud using the Expensify Card?
+Expensify uses sophisticated algorithms to detect and block unusual card activity. You can also enable real-time notifications to receive alerts each time your card is charged.
+
+#### How long does the dispute process take?
+The dispute process can take up to 90 days.
+
+#### Can I cancel a dispute?
+You can cancel a filed dispute by using your Expensify Chat thread with Concierge or by emailing concierge@expensify.com.
+
+### Account and Usage Requirements
+#### Do I need a specific type of bank account to use the Expensify Card?
+The Expensify Card requires a US business bank account opened in the name of a business incorporated in the US.
+
+#### Can I use the Expensify Card across multiple workspaces?
+You can use the Expensify Card on every workspace you create. However, a settlement account can only be used with the Expensify Card on one workspace.
+
+#### Can I issue multiple cards to the same employee?
+You can issue an unlimited number of both physical and virtual cards to employees, supporting a variety of use cases.
+
+### Upgrading and Reconciliation
+#### Why don’t I see the task to agree to new terms on my Home page?
+There are several reasons why the task to accept new terms might not appear:
+ - You may not be a Domain Admin.
+ - Another domain admin has already accepted the terms.
+ - The task might be hidden. Scroll to the bottom of the Home page and press **Show Hidden Tasks** to view all tasks.
+
+#### Will upgrading affect the continuous reconciliation process?
+The upgrade process won't affect continuous reconciliation. During the transition, you may have employees with both old and new cards, resulting in two separate debits for your settlement account per period. Once all spending transitions to the new cards, you'll only see one settlement.
+#### Do I have to upgrade to the new Expensify Visa® Commercial Card?
+Yes, an upgrade to the new Expensify Visa® Commercial Card is necessary. A deadline will be provided soon, but you'll have ample time to complete the upgrade.
\ No newline at end of file
diff --git a/help/chat.md b/help/chat.md
index c3b684874974..b46d1bec1066 100644
--- a/help/chat.md
+++ b/help/chat.md
@@ -2,136 +2,246 @@
layout: product
title: Expensify Chat
---
+
## Introduction
-Expensify Chat is a full-featured business chat tool, seamlessly integrated into the Expensify Superapp. It enables real-time collaboration with your team, clients, vendors, and friends, offering a powerful, Slack-style chat experience. Expensify Chat provides all the features you expect from a modern chat tool, including chat rooms, direct messages, file sharing, image attachments, emoji reactions, and threaded conversations.
-
-### [Main uses] When should I use Expensify Chat?
-Expensify Chat is designed for teams and businesses of all sizes to facilitate communication and collaboration. Use Expensify Chat to:
-* **Collaborate with teammates** - Create chat rooms and direct messages to discuss projects, share updates, and work together in real-time.
-* **Support clients** - Manage client conversations with ease, keeping all discussions, invoices, and approvals in one place.
-* **Engage with vendors** - Communicate with your vendors to negotiate, place orders, and track payments.
-* **Coordinate with friends** - Keep in touch with friends and colleagues using direct messages or group chats.
-
-### [Core users] Who uses Expensify Chat?
-Expensify Chat is for everyone who needs to stay connected and collaborate, including:
-* **Teams** - Coordinate work, share updates, and resolve issues quickly with real-time chat rooms and message threads.
-* **Remote workers** - Stay connected with your team from anywhere, with desktop and mobile chat apps that support real-time communication.
-* **Clients** - Provide seamless client communication, allowing you to manage projects and billing through the same platform.
-* **Vendors** - Manage vendor communication, ensuring that orders, invoices, and payments are all handled in one platform.
-* **Friends and family** - Expensify Chat is also great for personal conversations, making it easy to chat and share files with anyone who has an email address.
-
-### [Key advantages] Why should I use Expensify Chat?
-Expensify Chat offers unique benefits that set it apart from other business chat tools:
-* **Integrated with Expensify** - Unlike standalone chat apps, Expensify Chat is fully integrated with the Expensify Superapp, giving you access to all your expenses, invoices, payments, and chats in one platform.
-* **Real-time communication** - Instantly message anyone with an email address or phone number, whether they are part of your organization or an external client or vendor.
-* **Threads and reactions** - Organize conversations with threaded replies and react to messages with emojis to keep discussions focused and fun.
-* **File sharing and attachments** - Share files, images, and links directly within your chats for easy collaboration.
-* **Searchable history** - Expensify Chat allows you to search through all conversations, so you never lose track of important discussions or files.
-* **Cross-device functionality** - Stay connected with your team from anywhere, with support for both desktop and mobile apps.
+Expensify Chat is a tool for real-time collaboration with a Slack-style experience.
+
+### Main Uses
+Key scenarios for using Expensify Chat:
+
+* **Team collaboration** - Discuss projects and share updates in real-time.
+* **Client support** - Keep all client communications and approvals in one place.
+* **Vendor engagement** - Communicate with vendors for negotiations and orders.
+* **Friend coordination** - Stay in touch with friends through direct messages or group chats.
+
+### Core Users
+The main audiences for Expensify Chat:
+
+* **Teams** - Coordinate work and resolve issues quickly.
+* **Remote workers** - Stay connected via desktop and mobile apps.
+* **Clients** - Manage projects and billing seamlessly.
+* **Vendors** - Handle orders and payments efficiently.
+* **Friends and family** - Easy personal conversations with file sharing.
+
+### Key Advantages
+Benefits of using Expensify Chat:
+
+* **Integration with Expensify** - Access expenses, invoices, and chats in one place.
+* **Instant communication** - Message anyone with an email or phone number.
+* **Organized discussions** - Threaded replies and emoji reactions.
+* **Easy file sharing** - Directly share files and images within chats.
+* **Searchable history** - Find past conversations and files effortlessly.
+* **Cross-device support** - Stay connected on desktop and mobile.
## Concepts
-Expensify Chat introduces several key features that make it a powerful communication tool.
-
-### [Chat rooms] How do chat rooms work in Expensify Chat?
-Chat rooms are the core feature of Expensify Chat, allowing groups of people to collaborate in real-time:
-* **Create rooms** - You can create public or private rooms for your team, clients, or vendors. Public rooms are open to anyone in your workspace, while private rooms require an invitation.
-* **Invite members** - Invite anyone with an email address or SMS number to join a chat room, even if they aren’t on Expensify yet.
-* **Threads** - Keep conversations organized by replying to specific messages in a thread. This is useful for discussing multiple topics in a single room.
-
-### [Direct messages] What are direct messages?
-Direct messages are private, one-on-one conversations between two users:
-* **One-to-one messaging** - Use direct messages for private conversations with teammates, clients, or friends.
-* **Send files** - Attach images, documents, and links directly in your one-on-one conversations.
-* **Searchable** - All direct messages are fully searchable, so you can easily find past conversations or files.
-
-### [File sharing] How do I share files in Expensify Chat?
-Expensify Chat makes it easy to share files and attachments:
-* **Upload files** - You can upload images, documents, PDFs, and other files directly into any chat room or direct message.
-* **Preview files** - View shared files directly in the chat without having to download them.
-* **Download files** - All shared files can be downloaded for offline use or further collaboration.
-
-### [Emoji reactions] How do emoji reactions work in Expensify Chat?
-Emoji reactions add a fun and efficient way to respond to messages:
-* **React to messages** - Simply click the emoji icon under any message to react with an emoji. Reactions are visible to everyone in the conversation.
-* **Multiple reactions** - You can add multiple reactions to the same message, and others can join in by adding their own reactions.
-
-### [Threads] How do threaded conversations work?
-Threads allow you to keep conversations organized within chat rooms:
-* **Reply to a specific message** - Instead of creating a new message, you can reply directly to a previous message to start a thread.
-* **View threaded replies** - Threads are nested under the original message, making it easy to follow the conversation.
-* **Keep discussions organized** - Threads prevent clutter in busy chat rooms by grouping related messages together.
-
-### [Search] How does search work in Expensify Chat?
-Expensify Chat includes a powerful search feature to help you find messages, files, and conversations:
-* **Search messages** - Search across all your chat rooms and direct messages to find specific keywords, phrases, or conversations.
-* **Search files** - Quickly locate any files shared in chat rooms or direct messages by searching for file names or types.
-* **Filter by chat room** - Narrow your search results by limiting them to a specific chat room or direct message.
-
-## Platforms
-Expensify Chat works across multiple platforms, ensuring you can stay connected with your team wherever you are:
-* **Web app** - Access Expensify Chat through your browser, with full support for chat rooms, file sharing, and emoji reactions.
-* **Mobile app** - Stay connected on the go with the Expensify mobile app, which supports all chat features, including image attachments and notifications.
-* **Desktop app** - Use the Expensify desktop app for a more immersive experience, with full support for notifications, file sharing, and threaded conversations.
+
+### Chat Types
+Expensify Chat supports several types of communication:
+
+* **Private chats** - One-on-one communication.
+* **Group chats** - Private conversations with multiple participants.
+* **Chat rooms** - Public or private discussions available to workspace members.
+
+### Special Chat Rooms
+Expensify Chat includes special chat rooms for specific purposes:
+
+#### Admin and Announce
+Special rooms in a workspace:
+
+* **#admins** - Only accessible to Workspace Admins to manage settings and collaborate with other admins. This room includes your Expensify Setup Specialist and, if applicable, your Account Manager. You can also:
+ - Chat with your dedicated Expensify Setup Specialist.
+ - Chat with your Account Manager (if you have a subscription with 10 or more members).
+ - Review changes made to your Workspace settings.
+
+* **#announce** - For company-wide announcements. By default, all Workspace Members can send messages, but permissions can be updated to allow only admins to post.
+
+### Update Messaging Permissions in #announce
+To allow only admins to post in an #announce room:
+
+1. Open the #announce room chat in your inbox.
+2. Press the room header.
+3. Select **Settings**.
+4. Choose **Who can post** and select **Admins only**.
+
+### Reorder Chat Inbox
+
+Customize the order of chat messages in your inbox by pinning them or changing your message priority:
+
+* **Pin**: Moves a specific chat to the top of your inbox list.
+* **Message priority**: Determines the order of message display:
+ - **Most Recent**: Shows all chats by the most recent, with pinned chats at the top.
+ - **#focus**: Displays only unread and pinned chats, sorted alphabetically.
+
+#### Pin a Message
+
+To pin a message:
+
+1. Press and hold (or right-click) a chat in your inbox.
+2. Select **Pin**. The chat will be pinned to the top of your inbox.
+3. To unpin, repeat this process and select the pin icon again.
+
+#### Change Message Priority
+
+To change message priority:
+
+1. Press your profile image or icon.
+2. Select the **Preferences** tab.
+3. Choose **Priority Mode** and select either #focus or Most Recent.
+
+### Leave a Chat Room
+
+To leave a chat room:
+
+1. Open the chat room.
+2. Press the header or the 3 dot menu icon in the top right.
+3. Select **Leave**. After leaving, the chat room will no longer appear in your inbox, and you won't receive notifications from it.
+
+### Flag Chat Messages
+
+Flagging a message as offensive (including unwanted behavior or offensive messages or attachments) escalates it to Expensify’s internal moderation team for review. The person who sent the message will be notified of the flag anonymously, and the moderation team will decide what further action is needed.
+
+Depending on the severity of the offense, messages can be hidden (with an option to reveal) or fully removed. In extreme cases, the sender of the message may be temporarily or permanently blocked from posting.
+
+Messages sent in public chat rooms are automatically reviewed for offensive content by an automated system. If offensive content is found, the message is sent to Expensify’s internal moderation team for further review.
+
+To flag a message:
+
+1. Open the chat in your inbox.
+2. Press and hold (or hover over on desktop) the message and select **Flag as offensive**.
+3. Select a category: spam, inconsiderate, intimidation, bullying, harassment, or assault.
## Tutorials
-### [Create a chat room] How do I create a chat room in Expensify Chat?
-1. Navigate to the **Chat** section of the Expensify app.
-2. Press **Create Room**.
-3. Enter a name for the room and choose whether to make it public or private.
-4. Invite members by entering their email addresses or phone numbers.
-5. Press **Create** to finalize the room.
-
-### [Send a direct message] How do I send a direct message?
-1. Press **New Message** from the chat screen.
-2. Enter the email address or phone number of the person you want to message.
-3. Type your message and press **Send**.
-4. Optionally, attach files or images by pressing the attachment icon.
-
-### [React to a message] How do I react to a message with an emoji?
-1. Hover over the message you want to react to.
-2. Press the **emoji** icon that appears below the message.
-3. Choose an emoji from the list, and it will be added to the message.
-4. To add more reactions, simply repeat the process.
-
-### [Start a thread] How do I reply to a message in a thread?
-1. Hover over the message you want to reply to.
-2. Press the **Reply in thread** button.
-3. Type your reply and press **Send**. Your reply will appear nested under the original message.
-
-### [Search for a message] How do I search for messages or files?
-1. Press the **Search** bar at the top of the chat screen.
-2. Enter the keyword, phrase, or file name you are looking for.
-3. Filter results by chat room or direct message (optional).
-4. Press **Search** to view the results.
+### [Create Room] How do I create a chat room?
+To create a chat room:
+
+1. Press the **+** button and select **Start Chat**.
+2. Choose the **#Room** tab.
+3. Enter a name for the room (ensure it's unique within the workspace).
+4. Optionally, add a description.
+5. Select **Workspace** to assign the room to a workspace.
+6. Choose **Who can post** to set posting permissions (all members or only admins).
+7. Set **Visibility** to determine room accessibility:
+ - **Public**: Viewable by anyone (ideal for conferences).
+ - **Private**: Only invited individuals can find it.
+ - **Workspace**: Accessible by all workspace members.
+8. Press **Create room** to finalize the setup.
+
+*Note: Anyone, including those outside the workspace, can be invited to private or restricted rooms.*
+
+### [Invite Members] How do I invite members to a chat group or room?
+Invite members using one of the following methods:
+
+- **Mentioning**:
+ 1. Open the chat group or room.
+ 2. In the message field, type @ and the person’s name or email address. Repeat for all participants.
+ 3. Enter a message if desired and press Send.
+
+- **Members Pane**:
+ 1. Open the chat group or room.
+ 2. Press the room or group header, then **Members**.
+ 3. Press **Invite member**, select contacts, and press **Invite**.
+
+- **Sharing Link or QR Code**:
+ 1. Open the chat group or room.
+ 2. Press the room or group header, then **Share**.
+ 3. Copy the link or present the QR code for others to scan.
+
+*Note*: These options are only for groups or rooms, not for private 1-on-1 chats.
+
+### [Start a Private Chat] How do I start a private 1-on-1 chat?
+To start a private 1-on-1 chat:
+
+1. Press the **+** button and select **Start Chat**.
+2. Enter the name, email, or phone number of the person you want to chat with.
+3. Select their name to start a new chat with them.
+
+*Note: You cannot add more people to a private chat. To include additional participants, create a group chat.*
+
+### [Start a Group Chat] How do I start a group chat?
+To start a group chat:
+
+1. Press the **+** button and select **Start Chat**.
+2. Enter the names, emails, or phone numbers of the participants and select **Add to group** for each.
+3. Press **Next** and update the group image or name if desired.
+ - **Name**: Select **Group Name**, enter the new name, and save.
+ - **Image**: Select the profile image, upload a new image, and adjust as needed.
+4. Press **Start group** to create the chat.
+
+### [Direct Message] How do I send a direct message?
+To send a direct message:
+
+1. Press **New Message**.
+2. Enter the recipient's email or phone.
+3. Type and send your message.
+4. Attach files using the attachment icon.
+
+### [Send and Format Messages] How do I send and format chat messages?
+To send and format chat messages:
+
+1. Open any chat in your inbox.
+2. Use the message bar at the bottom to enter your message, add attachments, and insert emojis.
+ - **To add a message**: Press the field labeled "Write something" and type your message.
+ - **To add an attachment**: Press the plus icon and select **Add attachment**. Choose the attachment from your files.
+ - **To add an emoji**: Press the emoji icon to the right of the message field.
+3. Press the Send icon to send the message.
+
+You can format the text using markdown:
+
+- _Italicize_: Add underscores _ on both sides of the text.
+- **Bold**: Add two asterisks ** on both sides of the text.
+- ~~Strikethrough~~: Add two tildes ~~ on both sides of the text.
+- Heading: Add a number sign # in front of the text.
+- Inline image: Add `![Alt text](image URL)` with the URL and alt text.
+- Tag another member: Add an @ symbol followed by the member's name, username, or email.
+- Mention a room: Add a # followed by the room name.
+- > Blockquote: Add an angled bracket > in front of the text.
+- `Code block for a small amount of text`: Add a backtick ` on both sides of the text.
+- Code block for the entire message: Add three backticks ``` at the beginning and end of the message.
+
+### [Start a Conversation Thread] How do I start a conversation thread?
+To start a conversation thread within a chat:
+
+1. Open the chat in your inbox.
+2. Press on the message you want to reply to and select **Reply in thread**.
+3. Enter and submit your reply in the new chat thread.
+
+To return to the main conversation, use the link at the top of the thread.
+
+### [React Message] How do I react to a message?
+To react to a message:
+
+1. Hover over the message (desktop only).
+2. Press the **emoji** icon.
+3. Select an emoji to add to the message.
+
+### [Edit or Delete Messages] How do I edit or delete messages?
+To edit or delete your own messages:
+
+1. Open a chat in your inbox.
+2. Press on the message you want to edit or delete.
+3. Select **Edit comment** to modify the message. Once edited, an "edited" label will appear next to it.
+4. Select **Delete comment** to remove the message or image for all viewers. Note that deleting a message cannot be undone.
## FAQ
### How do I get started with Expensify Chat?
-To start using Expensify Chat:
-1. Log in to your Expensify account and navigate to the **Chat** section.
-2. Create new chat rooms or direct messages and start chatting with your team, clients, or vendors.
-3. You can also join existing chat rooms if you've been invited.
-
-### Can I invite external users to Expensify Chat?
-Yes, you can invite anyone to Expensify Chat by entering their email address or phone number. They will receive an invitation to join and can participate in chat rooms or direct messages.
-
-### Can I search through past conversations in Expensify Chat?
-Yes, Expensify Chat allows you to search through all your past conversations, including chat rooms and direct messages. Simply use the search bar at the top of the screen to find specific messages or files.
+Log in to your account and go to the **Chat** section to create or join chat rooms and start messaging.
-### How do I send files and attachments in Expensify Chat?
-To send files:
-1. Open a chat room or direct message.
-2. Press the **attachment** icon.
-3. Select the file from your device and press **Send**.
+### Can I invite external users?
+Yes, invite anyone via email or phone to join chat rooms or direct messages.
-### What types of files can I share in Expensify Chat?
-You can share images, documents, PDFs, and other common file types in Expensify Chat.
+### Can I search past conversations?
+Yes, use the search bar to find specific messages or files in past conversations.
-### Can I create private chat rooms?
-Yes, when creating a new chat room, you can choose to make it private. Private rooms require an invitation to join, and only invited members can see the room or participate in the conversation.
+### What's the difference between a private 1-on-1 chat and a group chat with only 2 people?
+With a group chat, you can add additional people to the chat at any time. However, you cannot add more participants to a private 1-on-1 chat.
-### How do I manage notifications in Expensify Chat?
-You can manage your notifications from the **Settings** section of the Expensify app. Here, you can customize notification preferences for chat messages, mentions, and other activity.
+### How do I remove someone from a chat group or room?
+Currently, members have to remove themselves from a chat.
+### Why is someone I don't recognize in my #admins room?
+Your #admins room includes your dedicated Expensify Setup Specialist who assists with onboarding and answers your questions. If you have a subscription with 10 or more members, your dedicated Account Manager is also part of the #admins room for ongoing support.
+### Additional Permissions
+Some chat rooms may have permissions that restrict who can send messages. If you do not have the required permission level, you will not be able to send messages in those rooms.
\ No newline at end of file
diff --git a/help/expense.md b/help/expense.md
index 0d0012c95fbb..a6335b8e3549 100644
--- a/help/expense.md
+++ b/help/expense.md
@@ -2,110 +2,881 @@
layout: product
title: Expensify Expense
---
+
## Introduction
Expensify Expense is the core of the Expensify Superapp, offering world-class expense management capabilities for individuals and businesses alike. Whether you're tracking personal expenses for budgeting, submitting receipts for reimbursement, or overseeing company-wide spending, Expensify Expense simplifies the process with its user-friendly design and powerful automation features.
-### [Main uses] When should I use Expensify Expense?
-Expensify Expense is designed for a wide range of expense management needs, including:
-* **Reimburse employee receipts** - Manage business expenses by capturing and submitting receipts for approval.
-* **Track personal expenses** - Keep tabs on your own expenses for tax deductions, budgeting, or general financial tracking.
-* **Split bills** - Easily divide the cost of shared expenses like meals or group activities and send or receive payments.
-* **Automate receipt capture** - Use SmartScan to automatically capture receipt details and categorize them instantly.
-* **Submit and approve expense reports** - Create detailed reports for approval, with multi-level workflows if needed.
-* **Stay on top of company spending** - With corporate cards and real-time tracking, managers can ensure compliance and stay within budget.
-
-### [Core users] Who uses Expensify Expense?
-Expensify Expense is versatile enough for personal, business, and enterprise use. Some key user groups include:
+### [Main uses] What are the main uses of Expensify Expense?
+Expensify Expense is designed for a wide range of expense management needs:
+* **Reimburse Employee Receipts** - Manage business expenses by capturing and submitting receipts for approval.
+* **Track Personal Expenses** - Keep tabs on your expenses for tax deductions, budgeting, or general financial tracking.
+* **Split Bills** - Easily divide the cost of shared expenses like meals or group activities and send or receive payments.
+* **Automate Receipt Capture** - Use SmartScan to automatically capture receipt details and categorize them instantly.
+* **Submit and Approve Expense Reports** - Create detailed reports for approval, with multi-level workflows if needed.
+* **Stay on Top of Company Spending** - With corporate cards and real-time tracking, managers can ensure compliance and stay within budget.
+
+### [Core users] Who are the core users of Expensify Expense?
+Expensify Expense is versatile enough for personal, business, and enterprise use. Key user groups include:
* **Individuals** - Track personal spending and maximize tax deductions with easy categorization of expenses.
* **Freelancers** - Manage client billable expenses and reimbursements.
* **Employees** - Submit expense reports with attached receipts, whether you're in the office or traveling.
* **Managers** - Approve expenses, oversee spending, and ensure compliance with company policies.
* **Accountants** - Streamline financial reporting by integrating with accounting platforms and processing reimbursements.
-* **Corporate teams** - Manage large-scale company expenses with corporate cards and centralized approval workflows.
+* **Corporate Teams** - Manage large-scale company expenses with corporate cards and centralized approval workflows.
-### [Key advantages] Why should I use Expensify Expense?
-Expensify Expense offers a variety of advantages for both personal and corporate users:
-* **Automated receipt capture** - Eliminate manual data entry with SmartScan, which reads and categorizes receipts automatically.
-* **Integrated corporate cards** - Link company cards to track purchases in real-time and avoid the need for reimbursements.
-* **Real-time expense tracking** - Keep an eye on budgets and expenses as they happen, reducing the risk of overspending.
-* **Custom approval workflows** - Create multi-level approval processes to streamline and secure the expense submission process.
-* **Seamless integration** - Sync your expenses with accounting systems like QuickBooks, Xero, NetSuite, and others.
-* **Worldwide compatibility** - Expensify supports every currency, making it ideal for international travel and business.
+### [Key advantages] What are the key advantages of using Expensify Expense?
+Expensify Expense offers a variety of advantages:
+* **Automated Receipt Capture** - Eliminate manual data entry with SmartScan.
+* **Integrated Corporate Cards** - Link company cards to track purchases in real-time.
+* **Real-Time Expense Tracking** - Monitor budgets and expenses as they happen.
+* **Custom Approval Workflows** - Create multi-level approval processes.
+* **Seamless Integration** - Sync your expenses with accounting systems like QuickBooks, Xero, NetSuite, and others.
+* **Worldwide Compatibility** - Expensify supports every currency, ideal for international business.
## Concepts
-Expensify Expense is built on a set of core concepts that make expense tracking easy and efficient:
-### [Receipt capture] How does Expensify Expense capture receipts?
-Expensify simplifies receipt management with SmartScan:
-* **SmartScan** - Automatically scans and extracts important details from your receipts (date, amount, merchant, etc.) and categorizes the expense.
+### [Receipt capture] What is receipt capture in Expensify Expense?
+Receipt capture simplifies receipt management with SmartScan:
+* **SmartScan** - Automatically scans and extracts details from your receipts and categorizes the expense.
* **E-receipts** - Automatically generate IRS-compliant electronic receipts for purchases made with the Expensify Card.
-* **Manual upload** - Take a photo of your receipt or upload it manually from your phone or desktop.
-
-### [Expense reports] How do I create and submit an expense report?
-Expensify streamlines expense report creation:
-1. **Add expenses** - Attach receipts or manually enter expenses into a report.
-2. **Categorize expenses** - Use custom categories and tags to organize your expenses.
-3. **Submit for approval** - Send your report to the relevant approver(s) with just one click.
-4. **Track status** - Get notified when your report is approved and reimbursed.
-
-### [Approvals] What is the approval process?
-Managers can review and approve expenses through a customizable workflow:
-* **Single or multi-level approvals** - Set up multiple approvers based on the amount or department.
-* **Automatic reminders** - Send automatic reminders to approvers to ensure timely processing.
-* **Real-time visibility** - Approvers can see the full expense report with attached receipts and can approve or reject it with a single click.
-
-### [Corporate cards] How do Expensify Cards work?
-Expensify Cards integrate directly with Expensify Expense to automate expense tracking:
-* **Automatic receipt capture** - Transactions made with Expensify Cards automatically generate e-receipts.
-* **Spend limits and controls** - Managers can set individual spending limits, track real-time spend, and lock cards if needed.
-* **Rewards** - Earn up to 2% cashback on Expensify Card purchases.
-
-### [Integrations] Which accounting systems does Expensify Expense support?
-Expensify integrates with all major accounting systems:
-* **QuickBooks** - Sync expenses and receipts with your QuickBooks account for easy reconciliation.
-* **Xero** - Automate the transfer of expense data to your Xero account.
-* **NetSuite** - Link expenses to your NetSuite ERP system for complete financial management.
-* **More integrations** - Expensify also integrates with Sage Intacct, Oracle, and others.
-
-### [Reimbursement] How do I get reimbursed for my expenses?
-Expensify makes reimbursement quick and easy:
-* **Direct deposit** - Get reimbursed directly to your bank account after your report is approved.
-* **International payments** - Expensify supports reimbursement in multiple currencies, perfect for global teams.
-
-## Platforms
-Expensify Expense is available on all platforms, ensuring you can track expenses wherever you are:
-* **Web app** - Access Expensify Expense from your browser at any time.
-* **Mobile app** - Track expenses on the go using the Expensify mobile app for iOS and Android.
-* **Desktop app** - Use the Expensify desktop app for Windows or Mac to manage expenses and reports.
+* **Manual Upload** - Take a photo or upload your receipt manually from your device.
+
+### [Expense categories] What are expense categories?
+Expense categories help code expenses for accounting and financial reporting. Categories can be manually created or imported from connected platforms like QuickBooks, Xero, and NetSuite. Over time, Expensify learns how you categorize specific merchants and applies them automatically.
+
+### [Track taxes] What is tax management in Expensify?
+Expensify allows you to configure and manage tax rates within your workspace, applicable on Collect and Control plans. This applies the correct tax rates to expenses based on currency and workspace settings.
+
+#### Enabling and Managing Taxes
+Expensify allows you to enable and manage tax rates in your workspace:
+
+* **Enable Taxes** - Taxes can be enabled on any workspace where the default currency is not USD. If there's a direct accounting integration, tax rates will be managed through the integration.
+
+* **Managing Tax Rates** - You can manually add, edit, or delete tax rates. Additionally, you can set default tax rates for both workspace currency and foreign currencies.
+
+### [Corporate cards] What is the role of corporate cards in Expensify Expense?
+Corporate cards integrate with Expensify Expense for automated expense tracking:
+* **Automatic Receipt Capture** - Transactions automatically generate e-receipts.
+* **Spend Limits and Controls** - Managers can set limits, track spending, and lock cards.
+* **Rewards** - Earn cashback on Expensify Card purchases.
+
+### [Integrations] What accounting systems does Expensify support?
+Expensify integrates with all major accounting systems, including QuickBooks Online, Xero, NetSuite, and Sage Intacct.
+
+### [Distance Rates] What are distance rates in Expensify?
+Distance rates are configured for mileage expenses, allowing employees to select predefined rates when logging distance-based expenses.
+
+### [NetSuite Integration] What is the NetSuite integration in Expensify?
+NetSuite integration allows for seamless data transfer between Expensify and NetSuite:
+* **Expense Categories** - Automatically imported from NetSuite into Expensify for consistency.
+* **Tags and Report Fields** - Import departments, classes, and locations as tags or report fields for detailed categorization.
+* **Custom Segments/Records** - Import custom segments and records for more specific data mapping.
+* **Auto-Sync** - Synchronize data changes between Expensify and NetSuite daily.
+
+### [Duplicate Detection] What is duplicate detection in Expensify?
+Duplicate Detection helps prevent duplicate expense requests by flagging expenses with the same date and amount in the same member's account:
+* **Flagging** - A red dot appears in the left menu or the expense’s chat room, putting the expense on “hold.”
+* **Eligibility** - Available exclusively for Collect & Control plans.
+
+### [Bank Account Connection] What does connecting a personal bank account to Expensify mean?
+Connecting a personal bank account allows direct receipt of payments and reimbursements:
+* **Secure Verification** - We use Plaid, an encrypted third-party platform, to verify your banking information securely.
+* **Direct Deposits** - Once connected, all payments and reimbursements go directly into your designated bank account.
+
+### [Expensify Wallet] What is the Expensify Wallet?
+The Expensify Wallet enables peer-to-peer payments by connecting a personal bank account:
+* **Setup** - Connect your bank account via Plaid to enable the wallet.
+* **Verification** - Verify your identity through Onfido by uploading identification.
+* **Payments** - Once the wallet is enabled, you can send and receive payments seamlessly.
+
+### [Business Bank Account Validation] What is validating a business bank account in Expensify?
+Validating a business bank account is essential to ensure that your account is ready for use in Expensify:
+* **Test Deposits** - After the bank account connection is approved, Expensify sends three test transactions to your account for validation.
+* **Verification Status** - Check your bank account status in the **Bank accounts** section under workspace settings. The status will either be **Verifying** or **Pending**.
+* **Input Transaction Amounts** - Once you receive the test deposits, input the transaction amounts as prompted in Expensify to complete the validation process.
+
+### [Expense Tags] What are tags in Expensify Expense?
+Tags in Expensify refer to line-item details like classes, projects, locations, and customers that help code expenses for accounting and reporting. Tags can be manually created or imported from a connected accounting system. Expensify learns and applies tags automatically over time.
+
+### [Workflows] What are workflows in Expensify Expense?
+Workflows in Expensify Expense allow you to manage expense approvals and submissions:
+* **Add Approvals** - Requires additional approval for an expense before payment can be authorized. The default approver is the workspace owner, but it can be changed to another workspace admin.
+* **Delay Submissions** - Determines when expenses without issues are automatically submitted. You can set a delay frequency for automatic submissions.
+
+### [Report Fields] What are report fields in Expensify Expense?
+Report fields allow you to add additional details to your reports:
+* **Enable Report Fields** - Report fields can be enabled in Workspaces on the Control plan. They provide a way to specify header-level details like project names or locations.
+* **Create Report Fields** - Once enabled, report fields can be created for free-text input, date selection, or a list of options.
+* **Edit/Delete Report Fields** - Existing report fields can be modified or removed as needed to keep your workspace organized.
## Tutorials
-### [Create report] How do I create an expense report?
-1. Navigate to **Create** > **Expense Report**.
-2. Add your receipts and manually log expenses.
-3. Categorize your expenses.
-4. Submit the report for approval.
+### Expense Reports
+#### [Create report] How do I create an expense report?
+To create an expense report:
+1. Press **Create** > **Expense Report** to start a new report.
+2. Add your receipts and manually log expenses for tracking.
+3. Categorize your expenses for organization.
+4. Submit the report for approval to the relevant supervisor.
+
+#### [Submit Expenses] What happens after I submit an expense?
+After submitting an expense, the next steps depend on whether it was sent to a workspace or an individual:
+- **Workspace submissions**: Automatically added to a report, checked for violations, and a chat is created. Reports are submitted for approval every Sunday, but can be manually submitted if ready.
+- **Individual submissions**: Sent via email or text, with chat option in Expensify Chat for discussions.
+
+#### [Approve expenses] How do I approve expense reports?
+To approve expense reports:
+1. Go to your Inbox and select the report needing approval.
+2. Review the receipts and expense details for accuracy.
+3. Press **Approve** or **Reject** based on your assessment.
+
+### Manage Workflows
+#### [Enable workflows] How do I enable workflows in Expensify?
+To enable workflows:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Workspaces** in the left menu.
+3. Select the desired workspace.
+4. Press **More features** in the left menu.
+5. Under the Spend section, toggle **Workflows** to enable approval settings.
+
+#### [Select workflows] How do I select workflow settings?
+To select workflow settings:
+1. Press **Workflows** in the left menu.
+2. Toggle the desired settings:
+ - **Add Approvals**: Select an approver for expenses requiring additional approval.
+ - **Delay Submissions**: Choose a frequency for automatic submission of expenses.
+
+#### [Add approvals] How do I add approvals to a workspace?
+To enable Add approvals on a workspace:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Workspaces** in the left menu.
+3. Select the workspace where you want to add approvals.
+4. Press **Workflows** in the left menu.
+5. Toggle **Add approvals**.
+
+Enabling **Add approvals** reveals the option to set a default approval workflow.
+
+#### [Configure approval workflows] How do I configure approval workflows in a workspace?
+To configure the default approval workflow:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Workspaces** in the left menu.
+3. Select the workspace where you want to set the approval workflow.
+4. Press **Workflows** in the left menu.
+5. Under **Expenses from Everyone**, press **First approver**.
+6. Select the workspace member as the first approver.
+7. Under **Additional approver**, continue selecting members.
+8. Press **Save**.
+
+To set a custom approval workflow for specific members:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Workspaces** in the left menu.
+3. Select the workspace where you want to add approvals.
+4. Press **Workflows** in the left menu.
+5. Under **Add approvals**, press **Add approval workflow**.
+6. Choose the specific member for the custom workflow.
+7. Press **Next**.
+8. Select the first approver.
+9. Press **Next**.
+10. Press **Additional approver** to select more members.
+11. Press **Add workflow** to save.
+
+#### [Edit or delete approval workflows] How do I manage approval workflows in Expensify?
+To edit an approval workflow:
+1. On the **Workflows** page, press the workflow to edit.
+2. Press the Approver field for the desired level.
+3. Select or deselect members as approvers.
+4. Press **Save**.
+
+To delete an approval workflow:
+1. On the **Workflows** page, press the workflow to delete.
+2. Press **Delete**.
+3. In the confirmation window, press **Delete** again.
+
+### Set Up Payment Account
+#### [Set up payment account] How do I set up a business bank account for workspace payments?
+To set up a business bank account for payments:
+1. Press **Workflows**.
+2. Enable the **Payments** toggle.
+3. Press **Connect Bank Account** and follow the prompts to connect your company bank account.
+4. Select an authorized expense payer, who is a workspace admin with access to the business bank account.
+
+### Expense Capture
+#### [SmartScan] How do I use SmartScan to capture receipts?
+To use SmartScan, follow these steps:
+1. Press the **+** icon and select **Submit Expense**.
+2. Press **Scan**.
+3. Take a photo of a receipt or upload it from your device. SmartScan will auto-populate details like merchant, date, and amount.
+4. Use the search field to find the desired workspace or person's name, email, or phone number.
+5. Add a description, category, tags, or tax as needed.
+6. (Optional) Enable the expense as billable if it should be billed to a client.
+7. Press **Submit**.
+
+#### [Manually add expense] How do I manually add an expense?
+To add an expense manually, follow these steps:
+1. Press the **+** icon and select **Submit Expense**.
+2. Press **Manual**.
+3. Enter the amount and press **Next**. Choose a currency if necessary.
+4. Use the search field to find the desired workspace or person's name, email, or phone number.
+5. (Optional) Add a description.
+6. Add a merchant.
+7. Press **Show more** to add additional fields like category if needed.
+8. Press **Submit**.
+
+### Manage Expense Tags
+#### [Create and manage tags] How do I create and manage expense tags?
+To create and manage expense tags in your workspace:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Workspaces** in the left menu.
+3. Select the desired workspace.
+4. Press **More features** and enable the **Tags** toggle in the Organize section.
+5. Press **Tags**.
+6. To add a tag, press **Add Tag**, enter a name, and press **Save**.
+7. To delete a tag, press the tag, press the three-dot menu, and select **Delete tag**.
+
+#### [Enable or disable tags] How do I enable or disable tags for expenses?
+To enable or disable tags:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Workspaces** in the left menu.
+3. Select the desired workspace.
+4. Press **Tags**.
+5. Press the tag and use the toggle to enable or disable it.
+
+#### [Add or edit a GL code] How do I add or edit a GL code for a tag?
+If your workspace is on the Control plan, you can add or edit a GL code for a tag:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Workspaces** in the left menu.
+3. Select the workspace.
+4. Press **Tags**.
+5. Press the tag to open tag-settings.
+6. Click the GL code field, make changes, and press **Save**.
+
+### Manage Expense Categories
+#### [Create categories] How do I create expense categories?
+To create expense categories:
+1. Press your profile image or icon in the bottom menu.
+2. Press **Workspaces**.
+3. Select the workspace you want to add categories to.
+4. Press **Categories**.
+5. Press **Add Category** and enter a name.
+6. Press **Save**.
+
+#### [Delete categories] How do I delete expense categories?
+To delete an expense category:
+1. Press the category in the **Categories** page.
+2. Press the three-dot menu in the top right.
+3. Press **Delete category** to permanently delete it.
+
+#### [Enable or disable categories] How do I enable or disable expense categories?
+To enable or disable categories:
+1. Press your profile image or icon in the bottom menu.
+2. Press **Workspaces**.
+3. Select a workspace.
+4. Press **Categories**.
+5. Press a category and use the toggle to enable or disable it.
+
+### Require Tags and Categories
+#### [Require tags and categories] How do I require tags and categories for expenses?
+To require workspace members to add tags and/or categories to their expenses:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Workspaces** in the left menu.
+3. Select a workspace.
+4. Press **Tags** or **Categories** in the left menu.
+5. Press **Settings** at the top right of the page.
+6. Enable the “Members must tag/categorize all expenses" toggle.
+7. If desired, repeat steps 4-6 for tags or categories (whichever you haven’t done yet).
+
+This will highlight the tag and/or category field as required on all expenses. Note that expenses can still be submitted without a tag and/or category, but the submitter and approver will see an orange dot on the expense details to alert them that the tag/category is missing.
+
+### Distance Expenses
+#### [Create distance expense] How do I create a distance expense?
+To create a distance expense:
+1. Press the **+** icon and select **Submit Expense**.
+2. Press **Distance**.
+3. Enter starting and ending locations.
+4. (Optional) Add stops by pressing **Add stop**.
+5. Press **Next**.
+6. Use the search field to find the desired workspace or person's name, email, or phone number.
+7. (Optional) Add a description.
+8. Press **Submit**.
+
+#### [Create and send a distance request] How do I create and send a distance request for reimbursement?
+To create and send a distance request for mileage reimbursement:
+1. Press the green **+** button and select **Request Money**.
+2. Press **Distance** on the Request Money screen.
+3. Enter the **Start** and **Finish** addresses and press **Next**. If you have multiple stops, add them before proceeding.
+4. Choose the recipient by selecting your organization's workspace from the recent workspaces list.
+5. On the confirmation page, review the amount, date, and distance. Optionally, add a description or category. Press **Request**.
+6. Your request will be sent to a workspace admin for approval and reimbursement through Expensify or other means.
+
+### Manage Distance Rates
+#### [Enable distance rates] How do I enable distance rates in a workspace?
+To enable distance rates in a workspace you manage:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Workspaces** in the left menu.
+3. Select the workspace where you want to enable distance rates.
+4. Press **More features** in the left menu.
+5. Toggle **Distance rates** to enable the feature.
+
+Once enabled, a new **Distance rates** option will appear in the left menu.
+
+#### [Add or manage distance rates] How do I add, edit, or delete distance rates?
+To manage distance rates:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Workspaces** in the left menu.
+3. Select the workspace where you want to manage distance rates.
+4. Press **Distance rates** in the left menu.
+
+To add a rate:
+1. Press **Add rate** in the top right.
+2. Enter a value and press **Save**.
+
+To edit or delete a rate:
+1. Press the desired distance rate.
+2. To enable or disable, use the toggle next to **Enable rate** and press **Save**.
+3. To edit, enter the new value and press **Save**.
+4. To delete, press **Delete**.
+
+For bulk actions:
+1. Use the checkboxes next to distance rates.
+2. Press "x selected" at the top right.
+3. Choose **Enable rates**, **Disable rates**, or **Delete rates** as needed.
+
+### Manage Tax Rates
+#### [Enable Taxes] How do I enable taxes on a workspace?
+To enable taxes on your workspace:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Workspaces** in the left menu.
+3. Select the workspace where you want to enable tax codes.
+4. Press **More features** in the left menu.
+5. Toggle **Taxes** to enable the feature.
+
+After enabling taxes, a new **Taxes** option will appear in the left menu.
+
+#### [Add or manage tax rates] How do I add, edit, or delete tax rates?
+To manage tax rates:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Workspaces** in the left menu.
+3. Select the workspace where you want to manage tax rates.
+4. Press **Taxes** in the left menu.
+
+To add a rate:
+1. Press **Add rate** in the top right.
+2. Enter a name, value, and tax code, then press **Save**.
+
+To edit or delete a rate:
+1. Press the desired tax rate.
+2. To enable or disable, use the toggle next to **Enable rate** and press **Save**.
+3. To edit, enter the new value and press **Save**.
+4. To delete, press **Delete**.
+
+For bulk actions:
+1. Use the checkboxes next to tax rates.
+2. Press "x selected" at the top right.
+3. Choose **Enable rates**, **Disable rates**, or **Delete rates** as needed.
+
+#### [Change Default Tax Rates] How do I change the default tax rates in a workspace?
+To change the default tax rates:
+1. On the **Taxes** settings page, press **Settings** in the top right.
+2. Press **Workspace currency default** or **Foreign currency default** and select the desired tax rate.
+
+### Bank Account Management
+#### [Connect Business Bank Account] How do I connect a business bank account in Expensify?
+To connect a business bank account:
+1. Enable the Make or Track Payments Workflow by navigating to **Workspaces** > **More Features** > **Enable Workflows**, then press **Workflows** and enable **Make or Track Payments**.
+2. Press **Connect Bank Account** and select either **Connect Online with Plaid** or **Connect Manually**.
+3. Enter your bank details.
+4. Upload a photo of your ID and take a selfie video for verification.
+5. Enter your company information, including business name, address, tax ID, and website.
+6. Provide additional information on beneficial owners if applicable.
+7. Verify all details are accurate and accept the agreement terms.
+
+#### [Validate Business Bank Account] How do I validate a business bank account in Expensify?
+To validate your business bank account:
+1. Navigate to **Settings > Workspaces > _Workspace Name_ > Bank account** to check the status.
+2. If the status is **Verifying**, check your email for further instructions. If **Pending**, proceed to the next step.
+3. Wait 1-2 business days for Expensify to send three test transactions to your bank account.
+4. In the **Bank accounts** section of your workspace settings, input the transaction amounts as prompted.
+
+Once completed, your business bank account is validated and ready for use in Expensify.
+
+#### [Unlock Business Bank Account] How do I unlock a business bank account?
+If your business bank account is locked due to a rejected withdrawal request, follow these steps to unlock it:
+1. Go to **Settings > Workspaces > _Workspace Name_ > Bank account** and press **Fix**. This sends a request to our support team to review the reason for the lock. They will provide you with the necessary next steps.
+2. Be patient, as unlocking the account can take several business days due to ACH processing times and clawback periods.
+
+If you need to enable direct debits from your verified bank account, provide your bank with the following details:
+- **For Expensify**:
+ - ACH CompanyIDs: 1270239450, 4270239450, 2270239450
+ - ACH Originator Name: Expensify
+- **For Bill Payments with Stripe**:
+ - ACH CompanyIDs: 1800948598, 4270465600
+ - ACH Originator Name: expensify.com
+- **For International Reimbursements with CorPay**:
+ - ACH CompanyIDs: 1522304924, 2522304924
+ - ACH Originator Name: Cambridge Global Payments
+
+#### [Connect Personal Bank Account] How do I connect a personal bank account to Expensify?
+To connect a personal bank account for receiving payments and reimbursements:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Wallet**.
+3. Press **Add Bank Account** to initiate the process.
+4. Press **Continue** to redirect to Plaid for secure bank account verification.
+5. Follow the prompts to enter your bank account details via Plaid.
+6. Once done, return to Expensify to complete the linking process.
+7. Choose the account you wish to connect and press **Save & continue**.
+
+Once connected, payments and reimbursements will be automatically deposited into the linked bank account.
+
+### Invoice Management
+#### [Enable Invoicing] How do I enable invoicing on a workspace?
+To enable invoicing:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Workspaces** and select the workspace for which you want to enable invoicing.
+3. Press **More features** in the left menu.
+4. Under the Earn section, enable the **Invoice** toggle.
+
+#### [Send Invoice] How do I send an invoice using Expensify?
+To send an invoice:
+1. Press the **+** icon in the bottom left menu and select **Send Invoice**.
+2. Enter the amount due and press **Next**.
+3. Enter the email or phone number of the person who should receive the invoice.
+4. (Optional) Add additional invoice details, including description, date, category, tag, and/or tax.
+5. Press **Send**.
+
+#### [Receive Invoice Payment] How do I receive invoice payments?
+If you have not connected a business bank account to receive invoice payments, you will see an **Invoice balance** in your Wallet. Expensify will automatically transfer these invoice payments once a business bank account is connected.
+
+#### [Pay an Invoice] How do I pay an invoice in Expensify?
+To pay an invoice in Expensify, follow these steps:
+
+1. Press the link in the email or text notification you receive from Expensify.
+2. Press **Pay**.
+3. Choose to **Pay as an individual** or **Pay as a business**.
+4. Press **Add Bank Account** or **Add debit or credit card** to issue payment.
+
+You can also view all unpaid invoices by searching for the sender’s email or phone number on the left-hand side of the app. The invoices waiting for your payment will have a green dot.
+
+### Expense Management
+#### [Approve and Pay Expenses] How do I approve and pay expenses in Expensify?
+To manage expenses effectively, follow these steps:
+
+1. **Manually Approve an Expense**:
+ - Open the Expensify Chat thread for the expense.
+ - Press the expense or group of expenses.
+ - Review the details, ensuring receipt, amount, and description accuracy.
+ - Determine the next step: Approve, hold, or request changes.
+
+2. **Approve Expenses**:
+ - Open the Expensify Chat thread for the expense.
+ - Press the expense or group of expenses.
+ - Review the expense details for correctness.
+ - Decide the next steps:
+ - **Approve**: When satisfied, press **Approve**.
+ - **Handle Holds**: Choose to approve non-held expenses or the full amount, including held ones.
+ - **Request Changes**: Add a comment in the chat thread to request any changes.
+
+3. **Hold an Expense**:
+ - Open the Expensify Chat thread for the expense.
+ - Press the expense or group of expenses.
+ - Press the three-dot menu and select **Hold**.
+ - Enter a reason for the hold.
+ - Review the hold overview and press **Got It**.
+ - When ready, remove the hold or approve the expense.
+
+4. **Unapprove an Expense**:
+ - Press the workspace logo in the top left.
+ - Select the workspace with the expense report.
+ - Search for the approved report.
+ - Press the dropdown arrow for report actions.
+ - Press **Unapprove**.
+
+5. **Pay Expenses**:
+ - Open the Expensify Chat thread for the expense.
+ - Press the expense or group of expenses.
+ - Select a payment option:
+ - Press **Pay** to pay the full amount within Expensify.
+ - Press **Pay Elsewhere** if payment is made outside Expensify.
+
+#### [Review & Resolve Duplicates] How do I handle duplicate expense requests?
+To review and resolve duplicate expenses:
+1. Press the red dot in the left menu or open the expense’s chat room to view the flagged request.
+2. Press the green **Review duplicates** button at the top of the request.
+3. Review the list of potential duplicates.
+4. To resolve a duplicate, press either **Keep all** or **Keep this one**.
+ - **Keep all**: Retains all expenses as separate charges and removes the hold.
+ - **Keep this one**: Retains this expense and discards its other related duplicates.
+5. If discrepancies exist between the duplicates (e.g., category, tags), choose which details to keep.
+6. Confirm your selection to merge the requests or keep all.
+
+The expenses are removed from the duplicates list and the hold is removed.
-### [SmartScan] How do I use SmartScan to capture receipts?
-1. Snap a photo of your receipt or upload it to Expensify.
-2. Let SmartScan automatically detect and categorize the expense.
+#### [Track Expenses] How do I track expenses in Expensify?
+To create, store, or share non-reimbursable expenses using the Track Expenses feature:
+1. Press the **+** icon in the bottom menu and select **Track Expense**.
+2. Create the expense manually, scan the receipt, or add a distance expense.
+3. Choose the next steps for the expense:
+ - **Submit it to someone**: Select this option to request payment from a contact or other members of your Expensify workspace.
+ - **Categorize it**: Select this option to choose a category and additional details to code the expense for a specific workspace. The expense will then be placed on a report and can be submitted to the workspace for approval.
+ - **Share it with my accountant**: Select this option to share the expense with your accountant. The expense will then be placed on a report under the workspace for your accountant to review.
+ - **Nothing for now**: Select this option to store the expense. Expensify will keep the expense until you are ready to take action on it—it won’t expire. When you’re ready, you can then select one of the above options for the expense at a later time.
-### [Approve expenses] How do I approve expense reports?
-1. Go to your Inbox and select the report requiring approval.
-2. Review the receipts and expense details.
-3. Click **Approve** or **Reject**.
+#### [Split an Expense] How do I split an expense with others?
+Splitting an expense allows the person who paid the bill to request money from multiple people who will split the cost with them. To split an expense:
+1. Press the **+** icon and select **Split Expense**.
+2. Upload a photo of your receipt or manually enter the total bill amount.
+3. Press **Next**.
+4. Enter the names, email addresses, or phone numbers for the people you want to request money from. Note: You can select multiple people.
+5. Press **Next**.
+6. (Optional) Enter a reason for the request in the Description field.
+7. (Optional) If you manually entered the bill amount, add the merchant and date of purchase.
+8. Press **Split**.
+
+Each person will receive an email or text with the details of the request. You can also chat with them about the expense in Expensify Chat, and you can receive payments through your Expensify Wallet or outside of Expensify.
+
+### Manage Report Fields
+#### [Enable Report Fields] How do I enable report fields on a workspace?
+To enable report fields on a workspace:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Workspaces** in the left menu.
+3. Select the workspace you want to enable report fields for.
+4. Press **More features** and toggle **Report Fields** to enable them.
+
+#### [Create Report Fields] How do I create new report fields?
+To create new report fields:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Workspaces** in the left menu.
+3. Select the workspace you want to create report fields on.
+4. Press **Report Fields** in the left menu.
+5. Press **Add Field** in the top right corner to create a new field.
+6. Enter a name for your report field and select the field type (Text, Date, or List).
+7. Press **Save** to finalize the new field.
+
+#### [Edit or Delete Report Fields] How do I edit or delete existing report fields?
+To edit or delete existing report fields:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Workspaces** in the left menu.
+3. Select the workspace with the report fields you want to edit or delete.
+4. Press **Report Fields** in the left menu.
+5. Select the report field you wish to edit or delete.
+6. Make the required edits in the right-hand panel, or press **Delete**.
+
+### Accounting Integrations
+#### [Connect to QuickBooks Online] How do I connect Expensify to QuickBooks Online?
+To integrate with QuickBooks Online:
+1. Press your profile image or icon in the bottom left menu to access settings.
+2. Press **Workspaces** and select the workspace you want to connect to QuickBooks Online.
+3. Press **More features** and enable the Accounting toggle.
+4. Press **Accounting** and then **Set up** next to QuickBooks Online.
+5. Enter your Intuit login details to import your settings.
+
+#### [Configure QuickBooks Online] How do I configure QuickBooks Online settings in Expensify?
+Configuring QuickBooks Online involves setting import, export, and advanced settings for seamless integration with Expensify.
+
+1. **Import Settings**:
+ - Under Accounting, select **Import** under QuickBooks Online.
+ - Review settings for Chart of Accounts, Classes, Customers/Projects, Locations, and Taxes.
+
+2. **Export Settings**:
+ - Under Accounting, select **Export** for QuickBooks Online.
+ - Review settings for Preferred Exporter, Export Out-of-Pocket Expenses, and Invoices.
+
+3. **Advanced Settings**:
+ - Select **Advanced** under QuickBooks Online.
+ - Set options for Auto-sync, Invite Employees, Automatically Create Entities, and Sync Reimbursed Reports.
+
+#### [Connect to Xero] How do I connect Expensify to Xero?
+To integrate with Xero:
+1. Press your profile image or icon in the bottom left menu to access settings.
+2. Press **Workspaces** and select your desired workspace.
+3. Press **More features** and enable the Accounting toggle.
+4. Press **Accounting** and then **Set up** next to Xero.
+5. Enter your Xero login details to import your settings.
+
+#### [Configure Xero] How do I configure Xero settings in Expensify?
+To configure Xero settings:
+1. Under the Accounting settings for your workspace, press **Import** under the Xero connection.
+2. Select options for settings like Xero organization, Chart of Accounts, Tracking Categories, Re-bill Customers, and Taxes.
+3. Under the Accounting settings, press **Export** for Xero connection configuration.
+4. Review export settings like Preferred Exporter, Export Out-of-Pocket Expenses, and Xero Bank Account.
+5. Press **Advanced** under Xero connection to set Auto-sync, Set Purchase Bill Status, Sync Reimbursed Reports, and other advanced settings.
+
+#### [Connect to Sage Intacct] How do I connect Expensify to Sage Intacct?
+To integrate with Sage Intacct:
+1. In Expensify, go to **Settings > Workspaces > [Workspace Name] > Accounting**.
+2. Press **Set up** next to Sage Intacct and enter your credentials.
+3. Press **Confirm** to finalize the setup.
+
+#### [Configure Sage Intacct] How do I configure Sage Intacct settings in Expensify?
+To configure Sage Intacct:
+1. Navigate to **Accounting settings** and select **Entity** under Sage Intacct to choose the entity.
+2. Press **Import** to set preferences for categories, expenses, and dimensions.
+3. Press **Export** to choose exporter and methods for expenses.
+4. Press **Advanced** to enable features like auto-sync.
+
+#### [Connect to NetSuite] How do I connect Expensify to NetSuite?
+To integrate with NetSuite:
+1. Log into Expensify as a workspace admin and press your profile image or icon in the bottom left menu.
+2. Scroll down and press **Workspaces** and select the workspace you want to connect to NetSuite.
+3. Press **More features** and enable the Accounting toggle.
+4. Press **Accounting** and then **Set up** next to NetSuite.
+5. Enter your NetSuite Account ID, Token ID, and Token Secret. These can be found in NetSuite under **Setup > Integration > Web Services Preferences**.
+6. Press **Confirm** to complete the setup.
+
+#### [Configure NetSuite] How do I configure NetSuite settings in Expensify?
+To configure NetSuite settings:
+1. Ensure the Expensify Bundle is installed in NetSuite by going to **Customization > SuiteBundler > Search & Install Bundles**.
+2. Enable Token-Based Authentication in NetSuite under **Setup > Company > Enable Features > SuiteCloud > Manage Authentication**.
+3. Add the Expensify Integration Role to a user in NetSuite under **Lists > Employees** and manage access.
+4. Create Access Tokens in NetSuite by entering "page: tokens" in the Global Search and selecting **New Access Token**.
+5. Confirm Expense Categories and Reports are enabled in NetSuite under **Setup > Accounting** and **Employees**.
+6. Follow the detailed steps for ensuring transaction forms are properly configured in NetSuite for Expense Reports, Journal Entries, Vendor Bills, and Credits.
+
+### Exporting Data
+#### [Export Expenses] How do I export expenses to a CSV file?
+To export your expense data to a CSV file:
+1. Press the **Search** tab in the bottom left menu to view your expenses.
+2. Select the checkbox next to the expenses or reports you wish to export.
+3. Press **# selected** at the top-right and select **Download** to export.
+
+ The CSV download will save locally to your device with the file naming prefix "Expensify." This file includes data such as Date, Merchant, Description, From, To, Category, Tag, Tax, Amount, Currency, Type, and Receipt URL.
+
+#### [Exporting Reports to Xero] How do I export reports to Xero manually?
+If an error occurs during an automatic export to Xero:
+1. Check your email or the related Workspace Chat for error notifications.
+2. Resolve the issue by opening the expense and making necessary changes.
+3. Ensure the report is in the Approved, Closed, or Reimbursed state.
+4. An admin must press the heading at the top of the expense, select **Export**, and then choose **Xero**.
+
+#### [Exporting Reports to QuickBooks Online] How do I manually export reports to QuickBooks Online?
+If an error occurs during an automatic export to QuickBooks Online:
+1. Check your email or the related Workspace Chat for error notifications.
+2. Open the expense and make necessary changes.
+3. Ensure the report is in the Approved, Closed, or Reimbursed state.
+4. An admin must press the heading at the top of the expense, select **Export**, and then choose **QuickBooks Online**.
+
+#### [QuickBooks Online Manual Export Troubleshooting] Why can't I manually export a report to QuickBooks Online?
+To export a report to QuickBooks Online, the report must be in the Approved, Closed, or Reimbursed state. If the report is in the Open state, pressing **Export** will lead to an empty page. Ensure the report is submitted or approved if it's in the Processing state. Once these changes are made, an admin can manually export the report to QuickBooks Online.
## FAQ
-### Why should I use Expensify Expense for my business?
-Expensify Expense automates time-consuming processes like receipt capture, approval workflows, and reimbursement, saving you time and improving accuracy.
+### General Inquiries
+#### Why should I use Expensify Expense for my business?
+Expensify Expense automates processes like receipt capture, workflows, and reimbursement, saving time and improving accuracy.
+
+#### How do SmartScan limits work?
+SmartScan allows you to scan a set number of receipts each month for free, with more available under paid plans.
+
+#### Can I use Expensify Expense for free?
+Yes, Expensify Expense offers a free plan with basic features, with advanced plans for larger business needs.
+
+#### How does Expensify support multi-currency expenses?
+Expensify converts expenses to your preferred currency and supports global reimbursement.
+
+### Workflow Management
+#### [Delayed Submission and Approvals] If I have delayed submission and an approver, what should I expect with a report?
+When Add Approver is enabled with Delay Submission, expense reports go from Open > Processing > Approved. If delayed submission is disabled, expense reports go from Processing > Approved.
+
+#### Can an employee have more than one approval workflow?
+No, each employee can have only one approval workflow.
+
+### Integration and Export
+#### [Disconnect from Xero] How do I disconnect Xero from Expensify?
+To disconnect Xero:
+1. Press your profile image or icon in the bottom left menu.
+2. Press **Workspaces** and select your workspace.
+3. Press **Accounting**.
+4. Press the three-dot menu next to Xero and select **Disconnect**.
+5. Press **Disconnect** again to confirm.
+
+You will no longer see the imported options from Xero.
+
+#### [Xero Export Confirmation] How do I know if a report successfully exported to Xero?
+When a report exports successfully, a message is posted in the related Expensify Chat room.
+
+#### [Duplicate Report Handling] What happens if I manually export a report that has already been exported?
+When an admin manually exports a report, Expensify will warn them if the report has already been exported. If the admin chooses to export it again, it will create a duplicate report in Xero. You will need to delete the duplicate entries from within Xero.
+
+#### [Auto Sync Impact] What happens to existing reports that have already been approved and reimbursed if I enable Auto Sync?
+- If Auto Sync was disabled when your Workspace was linked to Xero, enabling it won’t impact existing reports that haven’t been exported.
+- If a report has been exported and reimbursed via ACH, it will be automatically marked as paid in Xero during the next sync.
+- If a report has been exported and marked as paid in Xero, it will be automatically marked as reimbursed in Expensify during the next sync.
+- If a report has not yet been exported to Xero, it won’t be automatically exported.
+
+#### [Report Export to Sage Intacct] Why wasn't my report automatically exported to Sage Intacct?
+There are a number of factors that can cause auto-export to fail. If this happens, you will find the specific export error in the report comments for the report that failed to export. Once you’ve resolved any errors, you can manually export the report to Sage Intacct.
+
+#### [Negative Expenses to Sage Intacct] Can I export negative expenses to Sage Intacct?
+Yes, you can export negative expenses to Sage Intacct. If you are exporting out-of-pocket expenses as expense reports, then the total of each exported report cannot be negative.
+
+#### [NetSuite Plan Requirement] What type of Expensify plan is required to connect to NetSuite?
+You need a Control workspace to integrate with NetSuite. If you have a Collect workspace, you will need to upgrade to Control.
+
+#### [NetSuite Page Size] What should I set my page size to in NetSuite for importing customers and vendors?
+Make sure your page size is set to 1000 in NetSuite for importing your customers and vendors. Go to **Setup > Integration > Web Services Preferences** and search **Page Size** to determine your page size.
+
+#### [NetSuite Export Options] What are the export options for NetSuite?
+You can export out-of-pocket expenses and company card expenses as Expense Reports, Vendor Bills, or Journal Entries in NetSuite. For invoices, select an Accounts Receivable account. Export settings can be configured to choose the date for records, export foreign currency amounts, and export to the next open period if a period is closed.
+
+#### [QuickBooks Online Error Resolution] Why do I see a red dot next to my QuickBooks Online connection?
+If there is an error with your connection, you’ll see a red dot next to Accounting in the left menu. When you press Accounting, you’ll also see a red dot displayed next to the QuickBooks Online connection card. This may occur if you incorrectly enter your QuickBooks Online login information when trying to establish the connection. To resubmit your login details:
+1. Press the three-dot menu to the right of the QuickBooks Online connection.
+2. Press **Enter credentials**.
+3. Enter your Intuit login details to establish the connection.
+
+#### [QuickBooks Online Export Confirmation] How do I know if a report is successfully exported to QuickBooks Online?
+When a report exports successfully, a message is posted in the expense’s related chat room.
+
+#### [Duplicate Report Handling in QuickBooks Online] What happens if I manually export a report that has already been exported?
+When an admin manually exports a report, Expensify will notify them if the report has already been exported. Exporting the data again will create a duplicate report in QuickBooks Online.
+
+#### [Auto Sync Impact for QuickBooks Online] What happens to existing approved and reimbursed reports if I enable Auto Sync?
+- If Auto Sync was disabled when your Workspace was linked to QuickBooks Online, enabling it won’t impact existing reports that haven’t been exported.
+- If a report has been exported and reimbursed via ACH, it will be automatically marked as paid in QuickBooks Online during the next sync.
+- If a report has been exported and marked as paid in QuickBooks Online, it will be automatically marked as reimbursed in Expensify during the next sync.
+
+Reports that have yet to be exported to QuickBooks Online won’t be automatically exported.
+
+#### [Report Exporting to Xero Troubleshooting] Why can't I manually export a report to Xero?
+To export a report to Xero, it must be in the Approved, Closed, or Reimbursed state. If it is in the Open state, pressing **Export** will lead to a notification that the data is not yet available for export. Make sure the report is submitted or approved if it's in the Processing state. Once these changes are made, an admin can manually export the report to Xero.
+
+### Exporting and Downloading Options
+#### [CSV Export Options] Can I export in a different format, like PDF or XLS?
+No, currently Expensify supports CSV export only.
+
+#### [CSV Customization] Can I add columns to the CSV download to capture additional data points?
+No, the CSV template cannot be customized.
+
+#### [Bulk Selection] Can I select expenses or reports in bulk for exporting?
+Yes, you can select expenses or reports in bulk by using the **Select multiple** or **Select all** option. To display these options on the mobile app, simply long press an item.
+
+### Invoicing and Payment
+#### [Workspace Requirement] Why do I need to create a workspace to send an invoice?
+A workspace is a configuration of settings related to your business. Since invoicing is considered a business feature, you must have a workspace to configure and use invoicing.
+
+#### [Invoice Communication] How do I communicate with the sender/recipient about the invoice?
+Expensify will automatically notify the invoice recipient about the new invoice via email, SMS, and a mobile app notification, along with instructions on how to pay it. Daily reminders will be sent until the invoice is paid. Additionally, an invoice chat room will be automatically created in Expensify between the invoice sender, their workspace admins, and the payer. You can use this chat to discuss anything related to the invoice.
+
+#### [Invoice Export] Can you export invoices between an accounting integration?
+Yes, you can export invoices between Expensify and your connected accounting integration.
+
+#### [Invoice Permissions] Who can send and pay an invoice?
+All workspace admins will be able to send and pay invoices. Invoices can also be paid by anyone, including recipients without an Expensify account.
+
+#### [Disable Invoicing] What happens if I disable invoicing in the future?
+When invoicing is disabled, all previously created invoice rooms and historical invoices will remain unaffected and continue to exist. However, all workspace admins will no longer have the option to send an invoice.
+
+#### [Business Bank Account Error] Why am I getting an error after I enter my website when connecting a business bank account?
+We can only accept a private domain website to ensure the security of your business. If you receive an error when entering your website, it is likely because the domain is not recognized as private. Make sure you are using a business email with a private domain. If you continue to experience issues, contact our support team at concierge@expensify.com for further assistance.
+
+### Duplicate Handling
+#### [Duplicate Expense Handling] What should I do if an expense is flagged as a duplicate?
+If an expense is flagged as a duplicate, you can review and resolve it by selecting to keep all duplicates or only one. Adjust and confirm any discrepancies before finalizing your choice.
+
+#### [Duplicate Detection Criteria] When are expenses flagged as duplicates?
+Expenses are flagged as duplicates if they have the same date and amount unless:
+- They were split from a single expense.
+- They were imported from a credit card.
+- Matching email receipts were received with different timestamps.
+
+#### [Concierge Duplicate Alert] What should I do if Concierge flags a receipt as a duplicate?
+If Concierge flags a receipt as a duplicate, scanning the receipt again will trigger the same alert. You can review these in the deleted filter on Expensify Classic.
+
+#### [Edit Duplicate Requests] Can I edit a duplicate request once resolved?
+Yes, you can edit a duplicate request after it has been resolved, but ensure the hold is first removed.
+
+#### [Review Discarded Duplicates] Can I review a discarded duplicate later?
+Yes, approvers can review discarded duplicates to ensure accuracy and prevent fraud.
+
+### Invoice Payment Options
+#### [Invoice Payment Options] What are the payment options for invoices in Expensify?
+When paying an invoice, you can choose to pay as an individual or as a business. You can add a bank account or use a debit or credit card to issue payment. Only the person who received the invoice will see the option to pay it. If you want to pay an invoice outside of Expensify, you will need to coordinate with the vendor to discuss alternative payment options.
+
+#### [Adding Payment Methods] Can I add additional payment methods for paying invoices?
+Yes, you can add additional payment methods to your Expensify Wallet. To do this, go to Account Settings > Wallet, then press Add Bank Account. This allows you to choose a payment method when paying future invoices.
+
+#### [Invoice Sending Limitations] Can anyone send an invoice through Expensify?
+Only Expensify customers can send an invoice. This feature is designed to ensure that businesses using Expensify can manage their invoice billing processes efficiently while providing flexibility for their customers to make payments.
+
+#### [Invoice Visibility] Can someone other than the recipient pay an invoice?
+No, only the person who received the invoice will see the option to pay it. This ensures secure and accurate processing of invoice payments.
+
+#### [Unpaid Invoices] How can I view unpaid invoices?
+To view all unpaid invoices, search for the sender’s email or phone number on the left-hand side of the app. Invoices awaiting your payment will have a green dot.
+
+#### [Invoice Chat Communication] How can I communicate about an invoice?
+You can chat directly with your vendor at expensify.com in the designated invoice room to discuss invoice-related matters.
+
+### Bank Account Requirements
+#### [Business Bank Account Requirements] What are the general requirements for adding a business bank account?
+To add a business bank account to issue reimbursements via ACH (US) or to issue Expensify Cards:
+- Enter a physical address for yourself, any Beneficial Owner (if one exists), and the business associated with the bank account. We cannot accept a PO Box or MailDrop location.
+- We are required by law to verify your identity. Part of this process requires you to verify a US-issued photo ID. Your ID must be issued by the United States to use features related to US ACH. You and any Beneficial Owner (if one exists) must also have a US address.
+
+#### [Beneficial Owner Definition] What is a Beneficial Owner?
+A Beneficial Owner refers to an **individual** who owns 25% or more of the business. If no individual owns 25% or more of the business, the company does not have a Beneficial Owner.
+
+#### [Beneficial Owner Details] What do I do if the Beneficial Owner section only asks for personal details, but my organization is owned by another company?
+Please indicate you have a Beneficial Owner only if it is an individual who owns 25% or more of the business.
+
+#### [Address and ID Verification] Why can’t I input my address or upload my ID?
+When adding a verified business bank account in Expensify, the individual adding the account and any beneficial owner (if one exists) are required to have a US address, US photo ID, and a US SSN. If you do not meet these requirements, you’ll need to have another admin add the bank account and then share access with you once it is verified.
+
+#### [Document Requests] Why am I asked for documents when adding my bank account?
+When a bank account is added to Expensify, we complete a series of checks to verify the information provided to us. We conduct these checks to comply with both our sponsor bank's requirements and federal government regulations, specifically the Bank Secrecy Act / Anti-Money Laundering (BSA / AML) laws. Expensify also has anti-fraud measures in place.
+
+If automatic verification fails, we may request manual verification, which could involve documents such as address verification for your business, a letter from your bank confirming bank account ownership, etc.
+
+If you have any questions regarding the documentation request you received, please contact Concierge and they will be happy to assist.
+
+### Bank Account Validation
+#### [Microtransaction Validation] I don’t see all three microtransactions I need to validate my bank account. What should I do?
+Wait until the end of the second business day. If you still don’t see them, please contact your bank and ask them to whitelist our ACH IDs **1270239450**, **4270239450**, and **2270239450**. Expensify’s ACH Originator Name is "Expensify."
+
+Once that's all set, make sure to contact your account manager or concierge, and our team will be able to re-trigger those three test transactions!
+
+#### [Test Deposits for Validation] How many test deposits will I receive when validating my business bank account?
+You will receive two withdrawals and one deposit in your business bank account to complete the validation process.
+
+#### [Missing Test Deposits] What should I do if I don't see the test deposits in my business bank account after two business days?
+If the test deposits are not visible after two business days, it may be due to direct debits not being enabled on your bank account. In such cases, provide your bank with the following details:
+- ACH CompanyIDs: 1270239450, 4270239450, 2270239450
+- ACH Originator Name: Expensify
+
+If the issue persists, please contact Expensify Support for further assistance.
+
+### Sage Intacct Integration
+#### [Configure Sage Intacct] How do I set up and configure Sage Intacct in Expensify?
+To connect and configure Sage Intacct:
+1. **Connect to Sage Intacct**: Go to **Settings > Workspaces > [Workspace Name] > Accounting** and press **Set up** next to Sage Intacct, then enter your credentials to complete the setup.
+2. **Select Entity**: Choose the Sage Intacct entity to connect each Expensify workspace to, especially for multi-entity setups.
+3. **Import Settings**: Navigate to Accounting settings, press **Import** under Sage Intacct, and set preferences for expense types, categories, dimensions, customers/projects, and tax.
+4. **Export Settings**: Access export options under **Export**, choosing your preferred exporter and export methods for out-of-pocket and company card expenses.
+5. **Advanced Settings**: Enable auto-sync, invite employees, and configure reimbursement sync under **Advanced** settings to ensure seamless integration.
-### How do SmartScan limits work?
-SmartScan allows you to scan a set number of receipts each month for free, with additional scans available under paid plans.
+#### [Frequently Asked Questions] What are common questions about using Sage Intacct with Expensify?
+Some common concerns include:
+- **Auto-sync**: Only newly approved reports will be auto-exported to Sage Intacct. Existing approved reports must be manually exported.
+- **Negative Expenses**: Negative expenses can be exported, but out-of-pocket expense reports cannot be entirely negative.
+- **Export Errors**: If auto-export fails, check report comments for specific errors and resolve them before attempting manual export.
-### Can I use Expensify Expense for free?
-Yes, Expensify Expense offers a free plan with basic features, and advanced plans are available for businesses with larger needs.
+### Sage Intacct Tutorials
+#### [Configure Import Settings] How do I configure import settings for Sage Intacct?
+To configure import settings:
+1. Under Accounting settings, press **Import** for Sage Intacct.
+2. Choose how to import categories, dimensions, customers/projects, and tax.
+3. Configure expense types and chart of accounts based on how you plan to export expenses.
+4. Set up mapping for billable expenses by enabling necessary permissions in Sage Intacct.
-### How does Expensify support multi-currency expenses?
-Expensify automatically converts expenses to your preferred currency and supports global reimbursement.
+#### [Configure Export Settings] How do I configure export settings for Sage Intacct?
+To configure export settings:
+1. In Accounting settings, press **Export** under Sage Intacct.
+2. Choose the preferred exporter and export date options.
+3. Decide whether to export out-of-pocket expenses as expense reports or vendor bills.
+4. Set export preferences for company card expenses, selecting between credit card charges or vendor bills.
+#### [Manage Advanced Settings] How do I manage advanced settings for Sage Intacct?
+To manage advanced settings:
+1. Navigate to **Settings > Workspaces > [Workspace name] > Accounting** and press **Advanced** under Sage Intacct.
+2. Enable auto-sync for daily updates and automatic export of expenses.
+3. Use the invite employees feature to add Sage Intacct users to Expensify.
+4. Configure reimbursement sync to reflect accurate status between Expensify and Sage Intacct.
\ No newline at end of file
diff --git a/help/index.md b/help/index.md
index dde0e97da851..45366fecae11 100644
--- a/help/index.md
+++ b/help/index.md
@@ -3,558 +3,503 @@ layout: product
title: Expensify
---
## Introduction
-The Expensify Superapp packs the full power of 6 world-class business, finance, and collaboration products into a single app that works identically on desktop and mobile, efficiently with your colleagues, and seamlessly with your customers, vendors, family, and friends.
+The Expensify Superapp combines 6 world-class business, finance, and collaboration products into one app. It works identically on desktop and mobile, with colleagues and customers, and for personal use.
-### [Main uses] When should I use Expensify?
+### Main uses
Expensify can do a lot. You should check us out whenever you need to:
-* **Track and manage expenses** - Whether you are reimbursing employee receipts, deducting personal expenses, or just splitting the bill, Expensify Expense is for you.
-* **Issue corporate cards** - Skip the reimbursement and capture receipts electronically in real-time by issuing the Expensify Card to yourself and your employees.
-* **Book and manage travel** - If you are booking your own business trip, arranging a trip for a colleague, or managing the travel of your whole company, Expensify Travel has got you covered.
-* **Chat with friends and coworkers** - Whether it's collaborating with your team, supporting your client, negotiating with your vendor, or just saying Hi to a friend, Expensify Chat connects you with anyone with an email address or SMS number.
-* **Collect invoice payments online** - Expensify Invoice allows you to collect online payments from consumers and businesses alike – anyone with an email address or SMS number.
-* **Approve and pay bills online** - Scan, process, and approve bills online using Expensify Billpay, then we'll pay them electronically or via check, whatever they prefer.
-
-If you send, receive, or spend money – or even just talk to literally anyone, about literally anything – Expensify is the tool for you.
-
-### [Core users] Who uses Expensify?
-Expensify offers something for everyone. Some people who commonly use us include:
-* **Individuals** - Millions of individuals use Expensify to track personal expenses to maximize their tax deductions, stay within personal budgets, or just see where their money is going.
-* **Friends** - Expensify is a great way to split bills with friends, whether it's monthly rent and household expenses, a big-ticket bachelorette party, or just grabbing drinks with friends.
-* **Employees** - Road warriors and desk jockeys alike count on Expensify to reimburse expense reports they create in international airports, swanky hotels, imposing conference centers, quaint coffee shops, and boring office supply stores around the world.
-* **Managers** - Bosses manage corporate spend with Expensify to empower their best (and keep tabs on their… not-so-best), staying ahead of schedule and under budget.
-* **Accountants** - Internal accountants, fractional CFOs, CAS practices – you name it, they use Expensify to Invoice customers, process vendor bills, capture eReceipts, manage corporate spend: the whole shebang. If you're an accountant, we're already best friends.
-* **Travel managers** - Anyone looking to manage employee travel has come to the right place.
-
-If you are a person online who does basically anything, you can probably do it with Expensify.
-
-### [Key advantages] Why should I use Expensify?
-Though we do a lot, you've got a lot of options for everything we do. But you should use us because we are:
-* **Simple enough for individuals** - We've worked extremely hard to make a product that strips out all the complex jargon and enterprise baggage, and gives you a simple tool that doesn't overwhelm you with functionality and language you don't understand.
-* **Powerful enough for enterprises** - We've worked extremely hard to make a product that "scales up" to reveal increasingly sophisticated features, but only to those who need it, and only when they need it. Expensify is used by public companies, multinational companies, companies with tens of thousands of employees, non-profits, investment firms, accounting firms, manufacturers, and basically every industry in every currency and in every country around the world. If you are a company, we can support your needs, no matter how big or small.
-* **6 products for the price of 1** - Do you pay for an expense management system? A corporate card? A travel management platform? An enterprise chat tool? An invoicing tool? A billpay tool? Now you don't need to. Expensify's superapp design allows us to offer ALL these features on a single platform, at probably less than what you pay for any of them individually.
-* **Supports everyone everywhere** - Expensify works on iPhones and Androids, desktops and browsers. We support every currency and can reimburse to almost any country. You don't need to be an IT wizard – if you can type in their email address or SMS number, you can do basically everything with them.
-* **You get paid to use it** - Do you spend money? Spend it on the Expensify Card and we pay you up to 2% cashback. It's your money after all.
-* **Revenue share for accountants** - Do you manage the books for a bunch of clients? Become an Expensify Approved Accountant and take home 0.5% revenue share. Or share it with your clients as a discount, up to you!
-
-You are in the driver's seat; we're here to earn your business. But we're going to work harder for you than the other guys, and you won't be disappointed.
+* **Track and manage expenses** - Whether reimbursing employee receipts, deducting personal expenses, or splitting a bill, Expensify Expense is for you.
+* **Issue corporate cards** - Skip reimbursement and capture receipts in real-time by issuing the Expensify Card to yourself and employees.
+* **Book and manage travel** - If booking your own trip, arranging for a colleague, or managing company travel, Expensify Travel has you covered.
+* **Chat with friends and coworkers** - Collaborate with your team, support clients, negotiate with vendors, or just say Hi with Expensify Chat.
+* **Collect invoice payments online** - Expensify Invoice lets you collect online payments from anyone with an email or SMS number.
+* **Approve and pay bills online** - Scan, process, and approve bills online with Expensify Billpay, and we'll pay them electronically or via check.
+
+If you send, receive, or spend money – or talk to anyone about anything – Expensify is for you.
+
+### Core users
+Expensify offers something for everyone. Common users include:
+* **Individuals** - Millions track personal expenses to maximize deductions, stay within budgets, or see where money goes.
+* **Friends** - Split bills with friends for rent, parties, or drinks.
+* **Employees** - Reimburse expense reports from airports, hotels, conference centers, or coffee shops.
+* **Managers** - Manage corporate spend, empowering the best and keeping tabs on the rest, staying on schedule and budget.
+* **Accountants** - Internal accountants, CFOs, CAS practices use Expensify to invoice customers, process vendor bills, and manage spend.
+* **Travel managers** - Manage employee travel easily with Expensify.
+
+If you're online doing anything, Expensify can probably help.
+
+### Key advantages
+You've got options, but use Expensify because it is:
+* **Simple enough for individuals** - A simple tool without overwhelming functionality or language.
+* **Powerful enough for enterprises** - It scales up to reveal sophisticated features only when needed. Used by public companies, multinationals, and more.
+* **6 products for the price of 1** - Offers expense management, corporate cards, travel management, chat, invoicing, and billpay in one platform.
+* **Supports everyone everywhere** - Works on iPhones, Androids, desktops, and browsers, supporting every currency and reimbursing to almost any country.
+* **You get paid to use it** - Spend on the Expensify Card and earn up to 2% cashback.
+* **Revenue share for accountants** - Manage client books and earn or share a 0.5% revenue share.
+
+You are in the driver's seat, and we're here to earn your business.
## Concepts
-The Expensify Superapp has a lot of moving pieces, so let's break them down one by one.
-
-### [Superapp] What makes Expensify a superapp?
-A "superapp" is a single app that combines multiple products into one seamlessly interconnected experience. Expensify isn't a "suite" of separate products linked through a single account – Expensify is a single app with a single core design that can perform multiple product functions. The secret to making such a seamless experience is that we build all product functions atop the same common core:
-* **App** - The basis of the superapp experience is the actual app itself, which runs on your mobile phone or desktop computer.
-* **Chats** - Even if you don't plan on using Expensify Chat for enterprise-grade workspace collaboration, chat is infused through the entire product.
-* **Expense** - Even if you aren't actively managing your expenses, you've still got them. Every product that deals with money is ultimately dealing with expenses of some kind.
-* **Workspace** - Though Expensify works great for our millions of individual members, every product really shines when used between groups of members sharing a "workspace."
-* **Domain** - To support more advanced security features, many products provide extra functionality to members who are on the same email "domain."
-
-These are the foundational concepts you'll see again and again that underpin the superapp as a whole.
-
-### [App screens] What is the Expensify app?
-Just like your eyes are a window to your soul, the Expensify App is the doorway through which you experience the entire global world of interconnected chat-centric collaborative data that comprises the Expensify network. The main tools of this app consist of:
-* **Inbox** - The main screen of the app is the Inbox, which highlights exactly what you should do next, consolidated across all products.
-* **Search** - The next major screen is Search, which as you'd expect, lets you search everything across all products, from one convenient and powerful place.
-* **Settings** - Settings wraps up all your personal, workspace, and domain configuration options, all in one helpful space.
-* **Create** - Finally, the big green plus button is the Create button, which lets you create pretty much anything, across all the products.
-
-It's a deceptively simple app, with a few very familiar-looking screens and buttons that unlock an incredible range of sophisticated multi-product power.
-
-### [Platforms] Where can I use the Expensify app?
-The Expensify app comes in three flavors:
-* **Expensify web app** - The Expensify web app is what you would access at new.expensify.com. You can access the web app via a mobile web browser or a desktop web browser – it's optimized to work on both.
-* **Expensify mobile app** - The Expensify mobile app works more or less identically to the Expensify web app (when opened in a mobile browser), but is more reliable, higher performance, has better support for notifications.
-* **Expensify desktop app** - The Expensify desktop app works more or less identically to the Expensify web app (when opened in a desktop browser), but is more reliable, higher performance, and has better support for notifications.
-
-Whatever computer or phone you use, Expensify will work on it.
-
-### [Workspace] What is a workspace?
-A workspace groups members together to enable secure sharing and real-time collaboration. Every product adds features to the workspace, but all share the same common baseline:
-* **Name** - You can name your workspace anything. Names are not globally unique, but even if every other Alice has their own "Alice's Apples" workspace, yours is definitely the most special.
-* **Profile photo** - Give your workspace a great headshot (or logo), or just stick with the beautiful one it is randomly assigned.
-* **Description** - Help your members out by giving a good description to your workspace containing copious links and details.
-* **Currency** - Though every workspace can support expenses in every currency, for convenience they are all converted into a single currency of your choosing.
-* **Headquarters** - Workspaces work great for virtual teams, but some products deal with the physical world and need to know where you are headquartered.
-* **Members** - Though there are many situations in which you might want a workspace just for personal use, in general, workspaces work best when they have many members.
-* **Admins** - All members have some common elements of access, but "admin" members have enhanced privileges to manage the workspace overall.
-* **Rooms** - Every workspace has a series of chat rooms, some of which are built in automatically and some of which are created manually.
-* **Plan** - Workspaces come in two flavors, depending on the functionality you need:
- * **Collect** - The Collect workspace is optimized for businesses with simpler requirements looking for basic expense management, Expensify Card, invoice collections, and bill pay functionality.
- * **Control** - The Control workspace is built for more advanced companies with more powerful needs, such as multi-level approval, advanced domain control, enterprise accounting integrations, and so on.
-
-Workspaces make up the backbone of Expensify's collaboration features.
-
-### [Domain] What is a domain?
-A domain is a secondary way of grouping users, generally for more advanced security purposes. Unlike a workspace, which can contain anybody with any email address or SMS number, you join a domain by validating your email address and then optionally "claiming" it as your own.
-* **Name** - Each domain corresponds to the "domain name" of your email address (eg, cathy@croissants.com would have the domain of `croissants.com`). Unlike a workspace, you can't rename your domain.
-* **Members** - A domain is similar to a workspace in that it represents a group of users. Unlike a workspace, however, domain members are generally limited to those who have validated email contact methods on this domain.
-* **Group** - Every member of the domain is a member of exactly one group on the domain. This domain group sets various security rules for that member, such as setting their "preferred workspace."
-
-Domains allow for more advanced management and top-down control of Expensify members.
-
-### [Inbox] What does the Inbox do?
-Given Expensify's chat-centric design, that makes Expensify in effect a superpowered chat app – and in any chat app, the most important page is the Inbox. The Inbox does a real-time search across all products to highlight exactly what you should do *right now*. A few key features of the Inbox include:
-* **Green dot** - Whenever someone is waiting on you to do something – such as an expense you need to reimburse or a booking you need to approve – that thing's chat will be put to the top of the list with a little green dot next to it.
-* **Red dot** - Anything you need to finish to accomplish something you started – such as fixing a violation before an expense can be submitted – will also be put to the top of the list with a little red dot next to it.
-* **Pinned** - Anything you want to pay special attention to can be manually "pinned" to the top of the Inbox so it stays top of mind.
-* **Priority mode** - Though everyone's work style is unique to them, the Expensify app is organized around two modes of prioritization:
- * **Most recent mode** - The default mode for new users is to sort the Inbox to put whatever chat was most recently modified at the top. This works particularly well for those engaged in rapid-fire collaboration who want to "go where the action's at."
- * **Focus mode** - When the Inbox gets over 30 rows, it automatically switches to "focus mode," which alphabetically organizes the chats and only shows those that are "unread" (ie, have comments you haven't read yet), have a green or red dot, or are pinned. This works well for those engaged in many large group conversations that you might want to monitor, but not necessarily engage with immediately.
-
-The Inbox is the most powerful page in the app, and where you will spend the bulk of your time.
-
-### [Search] What does Search do?
-By and large, pretty much anything. Expensify has a "universal search" design that brings all data objects into a single place, and then lets you search all those objects using an incredibly flexible and powerful search engine. Search consists of the following main pieces:
-* **Query** - At the top of the search page is the "query," which formally describes what you are searching for.
-* **Datatype selector** - By default, we will search all datatypes simultaneously, but you can narrow the results to a single type.
-* **Filters** - Similarly, each datatype has its own properties (eg, an expense has an amount, a trip has a destination), and you can filter on each.
-* **Saved searches** - If you dial in a search you intend to do again and again, you can save it for future reuse.
-
-The Inbox's job is to push information in your direction, but the Search page exists to help you find anything you're looking for.
-
-### Settings
-Every product will generally have its own distinct settings, but all settings are conveniently grouped into three main categories:
-* **Account** - Every user has an "account" that stores all data owned by that user. Each individual person has a single user account, though that account can be associated with many contact methods (ie, email addresses and SMS numbers).
-* **Workspace** - Group functionality across all products is organized into "workspaces," which allow secure sharing of data and settings between multiple members.
-* **Domain** - Many users sign up with an email address, and the end of that address (ie, @company.com) corresponds to the "domain" that user is a member of. Domains are another way to group accounts and securely share data between the domain members.
-
-Every product adds its own layers of sophistication and power onto the common foundation of this shared superapp core.
-
-#### [Account] What are my account settings?
-Your account contains the sum total of all data you own or shared with you, across all products. But all products rely upon the same common set of account properties:
-* **Profile** - Your profile allows you to introduce and uniquely identify yourself to everyone else.
-* **Wallet** - Your wallet organizes the various financial payment tools (such as the Expensify Card) and bank accounts associated with your account.
-* **Preferences** - Your preferences configure high-level settings on how you are notified and how data is presented to you.
-
-Your personal account contains all the details that make you, you.
-
-#### [Profile] What are my profile settings?
-Your "profile" is how you identify yourself, both publicly and privately:
-* **Your public details** - As the name implies, your public details can be seen by other users. These include:
- * **Profile photo** - Your profile photo is the image that is shown next to your name wherever you appear. You can customize this however you please, or a random "avatar" image will be picked for you.
- * **Display name** - Your display name is the name that is generally shown next to your photo. If you don't have a display name, then your primary contact method will be shown instead.
- * **Contact methods** - Your contact methods are all the email addresses and SMS numbers associated with your account. All contact methods allow you to sign in and associate any email receipts with your account.
- * **Primary contact method** - This is the contact method that is highlighted on your profile, and to which all communications are sent. If you are an employee of a business, your primary contact method will typically be your company email address.
- * **Secondary contact method** - You can add any number of "secondary" contact methods. These are not shown on your profile, but do allow you to sign into your account. It's helpful to have multiple secondary contact methods (such as a personal email address and personal phone number) to ensure you can access your account if you lose access to your primary contact method (such as your work address).
- * **Status** - Your status is an expiring optional icon and message you can set that appears next to your name, such as to hint that you are on vacation or in a meeting, etc.
- * **Pronouns** - Your pronouns are an optional tool for allowing you to indicate how you would like to be addressed by others.
- * **Timezone** - Your timezone reflects the timezone in which you are currently located. This will generally be set automatically as you travel around the world, but can be manually set as well.
-
-* **Your private details** - Also as the name implies, your private details are not shown to others but might be required to enable certain functionality:
- * **Legal name** - Your legal name is what appears on your government ID, which might differ from how you like to be addressed on a daily basis (ie, your display name). By default, your legal name is assumed to be your display name, but if that is not the case, you can easily correct this.
- * **Date of birth** - Your date of birth is the birthday listed on your government ID.
- * **Address** - Your address reflects where you would like us to contact you via mail, in the event we ever need to do so (such as to ship you an Expensify Card).
-
-The combination of your public and private profile gives you the tools to introduce yourself to the world and to us.
-
-#### [Wallet] What are my wallet settings?
-Your wallet is your one-stop shop for all things banking and payment card-related. The major items in your wallet include:
-* **Cash** - Just like a regular wallet that has a mix of cash and cards, your Expensify wallet is also able to hold electronic cash you receive from others.
-* **Cards** - This contains a central list of every card associated with your Expensify account:
- * **Expensify Cards** - Your employer can assign you an Expensify Card that gives you access to company credit for business purchases.
- * **Imported cards** - You can import the transactions from your personal or corporate card into Expensify to submit to your company for approval or reimbursement or just to manage for your own needs.
- * **Payment** - You can link a credit card to your account for paying your Expensify subscription or to fund your wallet's cash balance.
-* **Bank accounts** - This contains a link of bank accounts associated with your Expensify account:
- * **Personal bank account** - You can like a personal bank account to either receive company reimbursements or fund your wallet's cash balance.
- * **Business bank account** - You can connect your business's bank account to reimburse expenses, issue Expensify Cards, collect online invoice payments, pay bills, and more!
-
-Just like your normal wallet, a lot can be stuffed into your Expensify wallet, and all of it is priceless.
-
-#### [Preferences] What are my preferences?
-Your preferences are personal settings that affect how we display information to you:
-* **Training and marketing** - We, in general, like to occasionally reach out with new information about features, changes, or offers to help – but only if you like.
-* **App sounds** - We've worked hard to come up with some subtle audio cues that hint when certain actions happen in the app, but they are entirely optional.
-* **Priority mode** - This is how you specify which Inbox priority mode you prefer.
-* **Language** - Everybody in the world can use Expensify, and we are supporting an increasing number of languages natively.
-* **Theme** - Give into the dark side or stay in the light, we won't judge!
-* **Two-factor authentication** - We strongly recommend everyone enable two-factor authentication to secure access to your account.
-
-Everybody likes things their own way, and preferences are how you make the Expensify app your own.
-
-#### [Subscription] What are my subscription settings?
-Most of Expensify is completely free to use, and millions of members use Expensify without paying anything at all. To unlock our more powerful functionality, create a workspace and pick which products you need – each can be adopted independently, but all are included in the base price (though some products have slightly different nuances: Expensify Card cashback deducts from the bill, Expensify Travel booking fees add to the bill, etc). Regardless of which products you enable, all are billed together via the same subscription. Your subscription consists of the following:
-* **Billing card** - Pick a credit or debit card from your wallet to pay your subscription.
-* **Subscription length** - Expensify has options for everyone depending on your specific needs, allowing you to balance cost versus commitment:
- * **Pay-per-use** - By default, your Expensify account starts with zero-risk, zero-commitment: just use Expensify to your heart's content, and you will be billed for as much or as little as you use the next month.
- * **Annual plan** - Once you know how much Expensify you need, lock in a 50% annual plan discount by committing to a certain number of seats for 12 months. The annual plan is configured as follows:
- * **Subscription size** - This is the number of seats you commit to purchasing for the next 12 months (billed monthly), at a 50% discounted rate. Any active seats billed at the end of the month in excess of the subscription size are billed at the pay-per-use rate (ie, without the 50% discount).
- * **Auto-renew** - Whether to automatically renew this subscription at the end of 12 months, or revert back to pay-per-use (giving up the 50% discount).
- * **Auto-increase annual seats** - Whether to automatically increase the number of annual seats you commit to based on the number of seats used. This avoids being accidentally billed for any pay-per-use seats.
-
-Pick the plan that works for you, and feel free to change as you need.
-
-#### [Price] What is the price of Expensify?
-For most users, Expensify is completely free. For business users, the price of Expensify depends on which features are enabled – and with Expensify Card cashback, you can actually be paid to use Expensify! The major variables going into the price of Expensify for your specific needs include the following:
-* **Personal use** - Most users enjoy Expensify free of charge, as there is a huge range of free features designed for use by yourself and with your friends.
-* **Active seats** - Our paid functionality is largely contained within workspaces and billed on an "active seat" basis. This means at the end of the month, we look over the activity of each workspace member to determine if they used any paid functionality or merely free features:
- * **Paid seat** - A workspace member who uses any paid functionality (ie, submitting, approving, or paying expenses) requires a "paid seat."
- * **Free seat** - A workspace member who only used free functionality (ie, viewing expenses, chatting outside of an expense report) only requires a "free seat."
-* **Paid seat price** - Once we determine how many paid seats you require in a given month, we initially set the price per paid seat at $20/seat/mo for Collect workspaces and $36/seat/mo for Control.
-* **Expensify Card discount** - The first modification to the base seat price is to assess how much total spend was approved on the workspace, versus how much of it was spent on the Expensify Card. This will generate a sliding discount ranging from 0% (if you aren't using the card at all) to 50% (if you have used the Expensify Card for at least 50% of your company's spend). The Expensify Card discount is applied to the seat price, which can reduce it down to $10/seat/mo for Collect workspaces or $18/seat/mo for Control. For example:
- * If your company spends 0% of the total approved spend on the Expensify Card, you receive no discount.
- * If your company spends 25% of the total approved spend on the Expensify Card, you receive a 25% discount off each seat.
- * If your company spends 75% of the total approved spend on the Expensify card, you receive a 50% discount off each seat.
-* **Annual plan discount** - Next, we determine how many seats you have committed to in your annual plan subscription size and apply an additional 50% discount to those seats – bringing the price down to $5/seat/mo for Collect workspaces, or $9/seat/mo for Control.
-* **Expensify Card cashback** - Finally, we calculate how much cashback you earned from spending on the Expensify Card and apply that to the bill, reducing the price further. In many cases, the cashback is larger than the Expensify bill itself, meaning our so-called "paid" features could not only be free, *you can actually be paid to use them.*
-
-Long story short, depending upon which features you use, you might pay us, it might be free, or we might even pay you. There are a lot of variables involved, so please check out our savings calculator to understand how this will shake out for you.
-
-#### [Save the world] What the heck is "Save the world"?
-Expensify.org’s mission is to empower individuals and communities to eliminate injustice around the world by making giving and volunteering more convenient, meaningful, and collaborative. We simplify full transparency for all, allowing our donors and volunteers to connect and make positive permanent changes. The foundation of Expensify.org was built on applying our expertise in expense management to increase the transparency of how funds are used, the convenience of how donations are gathered, and — most importantly — the human connection between donors, volunteers, and recipients.
-
-Please note that our funding model is not in the form of a grant given to a nonprofit organization. Instead, we're looking to help amplify the work of individuals who are directly absorbing the costs.
-
-### [Global create] What does the big green Create button do?
-Saving the best for last is the big green "global create" button. As the name suggests, this allows you to create basically anything your account is allowed to create. The exact options will depend on which products are configured in your workspace, but it can be any of the following:
-* **Start chat** - Begins a new chat with one or more users.
-* **Track expense** - Tracks an expense for personal use.
-* **Submit expense** - Submits an expense to another user for payment.
-* **Split expense** - Splits an expense with one or more other users for shared payments.
-* **Pay someone** - Sends money to another user from your Expensify wallet balance.
-* **Send invoice** - Sends an invoice from a workspace to a customer for online payment.
-* **Assign task** - Creates a new task and assigns it to yourself or another user for completion.
-* **Book travel** - Books a flight, hotel reservation, or car rental.
-* **Quick action** - Repeats the last action you took, most commonly to scan a receipt and submit it via a particular workspace all in a single button.
-
-As you can see, there's a lot packed into that big button – press it and see what happens!
-## Tutorials
-The Expensify superapp has a lot of moving parts, what specifically are you trying to do? Let's point you in the right direction with some step-by-step guides.
-
-### Role
-
-#### [Individual] How do I use Expensify as an individual?
-Expensify is designed to be flexible for a wide range of individual use cases. As an individual, you can:
-1. Track personal expenses
-2. Split bills with friends
-3. Collect receipts and categorize them
-4. Use Expensify Card for cashback and simplified reimbursement
-
-Simply log in, navigate to the expense section, and use the Create button to start organizing your expenses.
-
-#### [Friends] How do I use Expensify with my friends?
-You can use Expensify to settle shared expenses between friends, such as splitting the bill at a restaurant. Here's how:
-1. Create an expense and enter the total amount.
-2. Choose **Split Expense** and add your friends by entering their email addresses.
-3. Expensify will calculate each person's share, and you can easily send a request to them to settle the balance.
-
-#### [Employee] How do I use Expensify as an employee?
-As an employee, Expensify can help you:
-1. Submit expense reports for approval.
-2. Use the Expensify Card for company expenses.
-3. Book travel and manage expenses during work trips.
-4. Communicate with colleagues through integrated chat features.
-
-After logging in, create an expense report, attach receipts, and submit it for approval through your workspace.
-
-#### [Manager] How do I use Expensify as a manager?
-Managers can use Expensify to:
-1. Approve or reject expense reports from their team.
-2. Monitor corporate spending in real-time.
-3. Issue Expensify Cards to employees.
-4. Set up advanced approval workflows for multi-level reviews.
-
-Use the Inbox and Workspace features to manage team expenses and approvals efficiently.
-
-#### [Accountant] How do I use Expensify as an accountant?
-Accountants can:
-1. Manage multiple clients’ expense workflows through different workspaces.
-2. Create invoices and collect payments.
-3. Export data directly to accounting software for tax purposes.
-4. Benefit from revenue-sharing programs by becoming an Expensify Approved Accountant.
-
-You can use the Invoice and Bill Pay tools to manage clients' billing, and track expenses for tax reporting.
-
-#### [Travel manager] How do I use Expensify as a travel manager?
-Travel managers can:
-1. Book and manage employee travel.
-2. Track expenses related to flights, hotels, and car rentals.
-3. Issue Expensify Cards for travel-related spending.
-4. Approve travel expenses before they are reimbursed.
-
-Simply navigate to the Travel section, where you can manage travel bookings and expense submissions in one place.
-
-### Platforms
-
-#### [Web] How do I access Expensify on the web?
-To visit the Expensify website:
-1. Go to www.expensify.com, either on a desktop or mobile browser.
-
-#### [Mobile] How do I install the Expensify mobile app?
-To install the Expensify mobile app:
-1. Visit the Expensify for iOS or Expensify for Android app stores.
-2. Press **Install**.
-3. Follow the prompts to install.
-4. Press the Expensify icon in your phone's app list to start.
-
-#### [Desktop] How do I install the Expensify desktop app?
-To install the Expensify desktop app on MacOS:
-1. Download the Expensify for MacOS or Expensify for Windows installer.
-2. Double-click on the installer to open it.
-3. Click the Expensify icon on the taskbar to start.
-
-#### [Sign in] How do I sign up or sign in to my Expensify account?
-Signing up for a new account works the same as signing into an existing account, as follows:
-1. Install or access Expensify on any platform:
- * Access Expensify on the web.
- * Install the Expensify mobile app.
- * Install the Expensify desktop app.
-2. Choose how you want to connect and press Next:
- * Press **Email** and enter your email address, or
- * Press **Phone Number** and enter your SMS-compatible phone number.
- * Press **Google** and sign in to your Google account, or
- * Press **Apple** and sign into your Apple account.
-3. If asked to validate your email address, check your email inbox for a magic link and press it.
-4. If asked to join, this means that this is the first time you are signing in with this email address or phone number; press **Join** to confirm you entered it correctly.
-
-#### [Magic link] How do I use a magic link?
-Magic links are used for secure login without passwords. When prompted:
-1. Check your email for the Expensify Magic Link.
-2. Click the link in your email, and it will log you in to Expensify without needing to enter a password.
-
-#### [Sign out] How do I sign out?
-To sign out of Expensify:
-1. Press **Settings** in the Expensify app.
-2. Scroll to the bottom and press **Sign Out**.
-
-#### [Two factor] How do I secure my account with two-factor authentication?
-To enable two-factor authentication:
-1. Press **Settings**.
-2. Press **Security**.
-3. Press **Two-factor authentication**.
-4. Follow the steps to link your mobile phone for 2FA.
-
-#### [Close account] How do I close my account?
-To close your account:
-1. Press **Settings**.
-2. Press **Security**.
-3. Press **Close account**.
-4. Confirm by following the prompts to complete the process.
+### Superapp Fundamentals
+#### [Superapp Introduction] What is a superapp?
+A superapp is a single app combining multiple products into one interconnected experience. Expensify isn't a "suite" of separate products but a single app performing multiple functions. Built on a common core:
+* **App** - The superapp experience runs on your mobile phone or desktop computer.
+* **Chats** - Chat is infused through the entire product, even if not used for enterprise-grade collaboration.
+* **Expense** - All products dealing with money ultimately deal with expenses.
+
+#### [Domain Introduction] What is a domain?
+A domain groups users for advanced security. Join by validating your email:
+* **Name** - Corresponds to the "domain name" of your email address.
+* **Members** - Represents users with validated email contact methods.
+* **Group** - Each member belongs to one group, setting security rules.
+
+### Expensify Tools
+#### [Tools Introduction] What are the main tools in the Expensify App?
+The Expensify App is your window to the connected world of Expensify:
+* **Inbox** - Highlights what you should do next, across all products.
+* **Search** - Lets you search everything across all products from one place.
+* **Settings** - Wraps up personal, workspace, and domain configuration options.
+* **Create** - The big green plus button to create anything across all products.
+
+#### [Workspace Introduction] What is a workspace?
+A workspace groups members for secure sharing and collaboration. Features include:
+* **Name** - Name your workspace anything, it's not globally unique.
+* **Profile photo** - Use a headshot or logo, or the assigned one.
+* **Description** - Provide details for members with links and information.
+* **Currency** - Supports expenses in every currency, converted to one of your choice.
+* **Headquarters** - Some products need to know your physical location.
+* **Members** - Workspaces work best with many members.
+* **Admins** - Admins have enhanced privileges to manage the workspace.
+* **Rooms** - Contains chat rooms built in automatically or created manually.
+* **Plan** - Workspaces come in two flavors:
+ * **Collect** - Optimized for businesses with simpler requirements.
+ * **Control** - Built for companies with more powerful needs.
+
+### App Platforms and Search
+#### [Platforms Introduction] Where can I use the Expensify App?
+The Expensify app is available in three forms:
+* **Expensify web app** - Accessed at new.expensify.com via mobile or desktop web browser.
+* **Expensify mobile app** - Works like the web app but is more reliable and supports notifications.
+* **Expensify desktop app** - Similar to the web app but optimized for desktops and supports notifications.
+
+Expensify works on any computer or phone.
+
+#### [Search Introduction] What does Expensify's "universal search" do?
+Expensify's "universal search" brings all data into one place. Search components include:
+* **Query** - Describes what you are searching for.
+* **Datatype selector** - Narrow results to a single type.
+* **Filters** - Filter by datatype properties.
+* **Saved searches** - Save searches for future use.
+
+The Search page helps you find anything you're looking for.
+
+### Inbox and Communication
+#### [Inbox Introduction] What makes Expensify's Inbox powerful?
+Expensify's chat-centric design makes it a superpowered chat app. The Inbox highlights what you should do now:
+* **Green dot** - Indicates someone is waiting on you.
+* **Red dot** - Shows what you need to finish.
+* **Pinned** - Manually pin important items.
+* **Priority mode** - Organized by two modes:
+ * **Most recent mode** - Sorts Inbox by recent activity.
+ * **Focus mode** - Shows unread, green/red dot, or pinned chats.
+
+The Inbox is the most powerful page, where you'll spend most of your time.
+
+### Security and Data Protection
+#### [Security Overview] What security measures does Expensify implement?
+Expensify takes security seriously, aligning its measures with those used by banks to protect sensitive financial data. Regular testing and updates ensure security stays ahead of potential threats. Expensify also undergoes daily checks by McAfee for added protection against hackers. Users can verify Expensify's security at the McAfee SECURE site.
+
+#### [Security Standards] What are Expensify's security standards?
+Expensify adheres to the Payment Card Industry Data Security Standard (PCI-DSS), a high security standard used by major companies like PayPal and Visa to protect online credit card information. Additionally, Expensify is compliant with SSAE 16 and undergoes an annual SSAE-18 SOC 1 Type 2 audit by independent third-party auditors.
+
+#### [Data Encryption] How does Expensify encrypt data and passwords?
+Expensify employs data encryption to protect information. Upon submission, data is transformed into a secret code to ensure security during transit between your device and Expensify's servers, as well as within the server network. Expensify uses HTTPS+TLS for all web connections, ensuring data is encrypted at every stage.
+
+#### [GDPR Compliance] How does Expensify comply with GDPR?
+Expensify is committed to the General Data Protection Regulation (GDPR), which strengthens data protection for EU individuals. Key compliance measures include:
+- Participation in the EU-US and Swiss-US Privacy Shield Frameworks.
+- Annual SSAE-18 SOC 1 Type 2 audits.
+- Maintaining PCI-DSS compliance.
+- Annual penetration tests by third-party experts.
+- Background checks and security training for employees and contractors.
+- Appointing a dedicated Data Protection Officer reachable at [privacy@expensify.com](mailto:privacy@expensify.com).
+- Signing Data Processing Addendums with vendors.
+- Transparency about sub-processors on the website.
+- User tools for data export, preference management, and account closure.
+
+**Disclaimer**: This information is not legal advice. Consult legal counsel for specific GDPR applicability.
-### Profile
+## Tutorials
-#### [Photo] How do I set my profile photo?
-To set your profile photo:
-1. Press **Settings**.
-2. Press the **pencil icon** next to your existing profile photo.
-3. Press **Upload** photo.
-4. Follow the prompts on your platform to select your photo from local storage.
+### Getting Started
+#### [Web Access Guide] How do I access Expensify on the web?
+Visit the Expensify website:
+1. Go to www.expensify.com on a browser.
-#### [Display name] How do I change my display name?
-To change your display name:
-1. Press **Settings**.
+#### [Mobile App Installation] How do I install the Expensify mobile app?
+Install the Expensify mobile app:
+1. Visit iOS or Android app stores.
+2. Press **Install**.
+3. Follow prompts to install.
+4. Press the Expensify icon to start.
+
+#### [Desktop App Installation] How do I install the Expensify desktop app?
+Install the Expensify desktop app:
+1. Download the MacOS or Windows installer.
+2. Double-click the installer.
+3. Click the Expensify icon to start.
+
+#### [Join a Workspace] How do I join my company's workspace?
+Welcome to Expensify! If you received an invitation to join your company's Expensify workspace, follow these steps:
+
+1. Download the Expensify mobile app to upload expenses and check reports from your phone.
+2. Press your profile image or icon in the bottom menu, then press the **pencil icon** next to your photo to upload an image from your saved files.
+3. Press **Profile** to edit details like Display Name, Contact Method, Status, Pronouns, and Timezone.
+4. Meet **Concierge**, your personal assistant, to get reminders and alerts.
+5. Learn to add an expense by SmartScanning a receipt or entering it manually.
+6. Secure your account by enabling two-factor authentication through the **Security** settings.
+
+#### [Create a Company Workspace] How do I create a workspace for my company?
+Creating a workspace in Expensify is your first step to organizing your company's expenses. Here's how to do it:
+
+1. Press your **profile photo** or icon in the bottom menu to open the settings.
+2. Scroll and press **Workspaces**.
+3. Press **New workspace** to start creating your workspace.
+4. Press the **Edit pencil icon** next to your workspace image to upload a custom image.
+5. Press **Name** to set the workspace's name.
+6. Press **Default Currency** to choose the currency for all expenses.
+
+Invite team members to collaborate efficiently.
+
+#### [Manage a Copilot] How do I add, remove, or act as a Copilot?
+Manage your Copilot settings:
+
+1. To add a Copilot:
+ 1. Press your **profile icon** in the bottom left corner to open **Settings**.
+ 2. Press **Security**.
+ 3. Under Copilot: Delegated Access, press **Add Copilot**.
+ 4. Search for the user you'd like to add using their name or email address.
+ 5. Select **Full** or **Limited** access and press **Add Copilot**.
+
+2. To remove a Copilot:
+ 1. Press your **profile icon** in the bottom left corner to open **Settings**.
+ 2. Press **Security**.
+ 3. Under Copilot: Delegated Access, press the three vertical dots next to the Copilot and press **Remove Copilot**.
+
+3. To act as a Copilot:
+ 1. Press your **profile icon** in the bottom left corner to open **Settings**.
+ 2. Press the up-down arrow next to your profile name in the top left corner to access the account switcher.
+ 3. Select the account and level of access.
+
+#### [Name Update Process] How do I update my display or legal name?
+Update your display or legal name:
+1. Press your **profile icon** to open **Settings**.
2. Press **Profile**.
-3. Press **Display name**.
-4. Enter your first and last name.
-5. Press **Save**.
+3. Edit your name:
+ - **Display name**: Press **Display Name**, enter your first name (or nickname) and last name, then press **Save**.
+ - **Legal name**: Scroll to the Private Details section, press **Legal Name**, enter your legal first and last name, and press **Save**.
-#### [Secondary contact] How do I add a secondary contact method?
-To add a secondary contact method:
-1. Press **Settings**.
-2. Press **Profile**.
-3. Scroll to **Contact Methods**.
-4. Press **Add Secondary Contact** and enter your additional email or phone number.
+#### [Update Notification Preferences] How do I update my notification preferences?
+Customize how you receive email and in-app notifications from Expensify:
-#### [Primary contact] How do I change my primary contact method?
-To change your primary contact method:
-1. Add a new secondary contact method.
-2. Press **Make primary** to make it the new primary contact method.
+1. Press your profile image or icon in the bottom menu.
+2. Press **Preferences**.
+3. Enable or disable the toggles under Notifications:
+ - **Receive relevant feature updates and Expensify news**: If enabled, you will receive emails and in-app notifications from Expensify about new product and company updates.
+ - **Mute all sounds from Expensify**: If enabled, all in-app notification sounds will be silenced.
-#### [Remove contact] How do I remove a contact method?
-To remove a contact method:
-1. Press **Settings**.
-2. Go to **Profile** and navigate to **Contact Methods**.
-3. Select the contact method to remove and press **Remove**.
+#### [Email Address Management] How do I change or add an email address on my Expensify account?
+To change or add an email address on your Expensify account:
-#### [Pronouns] How do I set my pronouns?
-To change your pronouns:
-1. Press **Settings**.
+1. Press your profile image or icon.
2. Press **Profile**.
-3. Press **Pronouns**.
-4. Start typing your preferred pronouns.
-5. Choose your preferred set from the list.
-
-#### [Timezone] How do I change my timezone?
-By default, your timezone will be set automatically to match your system settings. To instead set it manually:
-1. Press **Settings**.
+3. Press **Contact Method**.
+4. Press **New Contact Method**.
+5. Enter the email address or phone number you want to use.
+6. Press **Add**.
+7. A verification code will be sent to your email. Enter it in Expensify and press **Verify**.
+
+You can press any email address in your list to set it as the default, remove it, or verify it.
+
+#### [Switch Theme] How do I switch between light and dark mode in Expensify?
+Change the appearance of Expensify by selecting a theme:
+
+1. Press your **profile image or icon** in the bottom menu.
+2. Press **Preferences**.
+3. Press the **Theme** option and select the desired theme:
+ - **Dark mode**: The app will appear with a dark background.
+ - **Light mode**: The app will appear with a light background.
+ - **Use Device settings**: Expensify will automatically use your device’s default theme.
+
+#### [Switch Language to Spanish] How do I switch my account language to Spanish?
+Change your account language to Spanish:
+
+1. Press your **profile image or icon** in the bottom menu.
+2. Press **Preferences**.
+3. Press the **Language** option and select **Spanish**.
+
+#### [Timezone Adjustment] How do I change my timezone?
+Change your timezone:
+1. Press your profile image or icon in the bottom menu.
2. Press **Profile**.
-3. Press **Timezone**.
-4. Disable **Automatically determine your location**.
-5. Press **Timezone**.
-6. Choose your preferred timezone from the list.
+3. Press **Timezone** to select your timezone.
-#### [Status] How do I set my status?
-To set your status:
-1. Press **Settings**.
-2. Press **Profile**.
-3. Press **Status**.
-4. Enter your custom status message and choose an emoji (optional).
-5. Press **Save**.
+#### [Pronouns Update] How do I update my pronouns?
+Update your pronouns to display them on your account:
-#### [Legal name] How do I change my legal name?
-To change your legal name:
-1. Press **Settings**.
+1. Press your profile image or icon.
2. Press **Profile**.
-3. Press **Legal Name**.
-4. Enter your updated legal name.
-5. Press **Save**.
-
-#### [Date of birth] How do I change my date of birth?
-To change your date of birth:
+3. Press **Pronouns** to select your pronouns. Type any letter into the field to see a list of available options.
+
+### Troubleshooting
+#### [Feature Issues] What should I do if I'm facing issues with a specific feature?
+If you're experiencing problems with a specific feature, refer to the respective section of the help docs for common errors and troubleshooting steps. If the issue persists, reach out to Concierge via in-product chat or by emailing concierge@expensify.com.
+
+#### [Local Issues] How do I troubleshoot local issues with my webpage?
+If your webpage isn't loading properly, try these steps:
+1. Press [here](https://www.expensify.com/signout.php?clean=true) to force a clean sign-out from the site, which can help remove stale data causing issues.
+2. Clear cookies and cache on your browser.
+3. Use an Incognito or Private browsing window.
+4. Try accessing the site on a different browser.
+
+#### [JavaScript Console Access] How do I access the JavaScript console on my browser or application?
+A developer console logs backend operations of sites and applications, providing information that can help developers solve your issues. To provide a screenshot of your developer console, follow the instructions for your browser or application:
+
+- **Chrome**: Press Cmd + Option + J on Mac, or Ctrl + Shift + J on Windows; or navigate through View > Developer > JavaScript Console.
+- **Firefox**: Press Cmd + Option + K on Mac, or Ctrl + Shift + J on Windows; or go through Menu Bar > More Tools > Web Developer Tools > Console tab.
+- **Safari**: Enable the console in Safari by selecting "Show features for web developers" in Safari Menu > Settings > Advanced. Then, press Cmd + Option + C or use the Develop Menu > Show JavaScript Console.
+- **Microsoft Edge**: Press Cmd + Option + J on Mac, or Ctrl + Shift + J on Windows; or right-click a webpage and select Inspect > Console.
+
+### Account Management
+#### [Sign In Process] How do I sign up or sign in?
+Sign up or sign in:
+1. Install or access Expensify on any platform.
+2. Choose connection method and press Next.
+3. Validate your email address and press **Join** if first time signing in.
+
+#### [Magic Link Login] How do I use a magic link for secure login?
+Use a magic link for secure login:
+1. Check email for Expensify Magic Link.
+2. Click link to log in without a password.
+
+#### [Sign Out Process] How do I sign out of Expensify?
+Sign out of Expensify:
1. Press **Settings**.
-2. Press **Profile**.
-3. Press **Date of Birth**.
-4. Update your birth date and press **Save**.
+2. Scroll and press **Sign Out**.
-#### [Address] How do I change my address?
-To change your address:
+#### [Close Account Process] How do I close my account?
+Close your account:
+1. Press your profile image or icon in the bottom menu.
+2. Press **Security**.
+3. Press **Close account**.
+4. Provide answers to the questions and confirm closure by pressing **Close Account**.
+
+### Subscription Management
+#### [Manage Subscription] How do I manage my subscription?
+To manage your subscription in New Expensify:
+1. Open the app on your device.
+2. Press your profile icon in the bottom-left corner.
+3. Navigate to the **Workspaces** section.
+4. Press **Subscription** under Workspaces to view your subscription details.
+
+#### [Add Payment Card] How do I add a payment card for billing?
+To add a payment card for billing:
+1. Locate the **Add Payment Card** option within your subscription settings.
+2. Enter your payment card details securely to maintain uninterrupted service.
+
+#### [Understand Subscription Details] What subscription details can I view?
+Within your subscription overview, you can view:
+- **Plan details**: See the number of seats, billing information, and renewal date.
+- **Auto-renew settings**: Check when your subscription will renew automatically.
+- **Auto-increase seats**: Discover potential savings by automatically increasing seats for team members exceeding the subscription size.
+
+#### [Request Early Cancellation] How can I request an early cancellation of my subscription?
+To request an early cancellation:
+1. Access the **Request Early Cancellation** option in the Subscriptions section.
+Note: Early cancellation might not be available for all customers.
+
+#### [Pricing Information] Where can I find more details on pricing plans?
+For detailed pricing plans, visit the billing page [coming soon].
+
+### Security and Customization
+#### [Enable 2FA Security] How do I secure my account with two-factor authentication?
+Secure your account with two-factor authentication:
1. Press **Settings**.
-2. Press **Profile**.
-3. Press **Address**.
-4. Enter your new address and press **Save**.
+2. Press **Security**.
+3. Press **Two-factor authentication**.
+4. Follow steps to link your phone.
-### Workspace
+#### [Additional Security with 2FA] How do I add an extra layer of security with 2FA?
+Adding an extra layer of security can help protect your financial data. To enable two-factor authentication (2FA):
-#### [Create] How do I create a workspace?
-To create a workspace:
+1. Press your profile image or icon in the bottom menu.
+2. Press **Security**.
+3. Under Security Options, press **Two Factor Authentication**.
+4. Save a copy of your backup codes. This is critical to avoid losing access if you cannot use your authenticator app.
+ - Press **Download** to save the backup codes to your device.
+ - Press **Copy** to paste the codes into a secure location.
+5. Press **Next**.
+6. Download or open your preferred authenticator app and connect it to Expensify by scanning the QR code or entering the code manually.
+7. Enter the 6-digit code from your authenticator app into Expensify and press **Verify**.
+
+When you log in to Expensify in the future, you'll need to use a magic code from your email and a 6-digit code from your authenticator app. If you lose access to your authenticator app, use your recovery codes as you would the authenticator code.
+
+### Profile and Contact Methods
+#### [Profile Photo Setup] How do I set my profile photo?
+Set your profile photo:
+1. Press your profile image or icon in the bottom menu.
+2. Press the **pencil icon** next to your photo.
+3. Press **Upload Image** to select a photo from your saved files.
+
+#### [Display Name Modification] How do I change my display name?
+Change your display name:
1. Press **Settings**.
-2. Press **Workspaces**.
-3. Press **Create Workspace**.
-4. Follow the steps to name and configure your new workspace.
+2. Press **Profile**.
+3. Press **Display name**.
+4. Enter your name and press **Save**.
-#### [Rename] How do I rename my workspace?
-To rename your workspace:
-1. Press **Settings**.
-2. Press **Workspaces**.
-3. Select your workspace and press **Edit**.
-4. Change the name and press **Save**.
+#### [Status Update] How do I set my status?
+Set your status:
+1. Press your profile image or icon in the bottom menu.
+2. Press **Profile**.
+3. Press **Status**.
+4. (Optional) Press the **emoji icon** to add an emoji.
+5. Enter a status message, such as "out of office" or "in a meeting."
+6. Press **Clear After** to select when the status should expire.
+7. Press **Save**.
-#### [Photo] How do I change the profile photo of my workspace?
-To change your workspace's profile photo:
+#### [Add Secondary Contact] How do I add a secondary contact method?
+Add a secondary contact method:
1. Press **Settings**.
-2. Press **Workspaces**.
-3. Select the workspace and press **Profile Photo**.
-4. Upload a new photo and press **Save**.
+2. Press **Profile**.
+3. Scroll to **Contact Methods**.
+4. Press **Add Secondary Contact** and enter details.
-#### [Description] How do I change the description of my workspace?
-To update your workspace description:
-1. Press **Settings**.
-2. Press **Workspaces**.
-3. Select the workspace and press **Description**.
-4. Update the text and press **Save**.
+#### [Change Primary Contact] How do I change my primary contact method?
+Change your primary contact method:
+1. Add a new secondary contact method.
+2. Press **Make primary** to set it as primary.
-#### [Currency] How do I change the currency of my workspace?
-To change your workspace currency:
+### Private Details and Regional Settings
+#### [Remove Contact Method] How do I remove a contact method?
+Remove a contact method:
1. Press **Settings**.
-2. Press **Workspaces**.
-3. Select the workspace and press **Currency**.
-4. Choose a new default currency and press **Save**.
+2. Go to **Profile** and **Contact Methods**.
+3. Select and press **Remove**.
-#### [Headquarters] How do I change the headquarters of my workspace?
-To change your workspace's headquarters location:
-1. Press **Settings**.
-2. Press **Workspaces**.
-3. Select the workspace and press **Headquarters**.
-4. Update the address and press **Save**.
+#### [Set Pronouns] How do I set my pronouns?
+Set your pronouns:
+1. Press your profile image or icon in the bottom menu.
+2. Press **Profile**.
+3. Press **Pronouns**.
+4. Type any letter to see a list of available pronouns and select your preferred set.
-#### [Invite member] How do I add or invite someone to my workspace?
-To invite a new member:
-1. Press **Settings**.
-2. Press **Workspaces**.
-3. Select the workspace and press **Members**.
-4. Press **Add Member** and enter the email addresses of the new members.
-5. Press **Invite**.
+#### [Timezone Adjustment] How do I change my timezone?
+Change your timezone:
+1. Press your profile image or icon in the bottom menu.
+2. Press **Profile**.
+3. Press **Timezone** to select your timezone.
-#### [Remove member] How do I remove someone from my workspace?
-To remove a member from your workspace:
+#### [Legal Name Update] How do I change my legal name?
+Change your legal name:
1. Press **Settings**.
-2. Press **Workspaces**.
-3. Select the workspace and press **Members**.
-4. Choose the member to remove and press **Remove Member**.
+2. Press **Profile**.
+3. Scroll to the Private Details section and press **Legal Name**.
+4. Enter updated name and press **Save**.
-#### [Add admin] How do I make someone an admin of my workspace?
-To promote a member to an admin:
+#### [Date of Birth Adjustment] How do I change my date of birth?
+Change your date of birth:
1. Press **Settings**.
-2. Press **Workspaces**.
-3. Select the workspace and press **Members**.
-4. Select the member and press **Make Admin**.
+2. Press **Profile**.
+3. Scroll to the Private Details section and press **Date of Birth**.
+4. Update birth date and press **Save**.
-#### [Remove admin] How do I remove an admin from my workspace?
-To remove admin privileges:
+### Address and Workspace Management
+#### [Address Update] How do I change my address?
+Change your address:
1. Press **Settings**.
-2. Press **Workspaces**.
-3. Select the workspace and press **Members**.
-4. Choose the admin and press **Remove Admin**.
+2. Press **Profile**.
+3. Scroll to the Private Details section and press **Address**.
+4. Enter new address and press **Save**.
-#### [More features] How do I enable features on my workspace?
-To enable features:
-1. Press **Settings**.
-2. Press **Workspaces**.
-3. Select the workspace and press **Features**.
-4. Toggle on the desired features and press **Save**.
+### Theme Preferences
+#### [Theme Preference Setup] How do I set my theme preference in New Expensify?
+Customize your theme preference in New Expensify to enhance your experience:
-#### [Upgrade plan] How do I upgrade my workspace?
-To upgrade a workspace to the Control plan:
-1. Press **Settings**.
-2. Press **Workspaces**.
-3. Select your workspace and press **Upgrade to Control**.
-4. Follow the steps to finalize the upgrade.
+1. Press your **profile image or icon** in the bottom menu.
+2. Press **Preferences**.
+3. Tap on **Theme**.
+4. Choose your preferred theme:
+ - **Dark mode**: Provides a dark background for a sleek look.
+ - **Light mode**: Offers a bright background for a classic appearance.
+ - **Use Device settings**: Aligns with your device's theme settings, adjusting automatically as your device changes.
-#### [Delete] How do I delete my workspace?
-To delete a workspace:
-1. Press **Settings**.
-2. Press **Workspaces**.
-3. Select the workspace and press **Delete Workspace**.
-4. Confirm the deletion.
+The default setting is **Use Device Settings**, which matches your device's theme transitions. Your selected theme will sync across all Expensify platforms you use.
## FAQ
You've got questions? We've got answers!
-### App
+### App and Messaging
+#### [Passwordless Authentication] Why don't I set a password?
+Expensify uses a "passwordless" design, sending a "magic link" to your contact method for secure authentication. Once signed in, you remain signed in until you sign out.
+
+#### [Infinite Sessions] Why am I never asked to sign in?
+Expensify uses "infinite sessions," keeping you signed in indefinitely until you sign out.
-#### Why don't I set a password?
-Expensify uses a "passwordless" design, where each time you sign in, we send a "magic link" to your contact method. This securely authenticates you based on your ability to receive the magic link to the contact method associated with your account. Once signed into a device, you remain signed into that account until you ask to sign out.
+#### [Messaging Closed Accounts] Why can others message me even if my account is closed?
+Expensify is a communications platform allowing messaging with valid email or SMS numbers, even if you don't use Expensify.
-#### Why am I never asked to sign in?
-Expensify uses an "infinite sessions" design, where after you sign in on a particular device, you remain signed in indefinitely, until you explicitly sign out.
+#### [Messaging User Blocking] Why can't I block users from messaging me using Expensify?
+Like Gmail or iMessage, Expensify allows messaging with valid emails or SMS numbers, without blocking all users.
-#### Why can others message me even if my account is closed?
-Like Gmail or iMessage, Expensify is a communications platform designed to let you message anyone with a valid email address or SMS number – whether or not they also use Gmail or iMessage. Accordingly, even if you don't use Expensify (or if you did use it but have since closed your account), other users can still message you using Expensify.
+### Profile and Legal Information
+#### [Display and Legal Names] Why do I have both a display name and legal name?
+Your display name shows how you'd like to be identified. Your legal name is used for documentation like billing or tax-related matters.
-#### Why can't I block users from messaging me using Expensify?
-Similar to how you can't ask Gmail to stop all Gmail users from emailing you, or ask iMessage to stop all iMessage users from texting you, you can't ask Expensify to stop all Expensify users from emailing you. Gmail, iMessage, and Expensify are all tools designed to enable the user to email and SMS other users.
+#### [Need for Legal Name] Why do you need my legal name?
+Your legal name is for identity verification when issuing payment cards and processing reimbursements.
-### Profile
+#### [Birth Date Requirement] Why do you need my date of birth?
+Your birth date verifies identity for financial products, ensuring compliance with regulations.
-#### Why do I have both a display name and legal name?
-You have a display name to show how you'd like to be publicly identified. Your legal name is used for documentation purposes, such as for billing or tax-related matters, which require your formal identification.
+#### [Home Address Requirement] Why do you need my home address?
+We need your address for shipping items and identity verification when processing payments.
-#### Why do you need my legal name?
-Your legal name is necessary for identity verification when issuing payment cards, processing reimbursements, and fulfilling regulatory requirements.
+### Workspace and Copilot
+#### [Workspace Address Requirement] Why do you need the address of my workspace's headquarters?
+We need the address to process transactions, apply local taxes, and comply with regional laws.
-#### Why do you need my date of birth?
-Your date of birth is used for verifying your identity when issuing financial products like the Expensify Card. It helps ensure compliance with regulatory requirements.
+#### [Copilot Permissions] As a Copilot, can I add or remove other Copilots?
+No. Copilots are restricted from adding or removing Copilots from other accounts. Only the account owner can add or remove Copilots from their own account. The only exception is that Copilots can remove themselves from another user's account.
-#### Why do you need my home address?
-We need your home address for shipping physical items like the Expensify Card and for identity verification when processing reimbursements or payments.
+#### [Copilot Action Identification] How can I tell which actions were taken by a Copilot?
+Any action taken by a Copilot will be displayed as being taken by the Copilot on behalf of the account owner.
-### Workspace
+#### [Multiple Copilots] Can I have more than one Copilot?
+You can assign as many Copilots as you need—there is no limit. However, you can only add one Copilot per minute.
-#### Why do you need the address of my workspace's headquarters?
-We need the headquarters' address to correctly process transactions, apply any local taxes, and ensure compliance with regional laws.
+### Account Closure and Pricing
+#### [Account Closure Issues] Why can't I close my account?
+There are several reasons you might be unable to close your account. If your account has an outstanding balance or if you have been assigned a role under a company’s Expensify workspace, you may encounter an error message during the account closure process, or the Close Account button may not be available. Here are the steps to follow for each scenario:
+- **Account Under a Validated Domain**: A Domain Admin must remove your account from the domain. Then you will be able to successfully close your account.
+- **Sole Domain Admin**: If you are the only Domain Admin for a company’s domain, you must assign a new Domain Admin before you can close your account.
+- **Workspace Billing Owner with an annual subscription**: You must downgrade from the annual subscription before closing the account. Alternatively, you can have another user take over billing for your workspaces.
+- **Company Workspace Owner**: You must assign a new workspace owner before you can close your account.
+- **Account has an outstanding balance**: You must make a payment to resolve the outstanding balance before you can close your account.
+- **Preferred Exporter for a workspace integration**: You must assign a new Preferred Exporter before closing your account.
+- **Verified Business Account that is locked**: You must unlock the account.
+- **Verified Business Account that has an outstanding balance**: You must make a payment to settle any outstanding balances before the account can be closed.
+- **Unverified account**: You must first verify your account before it can be closed.
-### Pricing
+#### [Workspace Member Pricing] Which active workspace members require paid seats?
+Workspace members billed for a paid seat if they submit, approve, pay, export, or chat on expenses.
-#### Which active workspace members require paid seats?
-If a workspace member takes any of the following actions inside of a workspace, you will be billed at the end of the month for a paid seat:
-* Submit an expense
-* Approve an expense
-* Pay an expense
-* Export an expense
-* Chat on an expense report
-* and so on
+#### [Member Double Billing] Why do some workspace members using paid features not require paid seats?
+Members using paid functionality on multiple workspaces in a month aren't billed twice – no "double dipping."
-In general, any action that modifies financial data or participates in a financial workflow is billable activity.
+### Billing and Subscriptions
+#### [Billing Page] What is the status of the billing page?
+The billing page is currently under development and will be available soon. Stay tuned for updates on how to access and use the new billing features.
-#### Why do some workspace members using paid features not require paid seats?
-In general, any workspace member that uses paid functionality will require a paid seat. However, if you own two or more workspaces with the same member, and the member uses paid functionality on multiple workspaces in a given month (ie, an admin approving expense reports on two different workspaces), you will not be billed twice for the same member – there is no "double dipping."
\ No newline at end of file
+#### [Recovery Codes Usage] How do I use my recovery codes if I lose access to my authenticator app?
+Your recovery codes work the same way as your authenticator codes. Just enter a recovery code as you would the authenticator code.
\ No newline at end of file
diff --git a/help/map.md b/help/map.md
new file mode 100644
index 000000000000..eb218e67dcc0
--- /dev/null
+++ b/help/map.md
@@ -0,0 +1,371 @@
+---
+layout: product
+title: Application Map
+---
+
+## Application Map
+Lost in the app? Let this map guide you!
+
+* Inbox
+ * Workspace selector
+ * Chat selector
+ * Special chats:
+ * Concierge
+ * Workspace chat
+ * #announce
+ * #admins
+ * Personal chat
+ * Chat modifiers:
+ * Pin
+ * Green dot
+ * Red dot
+ * Unread
+ * Current chat
+ * Chat header
+ * Profile image
+ * Name
+ * Description
+ * Pin / Unpin
+ * Share shortcut
+ * Members
+ * Invite member
+ * Bulk actions
+ * Find a member
+ * Member list
+ * Select all
+ * Member row
+ * Profile image
+ * Name
+ * Remove from chat
+ * Profile link
+ * Profile image
+ * Message
+ * Email
+ * Preferred pronouns
+ * Local time
+ * Settings
+ * Notify me about new messages
+ * Who can post
+ * Visibility
+ * Private notes
+ * Leave
+ * Message list
+ * Message actions
+ * Add reaction
+ * Reply in thread
+ * Mark as unread
+ * Join thread
+ * Copy link
+ * Flag as offensive
+ * Download
+ * Message composer
+ * Attach
+ * Split expense
+ * Assign task
+ * Add attachment
+ * Write something
+ * Emoji
+ * Send
+* Search
+ * Type selector
+ * State selector
+ * Filters
+ * Search results table
+ * Select all
+ * Search results row
+* Settings
+ * Status shortcut
+ * Profile
+ * Public
+ * Display name
+ * Contact method
+ * Status
+ * Emoji
+ * Message
+ * Clear after
+ * Pronouns
+ * Timezone
+ * Automatically determine your location
+ * Timezone
+ * Share
+ * QR Code
+ * Copy URL
+ * Get $250
+ * Private
+ * Legal name
+ * Date of birth
+ * Phone number
+ * Address
+ * Wallet
+ * Bank accounts
+ * Assigned cards
+ * Send and receive money with friends
+ * Preferences
+ * Notifications
+ * Receive relevant feature updates and Expensify news
+ * Mute all sounds from Expensify
+ * Priority mode
+ * Language
+ * Theme
+ * Security
+ * Two-factor authentication
+ * Close account
+ * Workspaces
+ * Profile
+ * Profile image
+ * Name
+ * Description
+ * Default currency
+ * Company address
+ * Share
+ * Delete
+ * Members
+ * Bulk actions
+ * Remove members
+ * Make member
+ * Make admin
+ * Make auditor
+ * Profile image
+ * Name
+ * Remove from workspace
+ * Role
+ * Profile shortcut
+ * Invoices
+ * Invoice balance
+ * Bank accounts
+ * Bank account
+ * Make default payment method
+ * Delete
+ * Add bank account
+ * Invoicing details
+ * Company name
+ * Company website
+ * Invoicing details
+ * Distance rates
+ * Add rate
+ * Bulk actions
+ * Settings
+ * Rate table
+ * Rate column
+ * Status column
+ * Rate row
+ * Enable rate
+ * Rate
+ * Delete
+ * Expensify Card
+ * Issue new card
+ * Workflows
+ * Delay submissions
+ * Submission frequency
+ * Add approvals
+ * Approvals
+ * Expenses from
+ * Approver
+ * Add approval workflow
+ * Make or track payments
+ * Connect bank account
+ * Connect online with Plaid
+ * Connect manually
+ * Rules
+ * Expenses
+ * Receipt required amount
+ * Max expense amount
+ * Max expense age
+ * Billable default
+ * eReceipts
+ * Expense reports
+ * Custom report names
+ * Prevent self-approvals
+ * Auto-approve compliant reports
+ * Auto-pay approved reports
+ * Categories
+ * Add category
+ * Bulk actions
+ * Delete categories
+ * Enable categories
+ * Settings
+ * Members must categorize all expenses -- Why not in Rules?
+ * Default spend categories
+ * Three dots menu
+ * Import spreadsheet
+ * Download CSV
+ * Category table
+ * Name column
+ * Status column
+ * Category row
+ * Enable category
+ * Name
+ * GL code
+ * Payroll code
+ * Category rules:
+ * Require description
+ * Default tax rate
+ * Flag amounts over
+ * Require receipts over
+ * Delete
+ * Tags
+ * Add tag
+ * Bulk actions
+ * Delete tag
+ * Disable tag
+ * Settings
+ * Custom tag name
+ * Members must tag all expenses
+ * Track billable expenses
+ * Three dots menu
+ * Import spreadsheet
+ * Download CSV
+ * Tag table
+ * Name column
+ * Status column
+ * Tag row
+ * Enable tag
+ * Name
+ * GL code
+ * Delete
+ * Taxes
+ * Add rate
+ * Bulk actions
+ * Delete rate
+ * Disable rate
+ * Settings
+ * Custom tax name
+ * Workspace currency default
+ * Foreign currency default
+ * Tax table
+ * Name
+ * Status
+ * Tax row
+ * Enable rate
+ * Name
+ * Value
+ * Tax code
+ * Report fields
+ * Add field
+ * Bulk actions
+ * Delete field
+ * Field table
+ * Name column
+ * Type column
+ * Field row
+ * Name
+ * Type
+ * Initial value
+ * Delete
+ * Accounting
+ * Connections list
+ * Quickbooks Online Connect
+ * Quickbooks Desktop Connect
+ * Xero
+ * NetSuite
+ * Sage Intacct
+ * More features
+ * Spend
+ * Distance rates
+ * Expensify Card
+ * Manage
+ * Workflows
+ * Rules
+ * Earn
+ * Invoices
+ * Organize
+ * Categories
+ * Tags
+ * Taxes
+ * Report fields
+ * Integrate
+ * Accounting
+ * Subscription
+ * Payment
+ * View payment history
+ * Request refund
+ * Your plan
+ * Subscription details
+ * Annual subscription
+ * Pay-per-user
+ * Three dot menu
+ * Request tax exempt status
+ * Domains
+ * Help
+ * Switch to Expensify Classic
+ * About
+ * App download links
+ * View keyboard shortcuts
+ * View the code
+ * View open jobs
+ * Report a bug
+ * Troubleshoot
+ * Client side logging
+ * Mask fragile user data wile exporting Onyx state
+ * Import Onyx state
+ * Export Onyx state
+ * Clear cache and restart
+ * Testing preferences
+ * Debug mode
+ * Use Staging server
+ * Force offline
+ * Simulate failing network requests
+ * Authentication status
+ * Device credentials
+ * Save the world
+ * Teachers Unite
+ * I know a teacher
+ * I am a teacher
+ * Sign out
+* Search router
+ * Search for something
+ * Recent searches
+ * Recent chats
+* Global Create
+ * Start chat
+ * Chat
+ * Name, email, or phone number
+ * Recents
+ * Contacts
+ * Add to group
+ * Room
+ * Room name
+ * Room description
+ * Workspace
+ * Visibility
+ * Track expense
+ 1. Choose type:
+ * Manual
+ * Amount
+ * Currency
+ * Scan
+ * Choose file
+ * Camera
+ * Distance
+ * Start
+ * Stop
+ 2. Code the expense
+ * Amount
+ * Description
+ * Show more
+ * Merchant
+ * Date
+ * Submit expense
+ 1. Choose type:
+ * Manual
+ * Amount
+ * Currency
+ * Scan
+ * Choose file
+ * Camera
+ * Distance
+ * Start
+ * Stop
+ 2. Choose who to submit to
+ * Name, email, or phone number
+ * Recents
+ * Contacts
+ 3. Code the expense
+ * Amount
+ * Description
+ * Show more
+ * Merchant
+ * Date
+ * Book travel
+ * Quick Action Button
+* Magic link page
diff --git a/help/travel.md b/help/travel.md
index 43e082896ce4..351f83f90ba4 100644
--- a/help/travel.md
+++ b/help/travel.md
@@ -7,7 +7,7 @@ Expensify Travel is a comprehensive travel management platform integrated direct
### [Main uses] When should I use Expensify Travel?
Expensify Travel is perfect for any situation involving corporate or personal travel, including:
-* **Booking business travel** - Book flights, hotels, and car rentals in a few clicks, all within the Expensify platform.
+* **Booking business travel** - Book flights, hotels, car rentals, and train travel in a few presses, all within the Expensify platform.
* **Tracking travel expenses** - Automatically capture travel-related expenses such as airfare, lodging, and meals, ensuring everything is logged without manual input.
* **Managing employee travel** - Empower managers to oversee travel bookings, set travel policies, and approve expenses in real time.
* **Ensuring compliance with travel policies** - Use travel policies to enforce company rules around budgets, preferred vendors, and travel categories.
@@ -38,59 +38,104 @@ Expensify Travel integrates a seamless booking experience directly into the app:
* **Flights** - Search for and book flights, choosing from corporate-approved airlines or vendors.
* **Hotels** - Book hotels using preferred vendor rates or select your own accommodations, with policy checks to ensure compliance.
* **Car rentals** - Rent vehicles from top providers, with automatic receipt tracking and expense capture.
-* **All in one place** - View and manage your full itinerary (flights, hotels, cars) from a single interface.
+* **Trains** - Book train travel alongside other modes of transportation.
+* **All in one place** - View and manage your full itinerary (flights, hotels, cars, trains) from a single interface.
-### [Travel policies] How do I enforce company travel policies?
+### Travel Policies
Corporate travel policies can be configured in Expensify Travel to ensure compliance:
* **Budgets** - Set maximum budgets for flights, hotels, and other travel-related expenses.
* **Preferred vendors** - Require employees to book through specific airlines, hotel chains, or rental agencies to take advantage of corporate rates.
* **Approval workflows** - Ensure all travel plans are reviewed and approved by the appropriate managers before booking.
* **Expense categories** - Automatically categorize travel expenses in line with company accounting policies.
-### [Approvals] How does the travel approval process work?
-Travel approvals in Expensify are designed to ensure compliance before any bookings are confirmed:
-1. **Request travel** - Employees submit travel requests, including flights, hotels, and car rentals, directly in the app.
-2. **Automatic policy checks** - Expensify automatically flags any out-of-policy bookings or expenses for manager review.
-3. **Manager approval** - Managers can approve or reject travel requests with one click, ensuring compliance before the trip is booked.
-4. **Track approval status** - Both employees and managers can monitor the status of a travel request in real time.
+### Approval Methods
+Expensify Travel offers three approval methods to accommodate different organizational needs: Soft Approval, Hard Approval, and Passive Approval.
-### [Expense integration] How does Expensify Travel integrate with Expensify Expense?
-Expensify Travel works seamlessly with Expensify Expense to automate the handling of travel expenses:
-* **Automatic expense capture** - Travel-related expenses (flights, hotels, meals) are automatically imported into Expensify Expense for easy tracking and reimbursement.
-* **Real-time tracking** - Travel expenses appear in your expense report as soon as they are incurred, providing real-time visibility into costs.
-* **One-click submission** - Employees can submit all travel expenses in a single report, and managers can approve them in bulk.
+- **Soft Approval**: Bookings are automatically approved unless a manager declines them within 24 hours. If not declined, the arrangements proceed even if they are out of policy.
+- **Hard Approval**: Bookings are automatically canceled if not approved within 24 hours.
+- **Passive Approval**: Managers are notified of out-of-policy travel, but no action is required.
-### [Corporate cards] Can I use Expensify Cards with Expensify Travel?
-Yes, Expensify Travel integrates with Expensify Cards:
-* **Automatic e-receipts** - Travel purchases made with an Expensify Card automatically generate e-receipts, eliminating the need for paper receipts.
-* **Real-time expense tracking** - Expenses made with the Expensify Card are logged in real-time and categorized according to your travel policies.
-* **Spend controls** - Set card limits and track spend in real-time to ensure that employees stay within budget.
-
-## Platforms
-Expensify Travel is accessible from all platforms, making it easy to manage travel from anywhere:
-* **Web app** - Manage your travel plans from your desktop via the Expensify web app.
-* **Mobile app** - Book travel and track expenses on the go using the Expensify mobile app for iOS and Android.
-* **Desktop app** - Use the Expensify desktop app for Mac or Windows to access the full range of travel and expense management features.
+### Travel Member Roles
+Assign roles to manage travel permissions within Expensify Travel:
+* **Traveler** - Can only book travel for themselves.
+* **Travel Arranger** - Can book travel for themselves and for other workspace members. Arrangers can be set to arrange travel for everyone in the workspace or for specific individuals only.
+* **Company Admin** - Can book travel for themselves as well as any other workspace members. They can also access administrative features to define travel policies, add or remove users, configure corporate cards as payment methods, view analytics and metrics, and use the Safety feature.
## Tutorials
-### [Book travel] How do I book a flight, hotel, or car rental?
-1. Navigate to the **Travel** section in the Expensify app.
-2. Select **Book Flight**, **Book Hotel**, or **Book Car Rental**.
-3. Enter your travel dates, destination, and any other required details.
-4. Select a flight, hotel, or car from the available options.
-5. Confirm your booking and add it to your travel itinerary.
-
-### [Submit travel request] How do I submit a travel request for approval?
+### [Book Travel] How do I book a flight, hotel, or car rental?
+To book travel from the Expensify app, follow these steps:
+1. Press the **Travel** tab.
+2. Press **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 selected arrangement (destination, dates of travel, etc.).
+5. Select all the details for the arrangement you wish to book.
+6. Review the booking details and press **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.
+
+### [Submit Travel Request] How do I submit a travel request for approval?
+To submit a travel request:
1. Go to **Create** > **Travel Request**.
2. Enter the details of your trip, including flights, hotels, and rental cars.
3. Review your travel options and ensure they are within policy.
4. Submit the request to your manager for approval.
-### [Approve travel] How do I approve a travel request?
+### [Approve Travel] How do I approve a travel request?
+To approve a travel request:
1. Go to your **Inbox** and find the travel request awaiting approval.
2. Review the trip details, including any out-of-policy flags.
-3. Click **Approve** or **Reject** as appropriate.
+3. Press **Approve** or **Reject** as appropriate.
+
+### [Edit or Cancel Travel Arrangements] How do I modify or cancel a travel booking?
+If you need to edit or cancel your travel arrangements, you can do so through the Expensify app:
+
+1. Open the Trip chat in your inbox to review your travel arrangements.
+2. Press your profile image or icon in the bottom left menu.
+3. Scroll down and press **Workspaces** in the left menu.
+4. Select the workspace the travel is booked under.
+5. Tap into the booking to see more details.
+6. Press **Trip Support** for assistance.
+
+If there are unexpected changes to your itinerary, such as a flight cancellation, Expensify’s travel partner **Spotnana** will reach out to provide updates. Note that any modifications, exchanges, or cancellations made through support will incur a $25 booking change fee.
+
+### [Configure Travel Policy] How do I set up a travel policy for my workspace?
+Workspace admins can create and update travel policies to establish travel rules for different groups of travelers. To configure a travel policy:
+
+1. Press the **Travel** tab and select **Book or manage travel**.
+2. Select the **Program** tab and choose **Policies**.
+3. Under Employee or Non-employee, press **Add new** to create a new policy.
+4. In the **Edit members** section, select the group of employees for the policy.
+5. Choose travel preferences to modify: General, flight, hotel, car, or rail.
+6. Press the paperclip icon next to each setting to de-couple it from the default policy.
+7. Update the desired settings and save changes.
+
+### [Demo Video] How can I watch a demo of Expensify Travel?
+To see how Expensify Travel works, watch the demo video:
+- The video provides a comprehensive overview of using Expensify Travel for booking and managing travel.
+
+### [Set Approval Method] How do I set the approval method for travel expenses?
+To configure the approval method for travel expenses in Expensify:
+1. Press the **Travel** tab and choose **Book or manage travel**.
+2. Navigate to the **Program** tab and select **Policies**.
+3. Under the General section, select the approval methods for Flights, Hotels, Cars, and Rail, choosing between Soft Approval, Hard Approval, or Passive Approval.
+
+### [Manage Travel Member Roles] How do I assign roles to travel members?
+To manage travel member roles within Expensify:
+1. Press the **Travel** tab and select **Book or manage travel**.
+2. Select the **Program** tab and choose **Users**.
+3. Press the name of the member whose role you wish to update.
+4. Press the **Roles** tab and select the desired role.
+5. Press **Save** to confirm the changes.
+
+### [Approve Travel Booking] How do I approve or decline a travel booking?
+To manage travel booking approvals effectively:
+1. Once an employee books a trip, you will receive an email notification with booking details.
+2. For **Soft Approval**, no action is required to approve, but to decline, follow the email prompt within 24 hours and press **Decline booking**, then **Deny Booking**.
+3. For **Hard Approval**, press **Approve booking** to confirm or **Decline booking** to reject, then follow the respective prompts.
## FAQ
@@ -109,3 +154,23 @@ Yes, Expensify Travel supports international bookings and expense tracking in mu
### How do I integrate Expensify Travel with my company’s existing travel policies?
You can configure travel policies directly in Expensify by setting budgets, preferred vendors, and approval workflows. These policies will automatically be enforced whenever employees book travel.
+### Are extended approval windows given for trips booked over the weekend or during company holidays?
+No, the approval window is fixed at 24 hours from when the trip is booked.
+
+### How does Expensify Travel handle approvals when the assigned approver is out of office?
+It is recommended to have multiple approvers set up for travel, as there is no delegated approval for out-of-office scenarios.
+
+### Can travelers upload a document when submitting a trip for approval?
+Travelers cannot upload a document at the time of trip submission, but companies can use a 'reason code' in the Out of Policy rules, which travelers complete at checkout. Documents can then be added to the expense report during submission in Expensify.
+
+### [Expense Integration] How do I use Expensify Travel with Expensify Expense?
+Expensify Travel works seamlessly with Expensify Expense to automate the handling of travel expenses:
+* **Automatic expense capture** - Travel-related expenses (flights, hotels, meals) are automatically imported into Expensify Expense for easy tracking and reimbursement.
+* **Real-time tracking** - Travel expenses appear in your expense report as soon as they are incurred, providing real-time visibility into costs.
+* **One-click submission** - Employees can submit all travel expenses in a single report, and managers can approve them in bulk.
+
+### [Corporate Cards] Can I use Expensify Cards with Expensify Travel?
+Yes, Expensify Travel integrates with Expensify Cards:
+* **Automatic e-receipts** - Travel purchases made with an Expensify Card automatically generate e-receipts, eliminating the need for paper receipts.
+* **Real-time expense tracking** - Expenses made with the Expensify Card are logged in real time and categorized according to your travel policies.
+* **Spend controls** - Set card limits and track spend in real time to ensure that employees stay within budget.
\ No newline at end of file
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 10e283ae43e9..04030d1972f0 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 9.0.56
+ 9.0.58CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 9.0.56.7
+ 9.0.58.1FullStoryOrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index f95e27e3a725..a1fc5be5e7ae 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 9.0.56
+ 9.0.58CFBundleSignature????CFBundleVersion
- 9.0.56.7
+ 9.0.58.1
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 9ed1578badb6..4fedc3fe0674 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName$(PRODUCT_NAME)CFBundleShortVersionString
- 9.0.56
+ 9.0.58CFBundleVersion
- 9.0.56.7
+ 9.0.58.1NSExtensionNSExtensionPointIdentifier
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index d1851cbce1af..6494782a6ec0 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -2503,7 +2503,7 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- - RNReanimated (3.15.1):
+ - RNReanimated (3.15.3):
- DoubleConversion
- glog
- hermes-engine
@@ -2523,10 +2523,10 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- - RNReanimated/reanimated (= 3.15.1)
- - RNReanimated/worklets (= 3.15.1)
+ - RNReanimated/reanimated (= 3.15.3)
+ - RNReanimated/worklets (= 3.15.3)
- Yoga
- - RNReanimated/reanimated (3.15.1):
+ - RNReanimated/reanimated (3.15.3):
- DoubleConversion
- glog
- hermes-engine
@@ -2547,7 +2547,7 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- - RNReanimated/worklets (3.15.1):
+ - RNReanimated/worklets (3.15.3):
- DoubleConversion
- glog
- hermes-engine
@@ -3269,7 +3269,7 @@ SPEC CHECKSUMS:
rnmapbox-maps: 460d6ff97ae49c7d5708c3212c6521697c36a0c4
RNPermissions: 0b1429b55af59d1d08b75a8be2459f65a8ac3f28
RNReactNativeHapticFeedback: 73756a3477a5a622fa16862a3ab0d0fc5e5edff5
- RNReanimated: 76901886830e1032f16bbf820153f7dc3f02d51d
+ RNReanimated: f46df3b08d5d59cd83c47bb6697ce88e565e0dc7
RNScreens: de6e57426ba0e6cbc3fb5b4f496e7f08cb2773c2
RNShare: bd4fe9b95d1ee89a200778cc0753ebe650154bb0
RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852
diff --git a/package-lock.json b/package-lock.json
index c3af1507f286..2d40dfece91d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.0.56-7",
+ "version": "9.0.58-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.56-7",
+ "version": "9.0.58-1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -51,7 +51,7 @@
"date-fns-tz": "^3.2.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "2.0.101",
+ "expensify-common": "2.0.103",
"expo": "51.0.31",
"expo-av": "14.0.7",
"expo-image": "1.12.15",
@@ -104,7 +104,7 @@
"react-native-plaid-link-sdk": "11.11.0",
"react-native-qrcode-svg": "6.3.11",
"react-native-quick-sqlite": "git+https://github.com/margelo/react-native-quick-sqlite#99f34ebefa91698945f3ed26622e002bd79489e0",
- "react-native-reanimated": "3.15.1",
+ "react-native-reanimated": "3.15.3",
"react-native-release-profiler": "^0.2.1",
"react-native-render-html": "6.3.1",
"react-native-safe-area-context": "4.10.9",
@@ -24154,9 +24154,9 @@
}
},
"node_modules/expensify-common": {
- "version": "2.0.101",
- "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.101.tgz",
- "integrity": "sha512-5TStDQGsXGJjdk64PBhEdXz/3H6QDlgoanEWI076okL5un4Qd2sSRfxHRiH61foHGsswXJFIZBHK3sysKDOJ4A==",
+ "version": "2.0.103",
+ "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.103.tgz",
+ "integrity": "sha512-Q42bUK6TeB87qN4MEBDlhNH1qQqUXY+tJKCZTt01Zv+lcn7KemudOCt7GNoEwfR7LLWsWuec7Vb5x45rQJNC2A==",
"license": "MIT",
"dependencies": {
"awesome-phonenumber": "^5.4.0",
@@ -35626,9 +35626,10 @@
}
},
"node_modules/react-native-reanimated": {
- "version": "3.15.1",
- "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.15.1.tgz",
- "integrity": "sha512-DbBeUUExtJ1x1nfE94I8qgDgWjq5ztM3IO/+XFO+agOkPeVpBs5cRnxHfJKrjqJ2MgwhJOUDmtHxo+tDsoeitg==",
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.15.3.tgz",
+ "integrity": "sha512-5QBk/7PZvZ98Adxm4MRyglwzsRzReTQIe4Hd2wbBBAZ68IC4OYKvsc8cPEjgx3/1mG8HgHFYhbcDe5U2RjeFqw==",
+ "license": "MIT",
"dependencies": {
"@babel/plugin-transform-arrow-functions": "^7.0.0-0",
"@babel/plugin-transform-class-properties": "^7.0.0-0",
diff --git a/package.json b/package.json
index 295066f32b9b..b87d36c7aa4f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.56-7",
+ "version": "9.0.58-1",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -108,7 +108,7 @@
"date-fns-tz": "^3.2.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "2.0.101",
+ "expensify-common": "2.0.103",
"expo": "51.0.31",
"expo-av": "14.0.7",
"expo-image": "1.12.15",
@@ -161,7 +161,7 @@
"react-native-plaid-link-sdk": "11.11.0",
"react-native-qrcode-svg": "6.3.11",
"react-native-quick-sqlite": "git+https://github.com/margelo/react-native-quick-sqlite#99f34ebefa91698945f3ed26622e002bd79489e0",
- "react-native-reanimated": "3.15.1",
+ "react-native-reanimated": "3.15.3",
"react-native-release-profiler": "^0.2.1",
"react-native-render-html": "6.3.1",
"react-native-safe-area-context": "4.10.9",
diff --git a/patches/react-native+0.75.2+012+Add-onPaste-to-TextInput.patch b/patches/react-native+0.75.2+012+Add-onPaste-to-TextInput.patch
index 55657e61dc09..e5ddeee282fb 100644
--- a/patches/react-native+0.75.2+012+Add-onPaste-to-TextInput.patch
+++ b/patches/react-native+0.75.2+012+Add-onPaste-to-TextInput.patch
@@ -1,8 +1,8 @@
diff --git a/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js
-index a77e5b4..5e58ec4 100644
+index 6c4bbb2..770dfee 100644
--- a/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js
+++ b/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js
-@@ -455,6 +455,21 @@ export type NativeProps = $ReadOnly<{|
+@@ -462,6 +462,21 @@ export type NativeProps = $ReadOnly<{|
|}>,
>,
@@ -24,7 +24,7 @@ index a77e5b4..5e58ec4 100644
/**
* The string that will be rendered before text input has been entered.
*/
-@@ -658,6 +673,9 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = {
+@@ -668,6 +683,9 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = {
topScroll: {
registrationName: 'onScroll',
},
@@ -34,7 +34,7 @@ index a77e5b4..5e58ec4 100644
},
validAttributes: {
maxFontSizeMultiplier: true,
-@@ -711,6 +729,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = {
+@@ -722,6 +740,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = {
secureTextEntry: true,
textBreakStrategy: true,
onScroll: true,
@@ -43,7 +43,7 @@ index a77e5b4..5e58ec4 100644
disableFullscreenUI: true,
includeFontPadding: true,
diff --git a/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js b/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js
-index 3bfe22c..1cb122f 100644
+index 8326797..dbfe5d5 100644
--- a/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js
+++ b/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js
@@ -88,6 +88,9 @@ const RCTTextInputViewConfig = {
@@ -56,7 +56,7 @@ index 3bfe22c..1cb122f 100644
},
validAttributes: {
fontSize: true,
-@@ -153,6 +156,7 @@ const RCTTextInputViewConfig = {
+@@ -154,6 +157,7 @@ const RCTTextInputViewConfig = {
onSelectionChange: true,
onContentSizeChange: true,
onScroll: true,
@@ -170,7 +170,7 @@ index a94fb19..8cfde15 100644
* The string that will be rendered before text input has been entered.
*/
diff --git a/node_modules/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/node_modules/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm
-index d5e2e22..a11679a 100644
+index d5e2e22..065a819 100644
--- a/node_modules/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm
+++ b/node_modules/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm
@@ -13,6 +13,10 @@
@@ -184,7 +184,7 @@ index d5e2e22..a11679a 100644
@implementation RCTUITextView {
UILabel *_placeholderView;
UITextView *_detachedTextView;
-@@ -172,7 +176,32 @@ - (void)scrollRangeToVisible:(NSRange)range
+@@ -172,7 +176,31 @@ - (void)scrollRangeToVisible:(NSRange)range
- (void)paste:(id)sender
{
_textWasPasted = YES;
@@ -197,8 +197,7 @@ index d5e2e22..a11679a 100644
+ if (UTTypeConformsTo((__bridge CFStringRef)identifier, kUTTypeImage)) {
+ NSString *MIMEType = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)identifier, kUTTagClassMIMEType);
+ NSString *fileExtension = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)identifier, kUTTagClassFilenameExtension);
-+ NSString *fileName = [NSString stringWithFormat:@"%@.%@", [[NSUUID UUID] UUIDString], fileExtension];
-+ NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:fileName];
++ NSString *filePath = RCTTempFilePath(fileExtension, nil);
+ NSURL *fileURL = [NSURL fileURLWithPath:filePath];
+ NSData *fileData = [clipboard dataForPasteboardType:identifier];
+ [fileData writeToFile:filePath atomically:YES];
@@ -218,7 +217,7 @@ index d5e2e22..a11679a 100644
}
// Turn off scroll animation to fix flaky scrolling.
-@@ -264,6 +293,10 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender
+@@ -264,6 +292,10 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender
return NO;
}
@@ -346,7 +345,7 @@ index f58f147..e367394 100644
RCT_EXPORT_VIEW_PROPERTY(mostRecentEventCount, NSInteger)
diff --git a/node_modules/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm b/node_modules/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm
-index 0318671..bb165d7 100644
+index 0318671..667e646 100644
--- a/node_modules/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm
+++ b/node_modules/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm
@@ -12,6 +12,10 @@
@@ -371,7 +370,7 @@ index 0318671..bb165d7 100644
return [super canPerformAction:action withSender:sender];
}
-@@ -222,7 +230,32 @@ - (void)scrollRangeToVisible:(NSRange)range
+@@ -222,7 +230,31 @@ - (void)scrollRangeToVisible:(NSRange)range
- (void)paste:(id)sender
{
_textWasPasted = YES;
@@ -384,8 +383,7 @@ index 0318671..bb165d7 100644
+ if (UTTypeConformsTo((__bridge CFStringRef)identifier, kUTTypeImage)) {
+ NSString *MIMEType = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)identifier, kUTTagClassMIMEType);
+ NSString *fileExtension = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)identifier, kUTTagClassFilenameExtension);
-+ NSString *fileName = [NSString stringWithFormat:@"%@.%@", [[NSUUID UUID] UUIDString], fileExtension];
-+ NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:fileName];
++ NSString *filePath = RCTTempFilePath(fileExtension, nil);
+ NSURL *fileURL = [NSURL fileURLWithPath:filePath];
+ NSData *fileData = [clipboard dataForPasteboardType:identifier];
+ [fileData writeToFile:filePath atomically:YES];
diff --git a/patches/react-native+0.75.2+018+Add-regex-to-TextInput.patch b/patches/react-native+0.75.2+018+Add-regex-to-TextInput.patch
deleted file mode 100644
index 6c511d8cbec1..000000000000
--- a/patches/react-native+0.75.2+018+Add-regex-to-TextInput.patch
+++ /dev/null
@@ -1,299 +0,0 @@
-diff --git a/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js
-index 770dfee..73e439b 100644
---- a/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js
-+++ b/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js
-@@ -329,6 +329,12 @@ export type NativeProps = $ReadOnly<{|
- */
- returnKeyType?: WithDefault,
-
-+ /**
-+ * Restricts the text value to match the specified regular expression. Use this
-+ * instead of implementing the logic in JS to avoid flicker.
-+ */
-+ regex?: ?string,
-+
- /**
- * Limits the maximum number of characters that can be entered. Use this
- * instead of implementing the logic in JS to avoid flicker.
-@@ -699,6 +705,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = {
- process: require('../../StyleSheet/processColor').default,
- },
- maxLength: true,
-+ regex: true,
- selectTextOnFocus: true,
- textShadowRadius: true,
- underlineColorAndroid: {
-diff --git a/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js b/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js
-index dbfe5d5..1f359ba 100644
---- a/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js
-+++ b/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js
-@@ -151,6 +151,7 @@ const RCTTextInputViewConfig = {
- autoFocus: true,
- lineBreakStrategyIOS: true,
- smartInsertDelete: true,
-+ regex: true,
- ...ConditionallyIgnoredEventHandlers({
- onClear: true,
- onChange: true,
-diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts b/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts
-index 20501f7..76f30b9 100644
---- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts
-+++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts
-@@ -701,6 +701,12 @@ export interface TextInputProps
- */
- inputMode?: InputModeOptions | undefined;
-
-+ /**
-+ * Restricts the text value to match the specified regular expression. Use this
-+ * instead of implementing the logic in JS to avoid flicker.
-+ */
-+ regex?: string | undefined;
-+
- /**
- * Limits the maximum number of characters that can be entered.
- * Use this instead of implementing the logic in JS to avoid flicker.
-diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js b/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js
-index 2f35731..5bb94bc 100644
---- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js
-+++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js
-@@ -697,6 +697,12 @@ export type Props = $ReadOnly<{|
- */
- maxFontSizeMultiplier?: ?number,
-
-+ /**
-+ * Restricts the text value to match the specified regular expression. Use this
-+ * instead of implementing the logic in JS to avoid flicker.
-+ */
-+ regex?: ?string,
-+
- /**
- * Limits the maximum number of characters that can be entered. Use this
- * instead of implementing the logic in JS to avoid flicker.
-diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js
-index 8cfde15..4f3345c 100644
---- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js
-+++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js
-@@ -731,6 +731,12 @@ export type Props = $ReadOnly<{|
- */
- maxFontSizeMultiplier?: ?number,
-
-+ /**
-+ * Restricts the text value to match the specified regular expression. Use this
-+ * instead of implementing the logic in JS to avoid flicker.
-+ */
-+ regex?: ?string,
-+
- /**
- * Limits the maximum number of characters that can be entered. Use this
- * instead of implementing the logic in JS to avoid flicker.
-diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm
-index e367394..95f21f2 100644
---- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm
-+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm
-@@ -59,6 +59,7 @@ @implementation RCTBaseTextInputViewManager {
- RCT_EXPORT_VIEW_PROPERTY(inputAccessoryViewID, NSString)
- RCT_EXPORT_VIEW_PROPERTY(textContentType, NSString)
- RCT_EXPORT_VIEW_PROPERTY(passwordRules, NSString)
-+RCT_EXPORT_VIEW_PROPERTY(regex, NSString)
-
- RCT_EXPORT_VIEW_PROPERTY(onChange, RCTBubblingEventBlock)
- RCT_EXPORT_VIEW_PROPERTY(onKeyPressSync, RCTDirectEventBlock)
-diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
-index db7cba4..f85f95a 100644
---- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
-+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
-@@ -34,6 +34,7 @@ @implementation RCTTextInputComponentView {
- UIView *_backedTextInputView;
- NSUInteger _mostRecentEventCount;
- NSAttributedString *_lastStringStateWasUpdatedWith;
-+ NSRegularExpression *_regex;
-
- /*
- * UIKit uses either UITextField or UITextView as its UIKit element for . UITextField is for single line
-@@ -224,6 +225,13 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
- if (newTextInputProps.inputAccessoryViewID != oldTextInputProps.inputAccessoryViewID) {
- _backedTextInputView.inputAccessoryViewID = RCTNSStringFromString(newTextInputProps.inputAccessoryViewID);
- }
-+
-+ if (newTextInputProps.regex != oldTextInputProps.regex) {
-+ _regex = [NSRegularExpression regularExpressionWithPattern:RCTNSStringFromString(newTextInputProps.regex)
-+ options:0
-+ error:nil];
-+ }
-+
- [super updateProps:props oldProps:oldProps];
-
- [self setDefaultInputAccessoryView];
-@@ -359,6 +367,14 @@ - (NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRange)range
- }
- }
-
-+ if (_regex) {
-+ NSMutableString *newString = [_backedTextInputView.attributedText.string mutableCopy];
-+ [newString replaceCharactersInRange:range withString:text];
-+ if ([_regex numberOfMatchesInString:newString options:0 range:NSMakeRange(0, newString.length)] == 0) {
-+ return nil;
-+ }
-+ }
-+
- if (props.maxLength) {
- NSInteger allowedLength = props.maxLength - _backedTextInputView.attributedText.string.length + range.length;
-
-diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java
-index 2cceb14..8fdc0c1 100644
---- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java
-+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java
-@@ -824,6 +824,47 @@ public class ReactTextInputManager extends BaseViewManager 0) {
-+ LinkedList list = new LinkedList<>();
-+ for (InputFilter currentFilter : currentFilters) {
-+ if (!(currentFilter instanceof RegexFilter)) {
-+ list.add(currentFilter);
-+ }
-+ }
-+ if (!list.isEmpty()) {
-+ newFilters = (InputFilter[]) list.toArray(new InputFilter[list.size()]);
-+ }
-+ }
-+ } else {
-+ if (currentFilters.length > 0) {
-+ newFilters = currentFilters;
-+ boolean replaced = false;
-+ for (int i = 0; i < currentFilters.length; i++) {
-+ if (currentFilters[i] instanceof RegexFilter) {
-+ currentFilters[i] = new RegexFilter(regex);
-+ replaced = true;
-+ }
-+ }
-+ if (!replaced) {
-+ newFilters = new InputFilter[currentFilters.length + 1];
-+ System.arraycopy(currentFilters, 0, newFilters, 0, currentFilters.length);
-+ newFilters[currentFilters.length] = new RegexFilter(regex);
-+ }
-+ } else {
-+ newFilters = new InputFilter[1];
-+ newFilters[0] = new RegexFilter(regex);
-+ }
-+ }
-+
-+ view.setFilters(newFilters);
-+ }
-+
- @ReactProp(name = "maxLength")
- public void setMaxLength(ReactEditText view, @Nullable Integer maxLength) {
- InputFilter[] currentFilters = view.getFilters();
-@@ -854,7 +895,7 @@ public class ReactTextInputManager extends BaseViewManager `[Take a self-guided product tour](${navatticURL}) and learn about everything Expensify has to offer.`,
+};
+
const onboardingEmployerOrSubmitMessage: OnboardingMessageType = {
message: 'Getting paid back is as easy as sending a message. Let’s go over the basics.',
video: {
@@ -99,6 +106,7 @@ const onboardingEmployerOrSubmitMessage: OnboardingMessageType = {
height: 960,
},
tasks: [
+ selfGuidedTourTask,
{
type: 'submitExpense',
autoCompleted: false,
@@ -265,6 +273,7 @@ type OnboardingTaskType = {
integrationName: string;
workspaceAccountingLink: string;
workspaceSettingsLink: string;
+ navatticURL: string;
}>,
) => string);
};
@@ -466,6 +475,7 @@ const CONST = {
OLD_DOT_ANDROID: 'https://play.google.com/store/apps/details?id=org.me.mobiexpensifyg&hl=en_US&pli=1',
OLD_DOT_IOS: 'https://apps.apple.com/us/app/expensify-expense-tracker/id471713959',
},
+ COMPANY_WEBSITE_DEFAULT_SCHEME: 'http',
DATE: {
SQL_DATE_TIME: 'YYYY-MM-DD HH:mm:ss',
FNS_FORMAT_STRING: 'yyyy-MM-dd',
@@ -621,7 +631,6 @@ const CONST = {
COMPANY_CARD_FEEDS: 'companyCardFeeds',
DIRECT_FEEDS: 'directFeeds',
NETSUITE_USA_TAX: 'netsuiteUsaTax',
- NEW_DOT_COPILOT: 'newDotCopilot',
COMBINED_TRACK_SUBMIT: 'combinedTrackSubmit',
CATEGORY_AND_TAG_APPROVERS: 'categoryAndTagApprovers',
PER_DIEM: 'newDotPerDiem',
@@ -880,8 +889,10 @@ const CONST = {
// Use Environment.getEnvironmentURL to get the complete URL with port number
DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:',
NAVATTIC: {
- ADMIN_TOUR: 'https://expensify.navattic.com/kh204a7',
- EMPLOYEE_TOUR: 'https://expensify.navattic.com/35609gb',
+ ADMIN_TOUR_PRODUCTION: 'https://expensify.navattic.com/kh204a7',
+ ADMIN_TOUR_STAGING: 'https://expensify.navattic.com/3i300k18',
+ EMPLOYEE_TOUR_PRODUCTION: 'https://expensify.navattic.com/35609gb',
+ EMPLOYEE_TOUR_STAGING: 'https://expensify.navattic.com/cf15002s',
},
OLDDOT_URLS: {
@@ -1600,6 +1611,7 @@ const CONST = {
CONTRIBUTORS: 'contributors@expensify.com',
FIRST_RESPONDER: 'firstresponders@expensify.com',
GUIDES_DOMAIN: 'team.expensify.com',
+ QA_DOMAIN: 'applause.expensifail.com',
HELP: 'help@expensify.com',
INTEGRATION_TESTING_CREDS: 'integrationtestingcreds@expensify.com',
NOTIFICATIONS: 'notifications@expensify.com',
@@ -2737,7 +2749,6 @@ const CONST = {
DAILY: 'daily',
MONTHLY: 'monthly',
},
- CARD_TITLE_INPUT_LIMIT: 255,
MANAGE_EXPENSIFY_CARDS_ARTICLE_LINK: 'https://help.expensify.com/articles/new-expensify/expensify-card/Manage-Expensify-Cards',
},
COMPANY_CARDS: {
@@ -3041,10 +3052,6 @@ const CONST = {
get RESTRICTED_ACCOUNT_IDS() {
return [this.ACCOUNT_ID.NOTIFICATIONS];
},
- // Account IDs that can't be added as a group member
- get NON_ADDABLE_ACCOUNT_IDS() {
- return [this.ACCOUNT_ID.NOTIFICATIONS, this.ACCOUNT_ID.CHRONOS];
- },
// Auth limit is 60k for the column but we store edits and other metadata along the html so let's use a lower limit to accommodate for it.
MAX_COMMENT_LENGTH: 10000,
@@ -4883,6 +4890,7 @@ const CONST = {
'\n' +
'*Your new workspace is ready! It’ll keep all of your spend (and chats) in one place.*',
},
+ selfGuidedTourTask,
{
type: 'meetGuide',
autoCompleted: false,
@@ -4996,7 +5004,10 @@ const CONST = {
},
],
},
- [onboardingChoices.PERSONAL_SPEND]: onboardingPersonalSpendMessage,
+ [onboardingChoices.PERSONAL_SPEND]: {
+ ...onboardingPersonalSpendMessage,
+ tasks: [selfGuidedTourTask, ...onboardingPersonalSpendMessage.tasks],
+ },
[onboardingChoices.CHAT_SPLIT]: {
message: 'Splitting bills with friends is as easy as sending a message. Here’s how.',
video: {
@@ -5007,6 +5018,7 @@ const CONST = {
height: 960,
},
tasks: [
+ selfGuidedTourTask,
{
type: 'startChat',
autoCompleted: false,
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 7d3d0edef36e..49dd42fa8281 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -449,6 +449,9 @@ const ONYXKEYS = {
/** Stores recently used currencies */
RECENTLY_USED_CURRENCIES: 'nvp_recentlyUsedCurrencies',
+ /** States whether we transitioned from OldDot to show only certain group of screens. It should be undefined on pure NewDot. */
+ IS_SINGLE_NEW_DOT_ENTRY: 'isSingleNewDotEntry',
+
/** Company cards custom names */
NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES: 'nvp_expensify_ccCustomNames',
@@ -1018,6 +1021,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.APPROVAL_WORKFLOW]: OnyxTypes.ApprovalWorkflowOnyx;
[ONYXKEYS.IMPORTED_SPREADSHEET]: OnyxTypes.ImportedSpreadsheet;
[ONYXKEYS.LAST_ROUTE]: string;
+ [ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY]: boolean | undefined;
[ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean;
[ONYXKEYS.SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP]: boolean;
[ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record;
diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx
index ad58294c0cc8..9a90de17595d 100644
--- a/src/components/AccountSwitcher.tsx
+++ b/src/components/AccountSwitcher.tsx
@@ -5,7 +5,6 @@ import {useOnyx} from 'react-native-onyx';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
-import usePermissions from '@hooks/usePermissions';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -34,7 +33,6 @@ function AccountSwitcher() {
const theme = useTheme();
const {translate} = useLocalize();
const {isOffline} = useNetwork();
- const {canUseNewDotCopilot} = usePermissions();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const [account] = useOnyx(ONYXKEYS.ACCOUNT);
const [session] = useOnyx(ONYXKEYS.SESSION);
@@ -47,7 +45,7 @@ function AccountSwitcher() {
const delegators = account?.delegatedAccess?.delegators ?? [];
const isActingAsDelegate = !!account?.delegatedAccess?.delegate ?? false;
- const canSwitchAccounts = canUseNewDotCopilot && (delegators.length > 0 || isActingAsDelegate);
+ const canSwitchAccounts = delegators.length > 0 || isActingAsDelegate;
const createBaseMenuItem = (
personalDetails: PersonalDetails | undefined,
@@ -87,7 +85,7 @@ function AccountSwitcher() {
}
const delegatePersonalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(delegateEmail);
- const error = ErrorUtils.getLatestErrorField(account?.delegatedAccess, 'connect');
+ const error = ErrorUtils.getLatestError(account?.delegatedAccess?.errorFields?.disconnect);
return [
createBaseMenuItem(delegatePersonalDetails, error, {
@@ -105,8 +103,9 @@ function AccountSwitcher() {
const delegatorMenuItems: PopoverMenuItem[] = delegators
.filter(({email}) => email !== currentUserPersonalDetails.login)
- .map(({email, role, errorFields}) => {
- const error = ErrorUtils.getLatestErrorField({errorFields}, 'connect');
+ .map(({email, role}) => {
+ const errorFields = account?.delegatedAccess?.errorFields ?? {};
+ const error = ErrorUtils.getLatestError(errorFields?.connect?.[email]);
const personalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(email);
return createBaseMenuItem(personalDetails, error, {
badgeText: translate('delegate.role', {role}),
diff --git a/src/components/AddPaymentCard/PaymentCardForm.tsx b/src/components/AddPaymentCard/PaymentCardForm.tsx
index f38ea60f1aad..5aaa23b238f7 100644
--- a/src/components/AddPaymentCard/PaymentCardForm.tsx
+++ b/src/components/AddPaymentCard/PaymentCardForm.tsx
@@ -177,18 +177,26 @@ function PaymentCardForm({
};
const onChangeCardNumber = useCallback((newValue: string) => {
- // replace all characters that are not spaces or digits
+ // Replace all characters that are not spaces or digits
let validCardNumber = newValue.replace(/[^\d ]/g, '');
- // gets only the first 16 digits if the inputted number have more digits than that
+ // Gets only the first 16 digits if the inputted number have more digits than that
validCardNumber = validCardNumber.match(/(?:\d *){1,16}/)?.[0] ?? '';
- // add the spacing between every 4 digits
- validCardNumber =
- validCardNumber
- .replace(/ /g, '')
- .match(/.{1,4}/g)
- ?.join(' ') ?? '';
+ // Remove all spaces to simplify formatting
+ const cleanedNumber = validCardNumber.replace(/ /g, '');
+
+ // Check if the number is a potential Amex card (starts with 34 or 37 and has up to 15 digits)
+ const isAmex = /^3[47]\d{0,13}$/.test(cleanedNumber);
+
+ // Format based on Amex or standard 4-4-4-4 pattern
+ if (isAmex) {
+ // Format as 4-6-5 for Amex
+ validCardNumber = cleanedNumber.replace(/(\d{1,4})(\d{1,6})?(\d{1,5})?/, (match, p1, p2, p3) => [p1, p2, p3].filter(Boolean).join(' '));
+ } else {
+ // Format as 4-4-4-4 for non-Amex
+ validCardNumber = cleanedNumber.match(/.{1,4}/g)?.join(' ') ?? '';
+ }
setCardNumber(validCardNumber);
}, []);
diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx
index de3a1fe39829..a230dfa1af8d 100644
--- a/src/components/AmountForm.tsx
+++ b/src/components/AmountForm.tsx
@@ -1,6 +1,6 @@
import type {ForwardedRef} from 'react';
import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react';
-import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native';
+import type {NativeSyntheticEvent} from 'react-native';
import {View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -16,7 +16,7 @@ import TextInput from './TextInput';
import isTextInputFocused from './TextInput/BaseTextInput/isTextInputFocused';
import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types';
import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol';
-import type TextInputWithCurrencySymbolProps from './TextInputWithCurrencySymbol/types';
+import type BaseTextInputWithCurrencySymbolProps from './TextInputWithCurrencySymbol/types';
type AmountFormProps = {
/** Amount supplied by the FormProvider */
@@ -51,7 +51,7 @@ type AmountFormProps = {
/** Number of decimals to display */
fixedDecimals?: number;
-} & Pick &
+} & Pick &
Pick;
/**
@@ -238,7 +238,6 @@ function AmountForm(
forwardDeletePressedRef.current = key === 'delete' || (allowedOS.includes(operatingSystem ?? '') && event.nativeEvent.ctrlKey && key === 'd');
};
- const regex = useMemo(() => MoneyRequestUtils.amountRegex(decimals, amountMaxLength), [decimals, amountMaxLength]);
const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit);
const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen();
@@ -262,7 +261,6 @@ function AmountForm(
keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD}
inputMode={CONST.INPUT_MODE.DECIMAL}
errorText={errorText}
- regex={regex}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
/>
@@ -292,17 +290,16 @@ function AmountForm(
}}
selectedCurrencyCode={currency}
selection={selection}
- onSelectionChange={(e: NativeSyntheticEvent) => {
+ onSelectionChange={(start, end) => {
if (!shouldUpdateSelection) {
return;
}
- setSelection(e.nativeEvent.selection);
+ setSelection({start, end});
}}
onKeyPress={textInputKeyPress}
isCurrencyPressable={isCurrencyPressable}
style={[styles.iouAmountTextInput]}
containerStyle={[styles.iouAmountTextInputContainer]}
- regex={regex}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
/>
diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx
index 2e0d3e62afa0..52c32ce1f584 100644
--- a/src/components/AmountTextInput.tsx
+++ b/src/components/AmountTextInput.tsx
@@ -39,7 +39,7 @@ type AmountTextInputProps = {
/** Hide the focus styles on TextInput */
hideFocusedState?: boolean;
-} & Pick;
+} & Pick;
function AmountTextInput(
{
diff --git a/src/components/AmountWithoutCurrencyForm.tsx b/src/components/AmountWithoutCurrencyForm.tsx
index 6a9fc22f68f8..78b7c84ecb54 100644
--- a/src/components/AmountWithoutCurrencyForm.tsx
+++ b/src/components/AmountWithoutCurrencyForm.tsx
@@ -1,8 +1,7 @@
-import React, {useCallback, useMemo, useState} from 'react';
+import React, {useCallback, useMemo} from 'react';
import type {ForwardedRef} from 'react';
-import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native';
import useLocalize from '@hooks/useLocalize';
-import {addLeadingZero, amountRegex, replaceAllDigits, replaceCommasWithPeriod, stripSpacesFromAmount, validateAmount} from '@libs/MoneyRequestUtils';
+import {addLeadingZero, replaceAllDigits, replaceCommasWithPeriod, stripSpacesFromAmount, validateAmount} from '@libs/MoneyRequestUtils';
import CONST from '@src/CONST';
import TextInput from './TextInput';
import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types';
@@ -22,11 +21,6 @@ function AmountWithoutCurrencyForm(
const {toLocaleDigit} = useLocalize();
const currentAmount = useMemo(() => (typeof amount === 'string' ? amount : ''), [amount]);
- const [selection, setSelection] = useState({
- start: currentAmount.length,
- end: currentAmount.length,
- });
- const decimals = 2;
/**
* Sets the selection and the amount accordingly to the value passed to the input
@@ -39,10 +33,7 @@ function AmountWithoutCurrencyForm(
const newAmountWithoutSpaces = stripSpacesFromAmount(newAmount);
const replacedCommasAmount = replaceCommasWithPeriod(newAmountWithoutSpaces);
const withLeadingZero = addLeadingZero(replacedCommasAmount);
- if (!validateAmount(withLeadingZero, decimals)) {
- // Use a shallow copy of selection to trigger setSelection
- // More info: https://github.com/Expensify/App/issues/16385
- setSelection((prevSelection) => ({...prevSelection}));
+ if (!validateAmount(withLeadingZero, 2)) {
return;
}
onInputChange?.(withLeadingZero);
@@ -50,17 +41,12 @@ function AmountWithoutCurrencyForm(
[onInputChange],
);
- const regex = useMemo(() => amountRegex(decimals), [decimals]);
const formattedAmount = replaceAllDigits(currentAmount, toLocaleDigit);
return (
) => {
- setSelection(e.nativeEvent.selection);
- }}
inputID={inputID}
name={name}
label={label}
@@ -69,7 +55,6 @@ function AmountWithoutCurrencyForm(
role={role}
ref={ref}
keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD}
- regex={regex}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
/>
diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx
index bc4467e82f01..70966a05b918 100644
--- a/src/components/AttachmentPicker/index.native.tsx
+++ b/src/components/AttachmentPicker/index.native.tsx
@@ -16,6 +16,8 @@ import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import CONST from '@src/CONST';
@@ -117,6 +119,8 @@ function AttachmentPicker({
}: AttachmentPickerProps) {
const styles = useThemeStyles();
const [isVisible, setIsVisible] = useState(false);
+ const StyleUtils = useStyleUtils();
+ const theme = useTheme();
const completeAttachmentSelection = useRef<(data: FileObject[]) => void>(() => {});
const onModalHide = useRef<() => void>();
@@ -444,6 +448,7 @@ function AttachmentPicker({
title={translate(item.textTranslationKey)}
onPress={() => selectItem(item)}
focused={focusedIndex === menuIndex}
+ wrapperStyle={StyleUtils.getItemBackgroundColorStyle(false, focusedIndex === menuIndex, false, theme.activeComponentBG, theme.hoverComponentBG)}
/>
))}
diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx
index 287ec3359175..9906f9b04c3c 100644
--- a/src/components/DatePicker/CalendarPicker/index.tsx
+++ b/src/components/DatePicker/CalendarPicker/index.tsx
@@ -1,6 +1,6 @@
import {addMonths, endOfDay, endOfMonth, format, getYear, isSameDay, parseISO, setDate, setYear, startOfDay, startOfMonth, subMonths} from 'date-fns';
import {Str} from 'expensify-common';
-import React, {useState} from 'react';
+import React, {useRef, useState} from 'react';
import {View} from 'react-native';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
@@ -51,6 +51,7 @@ function CalendarPicker({
const themeStyles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {preferredLocale, translate} = useLocalize();
+ const pressableRef = useRef(null);
const [currentDateView, setCurrentDateView] = useState(getInitialCurrentDateView(value, minDate, maxDate));
@@ -148,7 +149,11 @@ function CalendarPicker({
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
>
setIsYearPickerVisible(true)}
+ onPress={() => {
+ pressableRef?.current?.blur();
+ setIsYearPickerVisible(true);
+ }}
+ ref={pressableRef}
style={[themeStyles.alignItemsCenter, themeStyles.flexRow, themeStyles.flex1, themeStyles.justifyContentStart]}
wrapperStyle={[themeStyles.alignItemsCenter]}
hoverDimmingValue={1}
diff --git a/src/components/EmojiPicker/EmojiPickerMenuItem/index.tsx b/src/components/EmojiPicker/EmojiPickerMenuItem/index.tsx
index 8aaf4a14e560..1629089dace5 100644
--- a/src/components/EmojiPicker/EmojiPickerMenuItem/index.tsx
+++ b/src/components/EmojiPicker/EmojiPickerMenuItem/index.tsx
@@ -68,8 +68,7 @@ function EmojiPickerMenuItem({
ref.current = el ?? null;
}}
style={({pressed}) => [
- isFocused ? themeStyles.emojiItemKeyboardHighlighted : {},
- isHovered || isHighlighted ? themeStyles.emojiItemHighlighted : {},
+ isFocused || isHovered || isHighlighted ? themeStyles.emojiItemHighlighted : {},
Browser.isMobile() && StyleUtils.getButtonBackgroundColorStyle(getButtonState(false, pressed)),
themeStyles.emojiItem,
]}
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx
index 3ab907dc767d..94a46d861dde 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx
@@ -10,10 +10,14 @@ import Text from '@components/Text';
import useCurrentReportID from '@hooks/useCurrentReportID';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
-import Navigation from '@navigation/Navigation';
+import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute';
+import type {RootStackParamList, State} from '@libs/Navigation/types';
+import Navigation, {navigationRef} from '@navigation/Navigation';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import type {Route} from '@src/ROUTES';
+import SCREENS from '@src/SCREENS';
import type {Report} from '@src/types/onyx';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import MentionReportContext from './MentionReportContext';
@@ -69,7 +73,12 @@ function MentionReportRenderer({style, tnode, TDefaultRenderer, ...defaultRender
}
const {reportID, mentionDisplayText} = mentionDetails;
- const navigationRoute = reportID ? ROUTES.REPORT_WITH_ID.getRoute(reportID) : undefined;
+ let navigationRoute: Route | undefined = reportID ? ROUTES.REPORT_WITH_ID.getRoute(reportID) : undefined;
+ const topmostCentralPaneRoute = getTopmostCentralPaneRoute(navigationRef.getRootState() as State);
+ const backTo = Navigation.getActiveRoute();
+ if (topmostCentralPaneRoute?.name === SCREENS.SEARCH.CENTRAL_PANE) {
+ navigationRoute = reportID ? ROUTES.SEARCH_REPORT.getRoute({reportID, backTo}) : undefined;
+ }
const isCurrentRoomMention = reportID === currentReportIDValue;
const flattenStyle = StyleSheet.flatten(style as TextStyle);
diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts
index 90f0e0d8a151..fa531ce34adf 100644
--- a/src/components/Icon/Expensicons.ts
+++ b/src/components/Icon/Expensicons.ts
@@ -23,6 +23,7 @@ import Bed from '@assets/images/bed.svg';
import Bell from '@assets/images/bell.svg';
import BellSlash from '@assets/images/bellSlash.svg';
import Bill from '@assets/images/bill.svg';
+import Binoculars from '@assets/images/binoculars.svg';
import Bolt from '@assets/images/bolt.svg';
import Bookmark from '@assets/images/bookmark.svg';
import Box from '@assets/images/box.svg';
@@ -223,6 +224,7 @@ export {
Bill,
Bell,
BellSlash,
+ Binoculars,
Bolt,
Box,
Briefcase,
diff --git a/src/components/InitialURLContextProvider.tsx b/src/components/InitialURLContextProvider.tsx
index 85ad54ca6c94..adf361a2573d 100644
--- a/src/components/InitialURLContextProvider.tsx
+++ b/src/components/InitialURLContextProvider.tsx
@@ -26,14 +26,15 @@ type InitialURLContextProviderProps = {
};
function InitialURLContextProvider({children, url}: InitialURLContextProviderProps) {
- const [initialURL, setInitialURL] = useState(url);
+ const [initialURL, setInitialURL] = useState();
const {setSplashScreenState} = useSplashScreenStateContext();
useEffect(() => {
if (url) {
- const route = signInAfterTransitionFromOldDot(url);
- setInitialURL(route);
- setSplashScreenState(CONST.BOOT_SPLASH_STATE.READY_TO_BE_HIDDEN);
+ signInAfterTransitionFromOldDot(url).then((route) => {
+ setInitialURL(route);
+ setSplashScreenState(CONST.BOOT_SPLASH_STATE.READY_TO_BE_HIDDEN);
+ });
return;
}
Linking.getInitialURL().then((initURL) => {
diff --git a/src/components/InteractiveStepWrapper.tsx b/src/components/InteractiveStepWrapper.tsx
index 290ad628f9cf..ec0d2da02108 100644
--- a/src/components/InteractiveStepWrapper.tsx
+++ b/src/components/InteractiveStepWrapper.tsx
@@ -20,6 +20,9 @@ type InteractiveStepWrapperProps = {
// Title of the back button header
headerTitle: string;
+ // Subtitle of the back button header
+ headerSubtitle?: string;
+
// Index of the highlighted step
startStepIndex?: number;
@@ -48,6 +51,7 @@ function InteractiveStepWrapper(
wrapperID,
handleBackButtonPress,
headerTitle,
+ headerSubtitle,
startStepIndex,
stepNames,
shouldEnableMaxHeight,
@@ -72,6 +76,7 @@ function InteractiveStepWrapper(
>
diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx
index f1150391dd62..6f1c7aaee458 100644
--- a/src/components/MapView/MapView.tsx
+++ b/src/components/MapView/MapView.tsx
@@ -3,7 +3,7 @@ import type {MapState} from '@rnmapbox/maps';
import Mapbox, {MarkerView, setAccessToken} from '@rnmapbox/maps';
import {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
import * as Expensicons from '@components/Icon/Expensicons';
import useTheme from '@hooks/useTheme';
@@ -18,14 +18,14 @@ import useLocalize from '@src/hooks/useLocalize';
import useNetwork from '@src/hooks/useNetwork';
import ONYXKEYS from '@src/ONYXKEYS';
import Direction from './Direction';
-import type {MapViewHandle} from './MapViewTypes';
+import type {MapViewHandle, MapViewProps} from './MapViewTypes';
import PendingMapView from './PendingMapView';
import responder from './responder';
-import type {ComponentProps, MapViewOnyxProps} from './types';
import utils from './utils';
-const MapView = forwardRef(
- ({accessToken, style, mapPadding, userLocation, styleURL, pitchEnabled, initialState, waypoints, directionCoordinates, onMapReady, interactive = true}, ref) => {
+const MapView = forwardRef(
+ ({accessToken, style, mapPadding, styleURL, pitchEnabled, initialState, waypoints, directionCoordinates, onMapReady, interactive = true}, ref) => {
+ const [userLocation] = useOnyx(ONYXKEYS.USER_LOCATION);
const navigation = useNavigation();
const {isOffline} = useNetwork();
const {translate} = useLocalize();
@@ -298,8 +298,4 @@ const MapView = forwardRef(
},
);
-export default withOnyx({
- userLocation: {
- key: ONYXKEYS.USER_LOCATION,
- },
-})(memo(MapView));
+export default memo(MapView);
diff --git a/src/components/MapView/MapView.website.tsx b/src/components/MapView/MapView.website.tsx
index 3a28943b575a..b89bfa19e98e 100644
--- a/src/components/MapView/MapView.website.tsx
+++ b/src/components/MapView/MapView.website.tsx
@@ -4,11 +4,10 @@ import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import usePrevious from '@hooks/usePrevious';
import useThemeStyles from '@hooks/useThemeStyles';
-import type {MapViewHandle} from './MapViewTypes';
+import type {MapViewHandle, MapViewProps} from './MapViewTypes';
import PendingMapView from './PendingMapView';
-import type {ComponentProps} from './types';
-const MapView = forwardRef((props, ref) => {
+const MapView = forwardRef((props, ref) => {
const {isOffline} = useNetwork();
const {translate} = useLocalize();
const styles = useThemeStyles();
@@ -51,7 +50,6 @@ const MapView = forwardRef((props, ref) => {
}
>
(
+const MapViewImpl = forwardRef(
(
{
style,
@@ -40,13 +39,14 @@ const MapViewImpl = forwardRef(
waypoints,
mapPadding,
accessToken,
- userLocation,
directionCoordinates,
initialState = {location: CONST.MAPBOX.DEFAULT_COORDINATE, zoom: CONST.MAPBOX.DEFAULT_ZOOM},
interactive = true,
},
ref,
) => {
+ const [userLocation] = useOnyx(ONYXKEYS.USER_LOCATION);
+
const {isOffline} = useNetwork();
const {translate} = useLocalize();
@@ -295,8 +295,4 @@ const MapViewImpl = forwardRef(
},
);
-export default withOnyx({
- userLocation: {
- key: ONYXKEYS.USER_LOCATION,
- },
-})(MapViewImpl);
+export default MapViewImpl;
diff --git a/src/components/MapView/types.ts b/src/components/MapView/types.ts
deleted file mode 100644
index a0494a9ac499..000000000000
--- a/src/components/MapView/types.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import type {OnyxEntry} from 'react-native-onyx';
-import type * as OnyxTypes from '@src/types/onyx';
-import type {MapViewProps} from './MapViewTypes';
-
-type MapViewOnyxProps = {
- userLocation: OnyxEntry;
-};
-
-type ComponentProps = MapViewProps & MapViewOnyxProps;
-
-export type {MapViewOnyxProps, ComponentProps};
diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx
index 303a51d064d9..7fb5533fd172 100644
--- a/src/components/MenuItem.tsx
+++ b/src/components/MenuItem.tsx
@@ -251,6 +251,9 @@ type MenuItemBaseProps = {
/** Should we remove the background color of the menu item */
shouldRemoveBackground?: boolean;
+ /** Should we remove the hover background color of the menu item */
+ shouldRemoveHoverBackground?: boolean;
+
/** Should we use default cursor for disabled content */
shouldUseDefaultCursorWhenDisabled?: boolean;
@@ -411,6 +414,7 @@ function MenuItem(
shouldEscapeText = undefined,
shouldGreyOutWhenDisabled = true,
shouldRemoveBackground = false,
+ shouldRemoveHoverBackground = false,
shouldUseDefaultCursorWhenDisabled = false,
shouldShowLoadingSpinnerIcon = false,
isAnonymousAction = false,
@@ -594,7 +598,7 @@ function MenuItem(
StyleUtils.getButtonBackgroundColorStyle(getButtonState(focused || isHovered, pressed, success, disabled, interactive), true),
...(Array.isArray(wrapperStyle) ? wrapperStyle : [wrapperStyle]),
shouldGreyOutWhenDisabled && disabled && styles.buttonOpacityDisabled,
- isHovered && interactive && !focused && !pressed && !shouldRemoveBackground && styles.hoveredComponentBG,
+ isHovered && interactive && !focused && !pressed && !shouldRemoveBackground && !shouldRemoveHoverBackground && styles.hoveredComponentBG,
] as StyleProp
}
disabledStyle={shouldUseDefaultCursorWhenDisabled && [styles.cursorDefault]}
diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx
index 85a2298f63d6..39396795c557 100644
--- a/src/components/Modal/BaseModal.tsx
+++ b/src/components/Modal/BaseModal.tsx
@@ -193,7 +193,7 @@ function BaseModal(
safeAreaPaddingRight,
shouldAddBottomSafeAreaMargin,
shouldAddTopSafeAreaMargin,
- shouldAddBottomSafeAreaPadding: !keyboardStateContextValue?.isKeyboardShown && shouldAddBottomSafeAreaPadding,
+ shouldAddBottomSafeAreaPadding: (!avoidKeyboard || !keyboardStateContextValue?.isKeyboardShown) && shouldAddBottomSafeAreaPadding,
shouldAddTopSafeAreaPadding,
modalContainerStyleMarginTop: modalContainerStyle.marginTop,
modalContainerStyleMarginBottom: modalContainerStyle.marginBottom,
diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx
index 915b0eb38505..9ef33900bb00 100644
--- a/src/components/MoneyRequestAmountInput.tsx
+++ b/src/components/MoneyRequestAmountInput.tsx
@@ -1,6 +1,6 @@
import type {ForwardedRef} from 'react';
-import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
-import type {NativeSyntheticEvent, StyleProp, TextInputSelectionChangeEventData, TextStyle, ViewStyle} from 'react-native';
+import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
+import type {NativeSyntheticEvent, StyleProp, TextStyle, ViewStyle} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import {useMouseContext} from '@hooks/useMouseContext';
import * as Browser from '@libs/Browser';
@@ -274,7 +274,6 @@ function MoneyRequestAmountInput(
});
}, [amount, currency, onFormatAmount, formatAmountOnBlur, maxLength]);
- const regex = useMemo(() => MoneyRequestUtils.amountRegex(decimals), [decimals]);
const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit);
const {setMouseDown, setMouseUp} = useMouseContext();
@@ -308,7 +307,7 @@ function MoneyRequestAmountInput(
}}
selectedCurrencyCode={currency}
selection={selection}
- onSelectionChange={(e: NativeSyntheticEvent) => {
+ onSelectionChange={(selectionStart, selectionEnd) => {
if (shouldIgnoreSelectionWhenUpdatedManually && willSelectionBeUpdatedManually.current) {
willSelectionBeUpdatedManually.current = false;
return;
@@ -320,8 +319,8 @@ function MoneyRequestAmountInput(
// When the amount is updated in setNewAmount on iOS, in onSelectionChange formattedAmount stores the value before the update. Using amountRef allows us to read the updated value
const maxSelection = amountRef.current?.length ?? formattedAmount.length;
amountRef.current = undefined;
- const start = Math.min(e.nativeEvent.selection.start, maxSelection);
- const end = Math.min(e.nativeEvent.selection.end, maxSelection);
+ const start = Math.min(selectionStart, maxSelection);
+ const end = Math.min(selectionEnd, maxSelection);
setSelection({start, end});
}}
onKeyPress={textInputKeyPress}
@@ -338,7 +337,6 @@ function MoneyRequestAmountInput(
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
contentWidth={contentWidth}
- regex={regex}
/>
);
}
diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx
index e70a8cec4775..ebb927b2a279 100755
--- a/src/components/MoneyRequestConfirmationList.tsx
+++ b/src/components/MoneyRequestConfirmationList.tsx
@@ -2,8 +2,8 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native';
import lodashIsEqual from 'lodash/isEqual';
import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {InteractionManager, View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
@@ -14,7 +14,6 @@ import useThemeStyles from '@hooks/useThemeStyles';
import blurActiveElement from '@libs/Accessibility/blurActiveElement';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
-import type {MileageRate} from '@libs/DistanceRequestUtils';
import * as IOUUtils from '@libs/IOUUtils';
import Log from '@libs/Log';
import * as MoneyRequestUtils from '@libs/MoneyRequestUtils';
@@ -49,33 +48,7 @@ import UserListItem from './SelectionList/UserListItem';
import SettlementButton from './SettlementButton';
import Text from './Text';
-type MoneyRequestConfirmationListOnyxProps = {
- /** Collection of categories attached to a policy */
- policyCategories: OnyxEntry;
-
- /** Collection of draft categories attached to a policy */
- policyCategoriesDraft: OnyxEntry;
-
- /** Collection of tags attached to a policy */
- policyTags: OnyxEntry;
-
- /** The policy of the report */
- policy: OnyxEntry;
-
- /** The draft policy of the report */
- policyDraft: OnyxEntry;
-
- /** Mileage rate default for the policy */
- defaultMileageRate: OnyxEntry;
-
- /** Last selected distance rates */
- lastSelectedDistanceRates: OnyxEntry>;
-
- /** List of currencies */
- currencyList: OnyxEntry;
-};
-
-type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & {
+type MoneyRequestConfirmationListProps = {
/** Callback to inform parent modal of success */
onConfirm?: (selectedParticipants: Participant[]) => void;
@@ -178,23 +151,18 @@ function MoneyRequestConfirmationList({
onConfirm,
iouType = CONST.IOU.TYPE.SUBMIT,
iouAmount,
- policyCategories: policyCategoriesReal,
- policyCategoriesDraft,
isDistanceRequest = false,
- policy: policyReal,
- policyDraft,
isPolicyExpenseChat = false,
iouCategory = '',
shouldShowSmartScanFields = true,
isEditingSplitBill,
- policyTags,
iouCurrencyCode,
iouMerchant,
selectedParticipants: selectedParticipantsProp,
payeePersonalDetails: payeePersonalDetailsProp,
isReadOnly = false,
bankAccountRoute = '',
- policyID = '',
+ policyID,
reportID = '',
receiptPath = '',
iouAttendees,
@@ -205,14 +173,22 @@ function MoneyRequestConfirmationList({
onToggleBillable,
hasSmartScanFailed,
reportActionID,
- defaultMileageRate,
- lastSelectedDistanceRates,
action = CONST.IOU.ACTION.CREATE,
- currencyList,
shouldDisplayReceipt = false,
shouldPlaySound = true,
isConfirmed,
}: MoneyRequestConfirmationListProps) {
+ const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID ?? '-1'}`);
+ const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID ?? '-1'}`);
+ const [policyReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID ?? '-1'}`);
+ const [policyDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID ?? '-1'}`);
+ const [defaultMileageRate] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID ?? '-1'}`, {
+ selector: (selectedPolicy) => DistanceRequestUtils.getDefaultMileageRate(selectedPolicy),
+ });
+ const [policyCategoriesDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${policyID ?? '-1'}`);
+ const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES);
+ const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST);
+
const policy = policyReal ?? policyDraft;
const policyCategories = policyCategoriesReal ?? policyCategoriesDraft;
@@ -329,6 +305,8 @@ function MoneyRequestConfirmationList({
return false;
};
+ const routeError = Object.values(transaction?.errorFields?.route ?? {}).at(0);
+
useEffect(() => {
if (shouldDisplayFieldError && didConfirmSplit) {
setFormError('iou.error.genericSmartscanFailureMessage');
@@ -743,6 +721,9 @@ function MoneyRequestConfirmationList({
*/
const confirm = useCallback(
(paymentMethod: PaymentMethodType | undefined) => {
+ if (routeError) {
+ return;
+ }
if (iouType === CONST.IOU.TYPE.INVOICE && !hasInvoicingDetails(policy)) {
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_COMPANY_INFO.getRoute(iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()));
return;
@@ -815,6 +796,7 @@ function MoneyRequestConfirmationList({
transactionID,
reportID,
policy,
+ routeError,
],
);
@@ -830,6 +812,16 @@ function MoneyRequestConfirmationList({
}, []),
);
+ const errorMessage = useMemo(() => {
+ if (routeError) {
+ return routeError;
+ }
+ if (isTypeSplit && !shouldShowReadOnlySplits) {
+ return debouncedFormError && translate(debouncedFormError);
+ }
+ return formError && translate(formError);
+ }, [routeError, isTypeSplit, shouldShowReadOnlySplits, debouncedFormError, formError, translate]);
+
const footerContent = useMemo(() => {
if (isReadOnly) {
return;
@@ -872,33 +864,18 @@ function MoneyRequestConfirmationList({
return (
<>
- {!!formError && (
+ {!!errorMessage && (
)}
{button}
>
);
- }, [
- isReadOnly,
- isTypeSplit,
- iouType,
- confirm,
- bankAccountRoute,
- iouCurrencyCode,
- policyID,
- splitOrRequestOptions,
- formError,
- styles.ph1,
- styles.mb2,
- shouldShowReadOnlySplits,
- debouncedFormError,
- translate,
- ]);
+ }, [isReadOnly, iouType, confirm, bankAccountRoute, iouCurrencyCode, policyID, splitOrRequestOptions, styles.ph1, styles.mb2, errorMessage]);
const listFooterContent = (
({
- policyCategories: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`,
- },
- policyCategoriesDraft: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${policyID}`,
- },
- policyTags: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
- },
- defaultMileageRate: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- selector: DistanceRequestUtils.getDefaultMileageRate,
- },
- policy: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- },
- policyDraft: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`,
- },
- lastSelectedDistanceRates: {
- key: ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES,
- },
- currencyList: {
- key: ONYXKEYS.CURRENCY_LIST,
- },
-})(
- memo(
- MoneyRequestConfirmationList,
- (prevProps, nextProps) =>
- lodashIsEqual(prevProps.transaction, nextProps.transaction) &&
- prevProps.onSendMoney === nextProps.onSendMoney &&
- prevProps.onConfirm === nextProps.onConfirm &&
- prevProps.iouType === nextProps.iouType &&
- prevProps.iouAmount === nextProps.iouAmount &&
- prevProps.isDistanceRequest === nextProps.isDistanceRequest &&
- prevProps.isPolicyExpenseChat === nextProps.isPolicyExpenseChat &&
- prevProps.iouCategory === nextProps.iouCategory &&
- prevProps.shouldShowSmartScanFields === nextProps.shouldShowSmartScanFields &&
- prevProps.isEditingSplitBill === nextProps.isEditingSplitBill &&
- prevProps.iouCurrencyCode === nextProps.iouCurrencyCode &&
- prevProps.iouMerchant === nextProps.iouMerchant &&
- lodashIsEqual(prevProps.selectedParticipants, nextProps.selectedParticipants) &&
- lodashIsEqual(prevProps.payeePersonalDetails, nextProps.payeePersonalDetails) &&
- prevProps.isReadOnly === nextProps.isReadOnly &&
- prevProps.bankAccountRoute === nextProps.bankAccountRoute &&
- prevProps.policyID === nextProps.policyID &&
- prevProps.reportID === nextProps.reportID &&
- prevProps.receiptPath === nextProps.receiptPath &&
- prevProps.iouAttendees === nextProps.iouAttendees &&
- prevProps.iouComment === nextProps.iouComment &&
- prevProps.receiptFilename === nextProps.receiptFilename &&
- prevProps.iouCreated === nextProps.iouCreated &&
- prevProps.iouIsBillable === nextProps.iouIsBillable &&
- prevProps.onToggleBillable === nextProps.onToggleBillable &&
- prevProps.hasSmartScanFailed === nextProps.hasSmartScanFailed &&
- prevProps.reportActionID === nextProps.reportActionID &&
- lodashIsEqual(prevProps.defaultMileageRate, nextProps.defaultMileageRate) &&
- lodashIsEqual(prevProps.lastSelectedDistanceRates, nextProps.lastSelectedDistanceRates) &&
- lodashIsEqual(prevProps.action, nextProps.action) &&
- lodashIsEqual(prevProps.currencyList, nextProps.currencyList) &&
- prevProps.shouldDisplayReceipt === nextProps.shouldDisplayReceipt,
- ),
+export default memo(
+ MoneyRequestConfirmationList,
+ (prevProps, nextProps) =>
+ lodashIsEqual(prevProps.transaction, nextProps.transaction) &&
+ prevProps.onSendMoney === nextProps.onSendMoney &&
+ prevProps.onConfirm === nextProps.onConfirm &&
+ prevProps.iouType === nextProps.iouType &&
+ prevProps.iouAmount === nextProps.iouAmount &&
+ prevProps.isDistanceRequest === nextProps.isDistanceRequest &&
+ prevProps.isPolicyExpenseChat === nextProps.isPolicyExpenseChat &&
+ prevProps.iouCategory === nextProps.iouCategory &&
+ prevProps.shouldShowSmartScanFields === nextProps.shouldShowSmartScanFields &&
+ prevProps.isEditingSplitBill === nextProps.isEditingSplitBill &&
+ prevProps.iouCurrencyCode === nextProps.iouCurrencyCode &&
+ prevProps.iouMerchant === nextProps.iouMerchant &&
+ lodashIsEqual(prevProps.selectedParticipants, nextProps.selectedParticipants) &&
+ lodashIsEqual(prevProps.payeePersonalDetails, nextProps.payeePersonalDetails) &&
+ prevProps.isReadOnly === nextProps.isReadOnly &&
+ prevProps.bankAccountRoute === nextProps.bankAccountRoute &&
+ prevProps.policyID === nextProps.policyID &&
+ prevProps.reportID === nextProps.reportID &&
+ prevProps.receiptPath === nextProps.receiptPath &&
+ prevProps.iouAttendees === nextProps.iouAttendees &&
+ prevProps.iouComment === nextProps.iouComment &&
+ prevProps.receiptFilename === nextProps.receiptFilename &&
+ prevProps.iouCreated === nextProps.iouCreated &&
+ prevProps.iouIsBillable === nextProps.iouIsBillable &&
+ prevProps.onToggleBillable === nextProps.onToggleBillable &&
+ prevProps.hasSmartScanFailed === nextProps.hasSmartScanFailed &&
+ prevProps.reportActionID === nextProps.reportActionID &&
+ lodashIsEqual(prevProps.action, nextProps.action) &&
+ prevProps.shouldDisplayReceipt === nextProps.shouldDisplayReceipt,
);
diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx
index 21d0aa516d86..9b5c0b1b6f56 100644
--- a/src/components/PopoverMenu.tsx
+++ b/src/components/PopoverMenu.tsx
@@ -8,6 +8,7 @@ import type {ModalProps} from 'react-native-modal';
import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
@@ -168,6 +169,7 @@ function PopoverMenu({
}: PopoverMenuProps) {
const styles = useThemeStyles();
const theme = useTheme();
+ const StyleUtils = useStyleUtils();
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply correct popover styles
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
@@ -262,7 +264,14 @@ function PopoverMenu({
}
setFocusedIndex(menuIndex);
}}
- style={{backgroundColor: item.isSelected ? theme.activeComponentBG : undefined}}
+ wrapperStyle={StyleUtils.getItemBackgroundColorStyle(
+ !!item.isSelected,
+ focusedIndex === menuIndex,
+ item.disabled ?? false,
+ theme.activeComponentBG,
+ theme.hoverComponentBG,
+ )}
+ shouldRemoveHoverBackground={item.isSelected}
titleStyle={StyleSheet.flatten([styles.flex1, item.titleStyle])}
// Spread other props dynamically
{...menuItemProps}
diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
index a774b5c18c55..336b7dea9654 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
+++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
@@ -294,8 +294,13 @@ function MoneyRequestPreviewContent({
const navigateToReviewFields = () => {
const backTo = route.params.backTo;
- const comparisonResult = TransactionUtils.compareDuplicateTransactionFields(reviewingTransactionID);
- Transaction.setReviewDuplicatesKey({...comparisonResult.keep, duplicates, transactionID: transaction?.transactionID ?? ''});
+
+ // Clear the draft before selecting a different expense to prevent merging fields from the previous expense
+ // (e.g., category, tag, tax) that may be not enabled/available in the new expense's policy.
+ Transaction.abandonReviewDuplicateTransactions();
+ const comparisonResult = TransactionUtils.compareDuplicateTransactionFields(reviewingTransactionID, transaction?.reportID ?? '');
+ Transaction.setReviewDuplicatesKey({...comparisonResult.keep, duplicates, transactionID: transaction?.transactionID ?? '', reportID: transaction?.reportID});
+
if ('merchant' in comparisonResult.change) {
Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE.getRoute(route.params?.threadReportID, backTo));
} else if ('category' in comparisonResult.change) {
diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx
index 22e54670c264..c74ccf0470d0 100644
--- a/src/components/ScreenWrapper.tsx
+++ b/src/components/ScreenWrapper.tsx
@@ -1,9 +1,9 @@
-import {useIsFocused, useNavigation} from '@react-navigation/native';
+import {UNSTABLE_usePreventRemove, useIsFocused, useNavigation, useRoute} from '@react-navigation/native';
import type {StackNavigationProp} from '@react-navigation/stack';
import type {ForwardedRef, ReactNode} from 'react';
import React, {createContext, forwardRef, useEffect, useMemo, useRef, useState} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
-import {Keyboard, PanResponder, View} from 'react-native';
+import {Keyboard, NativeModules, PanResponder, View} from 'react-native';
import {PickerAvoidingView} from 'react-native-picker-select';
import type {EdgeInsets} from 'react-native-safe-area-context';
import useEnvironment from '@hooks/useEnvironment';
@@ -164,6 +164,15 @@ function ScreenWrapper(
// eslint-disable-next-line react-compiler/react-compiler
isKeyboardShownRef.current = keyboardState?.isKeyboardShown ?? false;
+ const route = useRoute();
+ const shouldReturnToOldDot = useMemo(() => {
+ return !!route?.params && 'singleNewDotEntry' in route.params && route.params.singleNewDotEntry === 'true';
+ }, [route?.params]);
+
+ UNSTABLE_usePreventRemove(shouldReturnToOldDot, () => {
+ NativeModules.HybridAppModule?.closeReactNativeApp(false, false);
+ });
+
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponderCapture: (_e, gestureState) => gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS,
diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx
index 5665909185c4..a330be3d5ff6 100644
--- a/src/components/Search/SearchPageHeader.tsx
+++ b/src/components/Search/SearchPageHeader.tsx
@@ -340,7 +340,10 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) {
}
const inputQueryJSON = SearchQueryUtils.buildSearchQueryJSON(inputValue);
if (inputQueryJSON) {
- const standardizedQuery = SearchQueryUtils.standardizeQueryJSON(inputQueryJSON, cardList, taxRates);
+ // Todo traverse the tree to update all the display values into id values; this is only temporary until autocomplete code from SearchRouter is implement here
+ // After https://github.com/Expensify/App/pull/51633 is merged, autocomplete functionality will be included into this component, and `getFindIDFromDisplayValue` can be removed
+ const computeNodeValueFn = SearchQueryUtils.getFindIDFromDisplayValue(cardList, taxRates);
+ const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(inputQueryJSON, computeNodeValueFn);
const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery);
SearchActions.clearAllFilters();
Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query}));
diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx
index 83d7d5d89b20..e65b12deb64b 100644
--- a/src/components/Search/SearchRouter/SearchRouter.tsx
+++ b/src/components/Search/SearchRouter/SearchRouter.tsx
@@ -1,4 +1,5 @@
import {useNavigationState} from '@react-navigation/native';
+import {Str} from 'expensify-common';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
@@ -6,15 +7,16 @@ import type {ValueOf} from 'type-fest';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import {usePersonalDetails} from '@components/OnyxProvider';
import {useOptionsList} from '@components/OptionListContextProvider';
-import type {AutocompleteRange, SearchQueryJSON} from '@components/Search/types';
+import type {SearchAutocompleteQueryRange, SearchQueryString} from '@components/Search/types';
import type {SelectionListHandle} from '@components/SelectionList/types';
-import useActiveWorkspaceFromNavigationState from '@hooks/useActiveWorkspaceFromNavigationState';
+import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useDebouncedState from '@hooks/useDebouncedState';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useLocalize from '@hooks/useLocalize';
import usePolicy from '@hooks/usePolicy';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as CardUtils from '@libs/CardUtils';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import {getAllTaxRates} from '@libs/PolicyUtils';
import type {OptionData} from '@libs/ReportUtils';
@@ -34,9 +36,13 @@ import Timing from '@userActions/Timing';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import type PersonalDetails from '@src/types/onyx/PersonalDetails';
+import {getQueryWithSubstitutions} from './getQueryWithSubstitutions';
+import type {SubstitutionMap} from './getQueryWithSubstitutions';
+import {getUpdatedSubstitutionsMap} from './getUpdatedSubstitutionsMap';
import SearchRouterInput from './SearchRouterInput';
import SearchRouterList from './SearchRouterList';
-import type {ItemWithQuery} from './SearchRouterList';
+import type {AutocompleteItemData} from './SearchRouterList';
type SearchRouterProps = {
onRouterClose: () => void;
@@ -48,7 +54,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
const [betas] = useOnyx(ONYXKEYS.BETAS);
const [recentSearches] = useOnyx(ONYXKEYS.RECENT_SEARCHES);
const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false});
- const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]);
+ const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]);
+ const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({});
const {shouldUseNarrowLayout} = useResponsiveLayout();
const listRef = useRef(null);
@@ -58,41 +65,6 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
return state?.routes.at(-1)?.params?.reportID;
});
- const activeWorkspaceID = useActiveWorkspaceFromNavigationState();
- const policy = usePolicy(activeWorkspaceID);
- const typeAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES);
- const statusAutocompleteList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP});
- const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE);
- const allTaxRates = getAllTaxRates();
- const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(allTaxRates, policy), [policy, allTaxRates]);
- const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST);
- const cardAutocompleteList = Object.values(cardList ?? {}).map((card) => card.bank);
- const personalDetailsForParticipants = usePersonalDetails();
- const participantsAutocompleteList = Object.values(personalDetailsForParticipants)
- .filter((details) => details && details?.login)
- // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
- .map((details) => details?.login as string);
-
- const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
- const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES);
- const categoryAutocompleteList = useMemo(() => {
- return getAutocompleteCategories(allPolicyCategories, activeWorkspaceID);
- }, [activeWorkspaceID, allPolicyCategories]);
- const recentCategoriesAutocompleteList = useMemo(() => {
- return getAutocompleteRecentCategories(allRecentCategories, activeWorkspaceID);
- }, [activeWorkspaceID, allRecentCategories]);
-
- const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST);
- const currencyAutocompleteList = Object.keys(currencyList ?? {});
- const [recentCurrencyAutocompleteList] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES);
-
- const [allPoliciesTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
- const [allRecentTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS);
- const tagAutocompleteList = useMemo(() => {
- return getAutocompleteTags(allPoliciesTags, activeWorkspaceID);
- }, [activeWorkspaceID, allPoliciesTags]);
- const recentTagsAutocompleteList = getAutocompleteRecentTags(allRecentTags, activeWorkspaceID);
-
const sortedRecentSearches = useMemo(() => {
return Object.values(recentSearches ?? {}).sort((a, b) => b.timestamp.localeCompare(a.timestamp));
}, [recentSearches]);
@@ -137,14 +109,52 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
return reports.slice(0, 10);
}, [debouncedInputValue, filteredOptions, searchOptions]);
- useEffect(() => {
- Report.searchInServer(debouncedInputValue.trim());
- }, [debouncedInputValue]);
-
const contextualReportData = contextualReportID ? searchOptions.recentReports?.find((option) => option.reportID === contextualReportID) : undefined;
+ const {activeWorkspaceID} = useActiveWorkspace();
+ const policy = usePolicy(activeWorkspaceID);
+
+ const typeAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES);
+ const statusAutocompleteList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP});
+ const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE);
+ const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST);
+ const cardAutocompleteList = Object.values(cardList);
+ const personalDetailsForParticipants = usePersonalDetails();
+
+ const participantsAutocompleteList = useMemo(
+ () =>
+ Object.values(personalDetailsForParticipants)
+ .filter((details): details is NonNullable => !!(details && details?.login))
+ .map((details) => ({
+ name: details.displayName ?? Str.removeSMSDomain(details.login ?? ''),
+ accountID: details?.accountID.toString(),
+ })),
+ [personalDetailsForParticipants],
+ );
+ const allTaxRates = getAllTaxRates();
+ const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(allTaxRates, policy), [policy, allTaxRates]);
+ const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
+ const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES);
+ const categoryAutocompleteList = useMemo(() => {
+ return getAutocompleteCategories(allPolicyCategories, activeWorkspaceID);
+ }, [activeWorkspaceID, allPolicyCategories]);
+ const recentCategoriesAutocompleteList = useMemo(() => {
+ return getAutocompleteRecentCategories(allRecentCategories, activeWorkspaceID);
+ }, [activeWorkspaceID, allRecentCategories]);
+
+ const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST);
+ const currencyAutocompleteList = Object.keys(currencyList ?? {});
+ const [recentCurrencyAutocompleteList] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES);
+
+ const [allPoliciesTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
+ const [allRecentTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS);
+ const tagAutocompleteList = useMemo(() => {
+ return getAutocompleteTags(allPoliciesTags, activeWorkspaceID);
+ }, [activeWorkspaceID, allPoliciesTags]);
+ const recentTagsAutocompleteList = getAutocompleteRecentTags(allRecentTags, activeWorkspaceID);
+
const updateAutocomplete = useCallback(
- (autocompleteValue: string, ranges: AutocompleteRange[], autocompleteType?: ValueOf) => {
+ (autocompleteValue: string, ranges: SearchAutocompleteQueryRange[], autocompleteType?: ValueOf) => {
const alreadyAutocompletedKeys: string[] = [];
ranges.forEach((range) => {
if (!autocompleteType || range.key !== autocompleteType) {
@@ -152,6 +162,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
}
alreadyAutocompletedKeys.push(range.value.toLowerCase());
});
+
+ let filteredAutocompleteSuggestions: AutocompleteItemData[] | undefined;
switch (autocompleteType) {
case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG: {
const autocompleteList = autocompleteValue ? tagAutocompleteList : recentTagsAutocompleteList ?? [];
@@ -159,13 +171,12 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
.filter((tag) => tag.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tag))
.sort()
.slice(0, 10);
- setAutocompleteSuggestions(
- filteredTags.map((tagName) => ({
- text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG}:${tagName}`,
- query: `${SearchQueryUtils.sanitizeSearchValue(tagName)}`,
- })),
- );
- return;
+
+ filteredAutocompleteSuggestions = filteredTags.map((tagName) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG,
+ text: tagName,
+ }));
+ break;
}
case CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY: {
const autocompleteList = autocompleteValue ? categoryAutocompleteList : recentCategoriesAutocompleteList;
@@ -175,13 +186,12 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
})
.sort()
.slice(0, 10);
- setAutocompleteSuggestions(
- filteredCategories.map((categoryName) => ({
- text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY}:${categoryName}`,
- query: `${SearchQueryUtils.sanitizeSearchValue(categoryName)}`,
- })),
- );
- return;
+
+ filteredAutocompleteSuggestions = filteredCategories.map((categoryName) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY,
+ text: categoryName,
+ }));
+ break;
}
case CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY: {
const autocompleteList = autocompleteValue ? currencyAutocompleteList : recentCurrencyAutocompleteList ?? [];
@@ -189,92 +199,110 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
.filter((currency) => currency.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(currency.toLowerCase()))
.sort()
.slice(0, 10);
- setAutocompleteSuggestions(
- filteredCurrencies.map((currencyName) => ({
- text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY}:${currencyName}`,
- query: `${currencyName}`,
- })),
- );
- return;
+
+ filteredAutocompleteSuggestions = filteredCurrencies.map((currencyName) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY,
+ text: currencyName,
+ }));
+ break;
}
case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE: {
const filteredTaxRates = taxAutocompleteList
- .filter((tax) => tax.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tax.toLowerCase()))
+ .filter((tax) => tax.taxRateName.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tax.taxRateName.toLowerCase()))
.sort()
.slice(0, 10);
- setAutocompleteSuggestions(
- filteredTaxRates.map((tax) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE}:${tax}`, query: `${SearchQueryUtils.sanitizeSearchValue(tax)}`})),
- );
- return;
+ filteredAutocompleteSuggestions = filteredTaxRates.map((tax) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE,
+ text: tax.taxRateName,
+ autocompleteID: tax.taxRateIds.join(','),
+ }));
+
+ break;
}
case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: {
const filteredParticipants = participantsAutocompleteList
- .filter((participant) => participant.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.toLowerCase()))
+ .filter(
+ (participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase()),
+ )
.sort()
.slice(0, 10);
- setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM}:${participant}`, query: `${participant}`})));
- return;
+ filteredAutocompleteSuggestions = filteredParticipants.map((participant) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM,
+ text: participant.name,
+ autocompleteID: participant.accountID,
+ }));
+ break;
}
case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: {
const filteredParticipants = participantsAutocompleteList
- .filter((participant) => participant.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.toLowerCase()))
+ .filter(
+ (participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase()),
+ )
.sort()
.slice(0, 10);
- setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${participant}`, query: `${participant}`})));
- return;
+ filteredAutocompleteSuggestions = filteredParticipants.map((participant) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO,
+ text: participant.name,
+ autocompleteID: participant.accountID,
+ }));
+ break;
}
case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN: {
const filteredChats = searchOptions.recentReports
.filter((chat) => chat.text?.toLowerCase()?.includes(autocompleteValue.toLowerCase()))
.sort((chatA, chatB) => (chatA > chatB ? 1 : -1))
.slice(0, 10);
- setAutocompleteSuggestions(filteredChats.map((chat) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${chat.text}`, query: `${chat.reportID}`})));
- return;
+ filteredAutocompleteSuggestions = filteredChats.map((chat) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.IN,
+ text: chat.text ?? '',
+ autocompleteID: chat.reportID,
+ }));
+ break;
}
case CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE: {
const filteredTypes = typeAutocompleteList
.filter((type) => type.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(type.toLowerCase()))
.sort();
- setAutocompleteSuggestions(filteredTypes.map((type) => ({text: `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${type}`, query: `${type}`})));
- return;
+ filteredAutocompleteSuggestions = filteredTypes.map((type) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE, text: type}));
+ break;
}
case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: {
const filteredStatuses = statusAutocompleteList
.filter((status) => status.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(status))
.sort()
.slice(0, 10);
- setAutocompleteSuggestions(filteredStatuses.map((status) => ({text: `${CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS}:${status}`, query: `${status}`})));
- return;
+ filteredAutocompleteSuggestions = filteredStatuses.map((status) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS, text: status}));
+ break;
}
case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE: {
const filteredExpenseTypes = expenseTypes
.filter((expenseType) => expenseType.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(expenseType))
.sort();
- setAutocompleteSuggestions(
- filteredExpenseTypes.map((expenseType) => ({
- text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE}:${expenseType}`,
- query: `${expenseType}`,
- })),
- );
- return;
+
+ filteredAutocompleteSuggestions = filteredExpenseTypes.map((expenseType) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE,
+ text: expenseType,
+ }));
+ break;
}
case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID: {
const filteredCards = cardAutocompleteList
- .filter((card) => card.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(card.toLowerCase()))
+ .filter((card) => card.bank.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(card.bank.toLowerCase()))
.sort()
.slice(0, 10);
- setAutocompleteSuggestions(
- filteredCards.map((card) => ({
- text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID}:${card}`,
- query: `${card}`,
- })),
- );
- return;
+
+ filteredAutocompleteSuggestions = filteredCards.map((card) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID,
+ text: CardUtils.getCardDescription(card.cardID),
+ autocompleteID: card.cardID.toString(),
+ }));
+ break;
}
default: {
- setAutocompleteSuggestions(undefined);
+ filteredAutocompleteSuggestions = undefined;
}
}
+ setAutocompleteSuggestions(filteredAutocompleteSuggestions);
},
[
tagAutocompleteList,
@@ -293,6 +321,10 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
],
);
+ useEffect(() => {
+ Report.searchInServer(debouncedInputValue.trim());
+ }, [debouncedInputValue]);
+
const onSearchChange = useCallback(
(userQuery: string) => {
let newUserQuery = userQuery;
@@ -302,29 +334,44 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
setTextInputValue(newUserQuery);
const autocompleteParsedQuery = parseForAutocomplete(newUserQuery);
updateAutocomplete(autocompleteParsedQuery?.autocomplete?.value ?? '', autocompleteParsedQuery?.ranges ?? [], autocompleteParsedQuery?.autocomplete?.key);
+
+ const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions);
+ setAutocompleteSubstitutions(updatedSubstitutionsMap);
+
if (newUserQuery) {
listRef.current?.updateAndScrollToFocusedIndex(0);
} else {
listRef.current?.updateAndScrollToFocusedIndex(-1);
}
},
- [autocompleteSuggestions, setTextInputValue, updateAutocomplete],
+ [autocompleteSubstitutions, autocompleteSuggestions, setTextInputValue, updateAutocomplete],
);
const onSearchSubmit = useCallback(
- (query: SearchQueryJSON | undefined) => {
- if (!query) {
+ (queryString: SearchQueryString) => {
+ const cleanedQueryString = getQueryWithSubstitutions(queryString, autocompleteSubstitutions);
+ const queryJSON = SearchQueryUtils.buildSearchQueryJSON(cleanedQueryString);
+ if (!queryJSON) {
return;
}
+
onRouterClose();
- const standardizedQuery = SearchQueryUtils.standardizeQueryJSON(query, cardList, allTaxRates);
- const queryString = SearchQueryUtils.buildSearchQueryString(standardizedQuery);
- Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: queryString}));
+
+ const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(queryJSON, SearchQueryUtils.getUpdatedAmountValue);
+ const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery);
+ Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query}));
+
setTextInputValue('');
},
- [allTaxRates, cardList, onRouterClose, setTextInputValue],
+ [autocompleteSubstitutions, onRouterClose, setTextInputValue],
);
+ const onAutocompleteSuggestionClick = (autocompleteKey: string, autocompleteID: string) => {
+ const substitutions = {...autocompleteSubstitutions, [autocompleteKey]: autocompleteID};
+
+ setAutocompleteSubstitutions(substitutions);
+ };
+
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => {
onRouterClose();
});
@@ -347,7 +394,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
isFullWidth={shouldUseNarrowLayout}
updateSearch={onSearchChange}
onSubmit={() => {
- onSearchSubmit(SearchQueryUtils.buildSearchQueryJSON(textInputValue));
+ onSearchSubmit(textInputValue);
}}
routerListRef={listRef}
shouldShowOfflineMessage
@@ -363,9 +410,10 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
reportForContextualSearch={contextualReportData}
recentSearches={sortedRecentSearches?.slice(0, 5)}
recentReports={recentReports}
- autocompleteItems={autocompleteSuggestions}
+ autocompleteSuggestions={autocompleteSuggestions}
onSearchSubmit={onSearchSubmit}
closeRouter={onRouterClose}
+ onAutocompleteSuggestionClick={onAutocompleteSuggestionClick}
ref={listRef}
/>
diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx
index c3799ce5579e..cc854ff926c3 100644
--- a/src/components/Search/SearchRouter/SearchRouterList.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterList.tsx
@@ -3,7 +3,7 @@ import type {ForwardedRef} from 'react';
import {useOnyx} from 'react-native-onyx';
import * as Expensicons from '@components/Icon/Expensicons';
import {usePersonalDetails} from '@components/OnyxProvider';
-import type {SearchQueryJSON} from '@components/Search/types';
+import type {SearchFilterKey, SearchQueryString} from '@components/Search/types';
import SelectionList from '@components/SelectionList';
import SearchQueryListItem from '@components/SelectionList/Search/SearchQueryListItem';
import type {SearchQueryItem, SearchQueryListItemProps} from '@components/SelectionList/Search/SearchQueryListItem';
@@ -16,20 +16,26 @@ import Navigation from '@libs/Navigation/Navigation';
import Performance from '@libs/Performance';
import {getAllTaxRates} from '@libs/PolicyUtils';
import type {OptionData} from '@libs/ReportUtils';
-import {trimSearchQueryForAutocomplete} from '@libs/SearchAutocompleteUtils';
+import {getQueryWithoutAutocompletedPart} from '@libs/SearchAutocompleteUtils';
import * as SearchQueryUtils from '@libs/SearchQueryUtils';
import * as Report from '@userActions/Report';
import Timing from '@userActions/Timing';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import {getSubstitutionMapKey} from './getQueryWithSubstitutions';
-type ItemWithQuery = {
+type SearchQueryItemData = {
query: string;
- id?: string;
text?: string;
};
+type AutocompleteItemData = {
+ filterKey: SearchFilterKey;
+ text: string;
+ autocompleteID?: string;
+};
+
type SearchRouterListProps = {
/** value of TextInput */
textInputValue: string;
@@ -41,20 +47,23 @@ type SearchRouterListProps = {
setTextInputValue: (text: string) => void;
/** Recent searches */
- recentSearches: Array | undefined;
+ recentSearches: Array | undefined;
/** Recent reports */
recentReports: OptionData[];
/** Autocomplete items */
- autocompleteItems: ItemWithQuery[] | undefined;
+ autocompleteSuggestions: AutocompleteItemData[] | undefined;
/** Callback to submit query when selecting a list item */
- onSearchSubmit: (query: SearchQueryJSON | undefined) => void;
+ onSearchSubmit: (query: SearchQueryString) => void;
/** Context present when opening SearchRouter from a report, invoice or workspace page */
reportForContextualSearch?: OptionData;
+ /** Callback to run when user clicks a suggestion item that contains autocomplete data */
+ onAutocompleteSuggestionClick: (autocompleteKey: string, autocompleteID: string) => void;
+
/** Callback to close and clear SearchRouter */
closeRouter: () => void;
};
@@ -64,21 +73,25 @@ const setPerformanceTimersEnd = () => {
Performance.markEnd(CONST.TIMING.SEARCH_ROUTER_RENDER);
};
-function getContextualSearchQuery(reportID: string) {
- return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${CONST.SEARCH.DATA_TYPES.CHAT} in:${reportID}`;
+function getContextualSearchQuery(reportName: string) {
+ return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${CONST.SEARCH.DATA_TYPES.CHAT} ${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${SearchQueryUtils.sanitizeSearchValue(reportName)}`;
}
function isSearchQueryItem(item: OptionData | SearchQueryItem): item is SearchQueryItem {
- if ('singleIcon' in item && item.singleIcon && 'query' in item && item.query) {
- return true;
- }
- return false;
+ return 'searchItemType' in item;
}
function isSearchQueryListItem(listItem: UserListItemProps | SearchQueryListItemProps): listItem is SearchQueryListItemProps {
return isSearchQueryItem(listItem.item);
}
+function getItemHeight(item: OptionData | SearchQueryItem) {
+ if (isSearchQueryItem(item)) {
+ return 44;
+ }
+ return 64;
+}
+
function SearchRouterItem(props: UserListItemProps | SearchQueryListItemProps) {
const styles = useThemeStyles();
@@ -100,7 +113,18 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList
}
function SearchRouterList(
- {textInputValue, updateSearchValue, setTextInputValue, reportForContextualSearch, recentSearches, autocompleteItems, recentReports, onSearchSubmit, closeRouter}: SearchRouterListProps,
+ {
+ textInputValue,
+ updateSearchValue,
+ setTextInputValue,
+ reportForContextualSearch,
+ recentSearches,
+ autocompleteSuggestions,
+ recentReports,
+ onSearchSubmit,
+ onAutocompleteSuggestionClick,
+ closeRouter,
+ }: SearchRouterListProps,
ref: ForwardedRef,
) {
const styles = useThemeStyles();
@@ -119,7 +143,7 @@ function SearchRouterList(
{
text: textInputValue,
singleIcon: Expensicons.MagnifyingGlass,
- query: textInputValue,
+ searchQuery: textInputValue,
itemStyle: styles.activeComponentBG,
keyForList: 'findItem',
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH,
@@ -129,12 +153,14 @@ function SearchRouterList(
}
if (reportForContextualSearch && !textInputValue) {
+ const reportQueryValue = reportForContextualSearch.text ?? reportForContextualSearch.alternateText ?? reportForContextualSearch.reportID;
sections.push({
data: [
{
text: `${translate('search.searchIn')} ${reportForContextualSearch.text ?? reportForContextualSearch.alternateText}`,
singleIcon: Expensicons.MagnifyingGlass,
- query: getContextualSearchQuery(reportForContextualSearch.reportID),
+ searchQuery: reportQueryValue,
+ autocompleteID: reportForContextualSearch.reportID,
itemStyle: styles.activeComponentBG,
keyForList: 'contextualSearch',
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION,
@@ -143,12 +169,13 @@ function SearchRouterList(
});
}
- const autocompleteData = autocompleteItems?.map(({text, query}) => {
+ const autocompleteData = autocompleteSuggestions?.map(({filterKey, text, autocompleteID}) => {
return {
- text,
+ text: getSubstitutionMapKey(filterKey, text),
singleIcon: Expensicons.MagnifyingGlass,
- query,
- keyForList: query,
+ searchQuery: text,
+ autocompleteID,
+ keyForList: autocompleteID ?? text, // in case we have a unique identifier then use it because text might not be unique
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION,
};
});
@@ -162,7 +189,7 @@ function SearchRouterList(
return {
text: searchQueryJSON ? SearchQueryUtils.buildUserReadableQueryString(searchQueryJSON, personalDetails, cardList, reports, taxRates) : query,
singleIcon: Expensicons.History,
- query,
+ searchQuery: query,
keyForList: timestamp,
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH,
};
@@ -178,20 +205,30 @@ function SearchRouterList(
const onSelectRow = useCallback(
(item: OptionData | SearchQueryItem) => {
if (isSearchQueryItem(item)) {
- if (!item?.query) {
+ if (!item.searchQuery) {
return;
}
- if (item?.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) {
- updateSearchValue(`${item?.query} `);
+ if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) {
+ const searchQuery = getContextualSearchQuery(item.searchQuery);
+ updateSearchValue(`${searchQuery} `);
+
+ if (item.autocompleteID) {
+ const autocompleteKey = `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${item.searchQuery}`;
+ onAutocompleteSuggestionClick(autocompleteKey, item.autocompleteID);
+ }
return;
}
- if (item?.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) {
- const trimmedUserSearchQuery = trimSearchQueryForAutocomplete(textInputValue);
- updateSearchValue(`${trimmedUserSearchQuery}${item?.query} `);
+ if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) {
+ const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue);
+ updateSearchValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `);
+
+ if (item.autocompleteID && item.text) {
+ onAutocompleteSuggestionClick(item.text, item.autocompleteID);
+ }
return;
}
- onSearchSubmit(SearchQueryUtils.buildSearchQueryJSON(item?.query));
+ onSearchSubmit(item.searchQuery);
}
// Handle selection of "Recent chat"
@@ -202,27 +239,25 @@ function SearchRouterList(
Report.navigateToAndOpenReport(item.login ? [item.login] : [], false);
}
},
- [closeRouter, textInputValue, onSearchSubmit, updateSearchValue],
+ [closeRouter, textInputValue, onSearchSubmit, updateSearchValue, onAutocompleteSuggestionClick],
);
const onArrowFocus = useCallback(
(focusedItem: OptionData | SearchQueryItem) => {
- if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !textInputValue) {
+ if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !focusedItem.searchQuery) {
return;
}
- const trimmedUserSearchQuery = trimSearchQueryForAutocomplete(textInputValue);
- setTextInputValue(`${trimmedUserSearchQuery}${focusedItem?.query} `);
+
+ const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue);
+ setTextInputValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(focusedItem.searchQuery)} `);
+
+ if (focusedItem.autocompleteID && focusedItem.text) {
+ onAutocompleteSuggestionClick(focusedItem.text, focusedItem.autocompleteID);
+ }
},
- [setTextInputValue, textInputValue],
+ [setTextInputValue, textInputValue, onAutocompleteSuggestionClick],
);
- const getItemHeight = useCallback((item: OptionData | SearchQueryItem) => {
- if (isSearchQueryItem(item)) {
- return 44;
- }
- return 64;
- }, []);
-
return (
sections={sections}
@@ -244,4 +279,4 @@ function SearchRouterList(
export default forwardRef(SearchRouterList);
export {SearchRouterItem};
-export type {ItemWithQuery};
+export type {AutocompleteItemData};
diff --git a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts
new file mode 100644
index 000000000000..117745fee480
--- /dev/null
+++ b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts
@@ -0,0 +1,50 @@
+import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types';
+import * as parser from '@libs/SearchParser/autocompleteParser';
+
+type SubstitutionMap = Record;
+
+const getSubstitutionMapKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`;
+
+/**
+ * Given a plaintext query and a SubstitutionMap object, this function will return a transformed query where:
+ * - any autocomplete mention in the original query will be substituted with an id taken from `substitutions` object
+ * - anything that does not match will stay as is
+ *
+ * Ex:
+ * query: `A from:@johndoe A`
+ * substitutions: {
+ * from:@johndoe: 9876
+ * }
+ * return: `A from:9876 A`
+ */
+function getQueryWithSubstitutions(changedQuery: string, substitutions: SubstitutionMap) {
+ const parsed = parser.parse(changedQuery) as {ranges: SearchAutocompleteQueryRange[]};
+
+ const searchAutocompleteQueryRanges = parsed.ranges;
+
+ if (searchAutocompleteQueryRanges.length === 0) {
+ return changedQuery;
+ }
+
+ let resultQuery = changedQuery;
+ let lengthDiff = 0;
+
+ for (const range of searchAutocompleteQueryRanges) {
+ const itemKey = getSubstitutionMapKey(range.key, range.value);
+ const substitutionEntry = substitutions[itemKey];
+
+ if (substitutionEntry) {
+ const substitutionStart = range.start + lengthDiff;
+ const substitutionEnd = range.start + range.length;
+
+ // generate new query but substituting "user-typed" value with the entity id/email from substitutions
+ resultQuery = resultQuery.slice(0, substitutionStart) + substitutionEntry + changedQuery.slice(substitutionEnd);
+ lengthDiff = lengthDiff + substitutionEntry.length - range.length;
+ }
+ }
+
+ return resultQuery;
+}
+
+export {getQueryWithSubstitutions, getSubstitutionMapKey};
+export type {SubstitutionMap};
diff --git a/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts
new file mode 100644
index 000000000000..ee7bf3850259
--- /dev/null
+++ b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts
@@ -0,0 +1,43 @@
+import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types';
+import * as parser from '@libs/SearchParser/autocompleteParser';
+import type {SubstitutionMap} from './getQueryWithSubstitutions';
+
+const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`;
+
+/**
+ * Given a plaintext query and a SubstitutionMap object,
+ * this function will remove any substitution keys that do not appear in the query and return an updated object
+ *
+ * Ex:
+ * query: `Test from:John1`
+ * substitutions: {
+ * from:SomeOtherJohn: 12345
+ * }
+ * return: {}
+ */
+function getUpdatedSubstitutionsMap(query: string, substitutions: SubstitutionMap): SubstitutionMap {
+ const parsedQuery = parser.parse(query) as {ranges: SearchAutocompleteQueryRange[]};
+
+ const searchAutocompleteQueryRanges = parsedQuery.ranges;
+
+ if (searchAutocompleteQueryRanges.length === 0) {
+ return {};
+ }
+
+ const autocompleteQueryKeys = searchAutocompleteQueryRanges.map((range) => getSubstitutionsKey(range.key, range.value));
+
+ // Build a new substitutions map consisting of only the keys from old map, that appear in query
+ const updatedSubstitutionMap = autocompleteQueryKeys.reduce((map, key) => {
+ if (substitutions[key]) {
+ // eslint-disable-next-line no-param-reassign
+ map[key] = substitutions[key];
+ }
+
+ return map;
+ }, {} as SubstitutionMap);
+
+ return updatedSubstitutionMap;
+}
+
+// eslint-disable-next-line import/prefer-default-export
+export {getUpdatedSubstitutionsMap};
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index 9238488361b0..a13b816fd8b8 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -466,6 +466,7 @@ function Search({queryJSON, onSearchListScroll, contentContainerStyle}: SearchPr
}
contentContainerStyle={[contentContainerStyle, styles.pb3]}
scrollEventThrottle={1}
+ shouldKeepFocusedItemAtTopOfViewableArea={type === CONST.SEARCH.DATA_TYPES.CHAT}
/>
);
}
diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts
index d5be896c1c50..3e5c158660f1 100644
--- a/src/components/Search/types.ts
+++ b/src/components/Search/types.ts
@@ -1,4 +1,4 @@
-import type {ValueOf} from 'react-native-gesture-handler/lib/typescript/typeUtils';
+import type {ValueOf} from 'type-fest';
import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types';
import type CONST from '@src/CONST';
import type {SearchDataTypes, SearchReport} from '@src/types/onyx/SearchResults';
@@ -56,10 +56,14 @@ type QueryFilter = {
value: string | number;
};
-type AdvancedFiltersKeys = ValueOf;
+type SearchFilterKey =
+ | ValueOf
+ | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE
+ | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS
+ | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID;
type QueryFilters = Array<{
- key: AdvancedFiltersKeys;
+ key: SearchFilterKey;
filters: QueryFilter[];
}>;
@@ -77,21 +81,23 @@ type SearchQueryAST = {
type SearchQueryJSON = {
inputQuery: SearchQueryString;
hash: number;
+ /** Hash used for putting queries in recent searches list. It ignores sortOrder and sortBy, because we want to treat queries differing only in sort params as the same query */
+ recentSearchHash: number;
flatFilters: QueryFilters;
} & SearchQueryAST;
-type AutocompleteRange = {
- key: ValueOf;
+type SearchAutocompleteResult = {
+ autocomplete: SearchAutocompleteQueryRange | null;
+ ranges: SearchAutocompleteQueryRange[];
+};
+
+type SearchAutocompleteQueryRange = {
+ key: SearchFilterKey;
length: number;
start: number;
value: string;
};
-type SearchAutocompleteResult = {
- autocomplete: AutocompleteRange | null;
- ranges: AutocompleteRange[];
-};
-
export type {
SelectedTransactionInfo,
SelectedTransactions,
@@ -105,11 +111,11 @@ export type {
ASTNode,
QueryFilter,
QueryFilters,
- AdvancedFiltersKeys,
+ SearchFilterKey,
ExpenseSearchStatus,
InvoiceSearchStatus,
TripSearchStatus,
ChatSearchStatus,
SearchAutocompleteResult,
- AutocompleteRange,
+ SearchAutocompleteQueryRange,
};
diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx
index dc216b51e291..6570ef020786 100644
--- a/src/components/SelectionList/BaseListItem.tsx
+++ b/src/components/SelectionList/BaseListItem.tsx
@@ -6,6 +6,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import useHover from '@hooks/useHover';
import {useMouseContext} from '@hooks/useMouseContext';
+import useStyleUtils from '@hooks/useStyleUtils';
import useSyncFocus from '@hooks/useSyncFocus';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -38,6 +39,7 @@ function BaseListItem({
}: BaseListItemProps) {
const theme = useTheme();
const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
const {hovered, bind} = useHover();
const {isMouseDownOnInput, setMouseUp} = useMouseContext();
@@ -96,13 +98,21 @@ function BaseListItem({
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: true}}
onMouseDown={(e) => e.preventDefault()}
id={keyForList ?? ''}
- style={pressableStyle}
+ style={[
+ pressableStyle,
+ isFocused && StyleUtils.getItemBackgroundColorStyle(!!item.isSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG),
+ ]}
onFocus={onFocus}
onMouseLeave={handleMouseLeave}
tabIndex={item.tabIndex}
wrapperStyle={pressableWrapperStyle}
>
-
+
{typeof children === 'function' ? children(hovered) : children}
{!canSelectMultiple && !!item.isSelected && !rightHandSideComponent && (
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index 3e1b3a3c2d70..bf8aa5454caa 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -107,6 +107,7 @@ function BaseSelectionList(
scrollEventThrottle,
contentContainerStyle,
shouldHighlightSelectedItem = false,
+ shouldKeepFocusedItemAtTopOfViewableArea = false,
}: BaseSelectionListProps,
ref: ForwardedRef,
) {
@@ -126,6 +127,20 @@ function BaseSelectionList(
const [currentPage, setCurrentPage] = useState(1);
const isTextInputFocusedRef = useRef(false);
const {singleExecution} = useSingleExecution();
+ const [itemHeights, setItemHeights] = useState>({});
+
+ const onItemLayout = (event: LayoutChangeEvent, itemKey: string | null | undefined) => {
+ if (!itemKey) {
+ return;
+ }
+
+ const {height} = event.nativeEvent.layout;
+
+ setItemHeights((prevHeights) => ({
+ ...prevHeights,
+ [itemKey]: height,
+ }));
+ };
const incrementPage = () => setCurrentPage((prev) => prev + 1);
@@ -151,7 +166,7 @@ function BaseSelectionList(
const selectedOptions: TItem[] = [];
sections.forEach((section, sectionIndex) => {
- const sectionHeaderHeight = variables.optionsListSectionHeaderHeight;
+ const sectionHeaderHeight = !!section.title || !!section.CustomSectionHeader ? variables.optionsListSectionHeaderHeight : 0;
itemLayouts.push({length: sectionHeaderHeight, offset});
offset += sectionHeaderHeight;
@@ -175,7 +190,7 @@ function BaseSelectionList(
disabledIndex += 1;
// Account for the height of the item in getItemLayout
- const fullItemHeight = getItemHeight(item);
+ const fullItemHeight = item?.keyForList && itemHeights[item.keyForList] ? itemHeights[item.keyForList] : getItemHeight(item);
itemLayouts.push({length: fullItemHeight, offset});
offset += fullItemHeight;
@@ -207,7 +222,7 @@ function BaseSelectionList(
itemLayouts,
allSelected: selectedOptions.length > 0 && selectedOptions.length === allOptions.length - disabledOptionsIndexes.length,
};
- }, [canSelectMultiple, sections, customListHeader, customListHeaderHeight, getItemHeight]);
+ }, [canSelectMultiple, sections, customListHeader, customListHeaderHeight, itemHeights, getItemHeight]);
const [slicedSections, ShowMoreButtonInstance] = useMemo(() => {
let remainingOptionsLimit = CONST.MAX_SELECTION_LIST_PAGE_LENGTH * currentPage;
@@ -257,8 +272,20 @@ function BaseSelectionList(
const itemIndex = item.index ?? -1;
const sectionIndex = item.sectionIndex ?? -1;
+ let viewOffsetToKeepFocusedItemAtTopOfViewableArea = 0;
+
+ // Since there are always two items above the focused item in viewable area, and items can grow beyond the screen size
+ // in searchType chat, the focused item may move out of view. To prevent this, we will ensure that the focused item remains at
+ // the top of the viewable area at all times by adjusting the viewOffset.
+ if (shouldKeepFocusedItemAtTopOfViewableArea) {
+ const firstPreviousItem = index > 0 ? flattenedSections.allOptions.at(index - 1) : undefined;
+ const firstPreviousItemHeight = firstPreviousItem && firstPreviousItem.keyForList ? itemHeights[firstPreviousItem.keyForList] : 0;
+ const secondPreviousItem = index > 1 ? flattenedSections.allOptions.at(index - 2) : undefined;
+ const secondPreviousItemHeight = secondPreviousItem && secondPreviousItem?.keyForList ? itemHeights[secondPreviousItem.keyForList] : 0;
+ viewOffsetToKeepFocusedItemAtTopOfViewableArea = firstPreviousItemHeight + secondPreviousItemHeight;
+ }
- listRef.current.scrollToLocation({sectionIndex, itemIndex, animated, viewOffset: variables.contentHeaderHeight});
+ listRef.current.scrollToLocation({sectionIndex, itemIndex, animated, viewOffset: variables.contentHeaderHeight - viewOffsetToKeepFocusedItemAtTopOfViewableArea});
},
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
@@ -450,7 +477,7 @@ function BaseSelectionList(
};
return (
- <>
+ onItemLayout(event, item?.keyForList)}>
(
wrapperStyle={listItemWrapperStyle}
/>
{item.footerContent && item.footerContent}
- >
+
);
};
diff --git a/src/components/SelectionList/CardListItem.tsx b/src/components/SelectionList/CardListItem.tsx
index 7f40d3165501..0e887d1d30db 100644
--- a/src/components/SelectionList/CardListItem.tsx
+++ b/src/components/SelectionList/CardListItem.tsx
@@ -46,7 +46,7 @@ function CardListItem({
return (
({
return (
({
({
return (
({
// Removing background style because they are added to the parent OpacityView via animatedHighlightStyle
styles.bgTransparent,
item.isSelected && styles.activeComponentBG,
- isFocused && styles.sidebarLinkActive,
styles.mh0,
];
diff --git a/src/components/SelectionList/Search/SearchQueryListItem.tsx b/src/components/SelectionList/Search/SearchQueryListItem.tsx
index f1636be0d88c..77637eed39df 100644
--- a/src/components/SelectionList/Search/SearchQueryListItem.tsx
+++ b/src/components/SelectionList/Search/SearchQueryListItem.tsx
@@ -12,7 +12,8 @@ import type IconAsset from '@src/types/utils/IconAsset';
type SearchQueryItem = ListItem & {
singleIcon?: IconAsset;
- query?: string;
+ searchQuery?: string;
+ autocompleteID?: string;
searchItemType?: ValueOf;
};
@@ -32,7 +33,7 @@ function SearchQueryListItem({item, isFocused, showTooltip, onSelectRow, onFocus
return (
({
// Removing background style because they are added to the parent OpacityView via animatedHighlightStyle
styles.bgTransparent,
item.isSelected && styles.activeComponentBG,
- isFocused && styles.sidebarLinkActive,
styles.mh0,
];
diff --git a/src/components/SelectionList/SelectableListItem.tsx b/src/components/SelectionList/SelectableListItem.tsx
index b1b242737623..e3c2a36ea7ed 100644
--- a/src/components/SelectionList/SelectableListItem.tsx
+++ b/src/components/SelectionList/SelectableListItem.tsx
@@ -32,7 +32,7 @@ function SelectableListItem({
return (
({
return (
({
rightHandSideComponent={rightHandSideComponent}
errors={item.errors}
pendingAction={item.pendingAction}
- pressableStyle={[isFocused && styles.sidebarLinkActive, pressableStyle]}
+ pressableStyle={pressableStyle}
FooterComponent={
item.invitedSecondaryLogin ? (
diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts
index 8fb50456182c..a534ba4a1623 100644
--- a/src/components/SelectionList/types.ts
+++ b/src/components/SelectionList/types.ts
@@ -597,6 +597,9 @@ type BaseSelectionListProps = Partial & {
/** Whether we highlight all the selected items */
shouldHighlightSelectedItem?: boolean;
+
+ /** Determines if the focused item should remain at the top of the viewable area when navigating with arrow keys */
+ shouldKeepFocusedItemAtTopOfViewableArea?: boolean;
} & TRightHandSideComponent;
type SelectionListHandle = {
diff --git a/src/components/SelectionListWithModal/index.tsx b/src/components/SelectionListWithModal/index.tsx
index 25123d5454d4..2ea739f531c8 100644
--- a/src/components/SelectionListWithModal/index.tsx
+++ b/src/components/SelectionListWithModal/index.tsx
@@ -79,7 +79,7 @@ function SelectionListWithModal(
const handleLongPressRow = (item: TItem) => {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- if (!turnOnSelectionModeOnLongPress || !isSmallScreenWidth || item?.isDisabled || item?.isDisabledCheckbox) {
+ if (!turnOnSelectionModeOnLongPress || !isSmallScreenWidth || item?.isDisabled || item?.isDisabledCheckbox || !isFocused) {
return;
}
setLongPressedItem(item);
diff --git a/src/components/TabSelector/getOpacity/index.native.ts b/src/components/TabSelector/getOpacity/index.native.ts
index 0da5455214c9..a59d32c2db6e 100644
--- a/src/components/TabSelector/getOpacity/index.native.ts
+++ b/src/components/TabSelector/getOpacity/index.native.ts
@@ -1,6 +1,6 @@
import type GetOpacity from './types';
-const getOpacity: GetOpacity = ({routesLength, tabIndex, active, affectedTabs, position, isActive}) => {
+const getOpacity: GetOpacity = ({routesLength, tabIndex, active, affectedTabs, position}) => {
const activeValue = active ? 1 : 0;
const inactiveValue = active ? 0 : 1;
@@ -9,7 +9,7 @@ const getOpacity: GetOpacity = ({routesLength, tabIndex, active, affectedTabs, p
return position?.interpolate({
inputRange,
- outputRange: inputRange.map((i) => (affectedTabs.includes(tabIndex) && i === tabIndex && isActive ? activeValue : inactiveValue)),
+ outputRange: inputRange.map((i) => (affectedTabs.includes(tabIndex) && i === tabIndex ? activeValue : inactiveValue)),
});
}
return activeValue;
diff --git a/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx b/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx
index 3e7c5f0bc414..4c30b048c1af 100644
--- a/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx
+++ b/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx
@@ -7,7 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import * as MoneyRequestUtils from '@libs/MoneyRequestUtils';
import type {BaseTextInputRef} from '@src/components/TextInput/BaseTextInput/types';
-import type TextInputWithCurrencySymbolProps from './types';
+import type BaseTextInputWithCurrencySymbolProps from './types';
function BaseTextInputWithCurrencySymbol(
{
@@ -24,7 +24,7 @@ function BaseTextInputWithCurrencySymbol(
extraSymbol,
style,
...rest
- }: TextInputWithCurrencySymbolProps,
+ }: BaseTextInputWithCurrencySymbolProps,
ref: React.ForwardedRef,
) {
const {fromLocaleDigit} = useLocalize();
diff --git a/src/components/TextInputWithCurrencySymbol/index.android.tsx b/src/components/TextInputWithCurrencySymbol/index.android.tsx
index cbd822d07cf8..f7794c5822ff 100644
--- a/src/components/TextInputWithCurrencySymbol/index.android.tsx
+++ b/src/components/TextInputWithCurrencySymbol/index.android.tsx
@@ -2,7 +2,7 @@ import React, {useEffect, useState} from 'react';
import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native';
import type {BaseTextInputRef} from '@src/components/TextInput/BaseTextInput/types';
import BaseTextInputWithCurrencySymbol from './BaseTextInputWithCurrencySymbol';
-import type TextInputWithCurrencySymbolProps from './types';
+import type {TextInputWithCurrencySymbolProps} from './types';
function TextInputWithCurrencySymbol({onSelectionChange = () => {}, ...props}: TextInputWithCurrencySymbolProps, ref: React.ForwardedRef) {
const [skipNextSelectionChange, setSkipNextSelectionChange] = useState(false);
@@ -21,7 +21,7 @@ function TextInputWithCurrencySymbol({onSelectionChange = () => {}, ...props}: T
setSkipNextSelectionChange(false);
return;
}
- onSelectionChange(event);
+ onSelectionChange(event.nativeEvent.selection.start, event.nativeEvent.selection.end);
}}
/>
);
diff --git a/src/components/TextInputWithCurrencySymbol/index.tsx b/src/components/TextInputWithCurrencySymbol/index.tsx
index b6230061bb6c..9e8333d9db23 100644
--- a/src/components/TextInputWithCurrencySymbol/index.tsx
+++ b/src/components/TextInputWithCurrencySymbol/index.tsx
@@ -1,14 +1,18 @@
import React from 'react';
+import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native';
import type {BaseTextInputRef} from '@src/components/TextInput/BaseTextInput/types';
import BaseTextInputWithCurrencySymbol from './BaseTextInputWithCurrencySymbol';
-import type TextInputWithCurrencySymbolProps from './types';
+import type {TextInputWithCurrencySymbolProps} from './types';
-function TextInputWithCurrencySymbol(props: TextInputWithCurrencySymbolProps, ref: React.ForwardedRef) {
+function TextInputWithCurrencySymbol({onSelectionChange = () => {}, ...props}: TextInputWithCurrencySymbolProps, ref: React.ForwardedRef) {
return (
) => {
+ onSelectionChange(event.nativeEvent.selection.start, event.nativeEvent.selection.end);
+ }}
/>
);
}
diff --git a/src/components/TextInputWithCurrencySymbol/index.web.tsx b/src/components/TextInputWithCurrencySymbol/index.web.tsx
new file mode 100644
index 000000000000..f86b764f79cc
--- /dev/null
+++ b/src/components/TextInputWithCurrencySymbol/index.web.tsx
@@ -0,0 +1,42 @@
+import React, {useRef} from 'react';
+import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native';
+import BaseTextInputWithCurrencySymbol from './BaseTextInputWithCurrencySymbol';
+import type {TextInputWithCurrencySymbolProps} from './types';
+
+function TextInputWithCurrencySymbol({onSelectionChange = () => {}, ...props}: TextInputWithCurrencySymbolProps, ref: React.ForwardedRef) {
+ const textInputRef = useRef(null);
+
+ return (
+ {
+ textInputRef.current = element as HTMLFormElement;
+
+ if (!ref) {
+ return;
+ }
+
+ if (typeof ref === 'function') {
+ ref(element as HTMLFormElement);
+ return;
+ }
+
+ // eslint-disable-next-line no-param-reassign
+ ref.current = element as HTMLFormElement;
+ }}
+ onSelectionChange={(event: NativeSyntheticEvent) => {
+ onSelectionChange(event.nativeEvent.selection.start, event.nativeEvent.selection.end);
+ }}
+ onPress={() => {
+ const selectionStart = (textInputRef.current?.selectionStart as number) ?? 0;
+ const selectionEnd = (textInputRef.current?.selectionEnd as number) ?? 0;
+ onSelectionChange(selectionStart, selectionEnd);
+ }}
+ />
+ );
+}
+
+TextInputWithCurrencySymbol.displayName = 'TextInputWithCurrencySymbol';
+
+export default React.forwardRef(TextInputWithCurrencySymbol);
diff --git a/src/components/TextInputWithCurrencySymbol/types.ts b/src/components/TextInputWithCurrencySymbol/types.ts
index 1d744e974be3..401af75b16cd 100644
--- a/src/components/TextInputWithCurrencySymbol/types.ts
+++ b/src/components/TextInputWithCurrencySymbol/types.ts
@@ -2,7 +2,7 @@ import type {NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInput
import type {TextSelection} from '@components/Composer/types';
import type {BaseTextInputProps} from '@components/TextInput/BaseTextInput/types';
-type TextInputWithCurrencySymbolProps = {
+type BaseTextInputWithCurrencySymbolProps = {
/** Formatted amount in local currency */
formattedAmount: string;
@@ -77,6 +77,12 @@ type TextInputWithCurrencySymbolProps = {
/** Hide the focus styles on TextInput */
hideFocusedState?: boolean;
-} & Pick;
+} & Pick;
-export default TextInputWithCurrencySymbolProps;
+type TextInputWithCurrencySymbolProps = Omit & {
+ onSelectionChange?: (start: number, end: number) => void;
+};
+
+export type {TextInputWithCurrencySymbolProps};
+
+export default BaseTextInputWithCurrencySymbolProps;
diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx
index cc2a7314f570..392e4b9176e6 100644
--- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx
+++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx
@@ -92,7 +92,6 @@ function BaseValidateCodeForm({
const shouldDisableResendValidateCode = !!isOffline || account?.isLoading;
const focusTimeoutRef = useRef(null);
const [timeRemaining, setTimeRemaining] = useState(CONST.REQUEST_CODE_DELAY as number);
- const [isResent, setIsResent] = useState(false);
const timerRef = useRef();
@@ -155,10 +154,6 @@ function BaseValidateCodeForm({
* Request a validate code / magic code be sent to verify this contact method
*/
const resendValidateCode = () => {
- if (hasMagicCodeBeenSent && !isResent) {
- return;
- }
-
sendValidateCode();
inputValidateCodeRef.current?.clear();
setTimeRemaining(CONST.REQUEST_CODE_DELAY);
@@ -229,10 +224,7 @@ function BaseValidateCodeForm({
{
- resendValidateCode();
- setIsResent(true);
- }}
+ onPress={resendValidateCode}
underlayColor={theme.componentBG}
hoverDimmingValue={1}
pressDimmingValue={0.2}
diff --git a/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx b/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx
index 5d1ea0d85d0b..c0b8c32cedcb 100644
--- a/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx
+++ b/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx
@@ -45,21 +45,22 @@ function ProgressBar({duration, position, seekPosition}: ProgressBarProps) {
};
const pan = Gesture.Pan()
+ .runOnJS(true)
.onBegin((event) => {
- runOnJS(setIsSliderPressed)(true);
- runOnJS(checkVideoPlaying)(onCheckVideoPlaying);
- runOnJS(pauseVideo)();
- runOnJS(progressBarInteraction)(event);
+ setIsSliderPressed(true);
+ checkVideoPlaying(onCheckVideoPlaying);
+ pauseVideo();
+ progressBarInteraction(event);
})
.onChange((event) => {
- runOnJS(progressBarInteraction)(event);
+ progressBarInteraction(event);
})
.onFinalize(() => {
- runOnJS(setIsSliderPressed)(false);
+ setIsSliderPressed(false);
if (!wasVideoPlayingOnCheck.value) {
return;
}
- runOnJS(playVideo)();
+ playVideo();
});
useEffect(() => {
diff --git a/src/hooks/useOnboardingFlow.ts b/src/hooks/useOnboardingFlow.ts
index 5ccd3bab9378..66ef088d0e4f 100644
--- a/src/hooks/useOnboardingFlow.ts
+++ b/src/hooks/useOnboardingFlow.ts
@@ -21,12 +21,23 @@ function useOnboardingFlowRouter() {
selector: hasCompletedHybridAppOnboardingFlowSelector,
});
+ const [isSingleNewDotEntry, isSingleNewDotEntryMetadata] = useOnyx(ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY);
+
useEffect(() => {
- if (isLoadingOnyxValue(isOnboardingCompletedMetadata, isHybridAppOnboardingCompletedMetadata)) {
+ if (isLoadingOnyxValue(isOnboardingCompletedMetadata)) {
+ return;
+ }
+
+ if (NativeModules.HybridAppModule && isLoadingOnyxValue(isHybridAppOnboardingCompletedMetadata, isSingleNewDotEntryMetadata)) {
return;
}
if (NativeModules.HybridAppModule) {
+ // For single entries, such as using the Travel feature from OldDot, we don't want to show onboarding
+ if (isSingleNewDotEntry) {
+ return;
+ }
+
// When user is transitioning from OldDot to NewDot, we usually show the explanation modal
if (isHybridAppOnboardingCompleted === false) {
Navigation.navigate(ROUTES.EXPLANATION_MODAL_ROOT);
@@ -43,7 +54,7 @@ function useOnboardingFlowRouter() {
if (!NativeModules.HybridAppModule && isOnboardingCompleted === false) {
OnboardingFlow.startOnboardingFlow();
}
- }, [isOnboardingCompleted, isHybridAppOnboardingCompleted, isOnboardingCompletedMetadata, isHybridAppOnboardingCompletedMetadata]);
+ }, [isOnboardingCompleted, isHybridAppOnboardingCompleted, isOnboardingCompletedMetadata, isHybridAppOnboardingCompletedMetadata, isSingleNewDotEntryMetadata, isSingleNewDotEntry]);
return {isOnboardingCompleted, isHybridAppOnboardingCompleted};
}
diff --git a/src/hooks/useReportIDs.tsx b/src/hooks/useReportIDs.tsx
index 7c35f2661336..284d80f737f2 100644
--- a/src/hooks/useReportIDs.tsx
+++ b/src/hooks/useReportIDs.tsx
@@ -2,12 +2,10 @@ import React, {createContext, useCallback, useContext, useMemo} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
import {getPolicyEmployeeListByIdWithoutCurrentUser} from '@libs/PolicyUtils';
-import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import SidebarUtils from '@libs/SidebarUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type * as OnyxTypes from '@src/types/onyx';
-import type {Message} from '@src/types/onyx/ReportAction';
import mapOnyxCollectionItems from '@src/utils/mapOnyxCollectionItems';
import useActiveWorkspace from './useActiveWorkspace';
import useCurrentReportID from './useCurrentReportID';
@@ -34,33 +32,6 @@ const ReportIDsContext = createContext({
policyMemberAccountIDs: [],
});
-/**
- * This function (and the few below it), narrow down the data from Onyx to just the properties that we want to trigger a re-render of the component. This helps minimize re-rendering
- * and makes the entire component more performant because it's not re-rendering when a bunch of properties change which aren't ever used in the UI.
- */
-const reportActionsSelector = (reportActions: OnyxEntry): ReportActionsSelector =>
- (reportActions &&
- Object.values(reportActions)
- .filter(Boolean)
- .map((reportAction) => {
- const {reportActionID, actionName, errors = []} = reportAction;
- const originalMessage = ReportActionsUtils.getOriginalMessage(reportAction);
- const message = ReportActionsUtils.getReportActionMessage(reportAction);
- const decision = message?.moderationDecision?.decision;
-
- return {
- reportActionID,
- actionName,
- errors,
- message: [
- {
- moderationDecision: {decision},
- },
- ] as Message[],
- originalMessage,
- };
- })) as ReportActionsSelector;
-
const policySelector = (policy: OnyxEntry): PolicySelector =>
(policy && {
type: policy.type,
@@ -84,7 +55,6 @@ function ReportIDsContextProvider({
const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE, {initialValue: CONST.PRIORITY_MODE.DEFAULT});
const [chatReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: (c) => mapOnyxCollectionItems(c, policySelector)});
- const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {selector: (c) => mapOnyxCollectionItems(c, reportActionsSelector)});
const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
const [reportsDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT);
const [betas] = useOnyx(ONYXKEYS.BETAS);
@@ -99,20 +69,10 @@ function ReportIDsContextProvider({
const getOrderedReportIDs = useCallback(
(currentReportID?: string) =>
- SidebarUtils.getOrderedReportIDs(
- currentReportID ?? null,
- chatReports,
- betas,
- policies,
- priorityMode,
- allReportActions,
- transactionViolations,
- activeWorkspaceID,
- policyMemberAccountIDs,
- ),
+ SidebarUtils.getOrderedReportIDs(currentReportID ?? null, chatReports, betas, policies, priorityMode, transactionViolations, activeWorkspaceID, policyMemberAccountIDs),
// we need reports draft in deps array for reloading of list when reportsDrafts will change
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- [chatReports, betas, policies, priorityMode, allReportActions, transactionViolations, activeWorkspaceID, policyMemberAccountIDs, reportsDrafts],
+ [chatReports, betas, policies, priorityMode, transactionViolations, activeWorkspaceID, policyMemberAccountIDs, reportsDrafts],
);
const orderedReportIDs = useMemo(() => getOrderedReportIDs(), [getOrderedReportIDs]);
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 03bbaf8ca8ab..38b11e9fea38 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -2477,6 +2477,10 @@ const translations = {
setupPage: {
title: 'Open this link to connect',
body: 'To complete setup, open the following link on the computer where QuickBooks Desktop is running.',
+ setupErrorTitle: 'Something went wrong',
+ setupErrorBody1: "The QuickBooks Desktop connection isn't working at the moment. Please try again later or",
+ setupErrorBody2: 'if the problem persists.',
+ setupErrorBodyContactConcierge: 'reach out to Concierge',
},
importDescription: 'Choose which coding configurations to import from QuickBooks Desktop to Expensify.',
classes: 'Classes',
@@ -3106,14 +3110,17 @@ const translations = {
processorLabel: 'Processor ID',
bankLabel: 'Financial institution (bank) ID',
companyLabel: 'Company ID',
+ helpLabel: 'Where do I find these IDs?',
},
gl1025: {
title: `What's the Amex delivery file name?`,
fileNameLabel: 'Delivery file name',
+ helpLabel: 'Where do I find the delivery file name?',
},
cdf: {
title: `What's the Mastercard distribution ID?`,
distributionLabel: 'Distribution ID',
+ helpLabel: 'Where do I find the distribution ID?',
},
},
amexCorporate: 'Select this if the front of your cards say “Corporate”',
@@ -3320,7 +3327,7 @@ const translations = {
cardNumber: 'Card number',
cardholder: 'Cardholder',
cardName: 'Card name',
- integrationExport: ({integration, type}: IntegrationExportParams) => `${integration} ${type} export`,
+ integrationExport: ({integration, type}: IntegrationExportParams) => `${integration} ${type?.toLowerCase()} export`,
integrationExportTitleFirstPart: ({integration}: IntegrationExportParams) => `Choose the ${integration} account where transactions should be exported. Select a different`,
integrationExportTitleLinkPart: 'export option',
integrationExportTitleSecondPart: 'to change the available accounts.',
@@ -3655,6 +3662,8 @@ const translations = {
return "Can't connect to Xero.";
case CONST.POLICY.CONNECTIONS.NAME.NETSUITE:
return "Can't connect to NetSuite.";
+ case CONST.POLICY.CONNECTIONS.NAME.QBD:
+ return "Can't connect to QuickBooks Desktop.";
default: {
return "Can't connect to integration.";
}
@@ -4050,7 +4059,7 @@ const translations = {
},
companyCards: {
title: 'Company cards',
- description: `Company cards lets you import spend for existing company cards from all major card issuers. You can assign cards to employees, and automatically import transactions.`,
+ description: `Connect your existing corporate cards to Expensify, assign them to employees, and automatically import transactions.`,
onlyAvailableOnPlan: 'Company cards are only available on the Control plan, starting at ',
},
rules: {
@@ -4753,12 +4762,12 @@ const translations = {
},
modifiedDate: 'Date differs from scanned receipt',
nonExpensiworksExpense: 'Non-Expensiworks expense',
- overAutoApprovalLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Expense exceeds auto approval limit of ${formattedLimit}`,
+ overAutoApprovalLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Expense exceeds auto-approval limit of ${formattedLimit}`,
overCategoryLimit: ({formattedLimit}: ViolationsOverCategoryLimitParams) => `Amount over ${formattedLimit}/person category limit`,
overLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Amount over ${formattedLimit}/person limit`,
overLimitAttendee: ({formattedLimit}: ViolationsOverLimitParams) => `Amount over ${formattedLimit}/person limit`,
perDayLimit: ({formattedLimit}: ViolationsPerDayLimitParams) => `Amount over daily ${formattedLimit}/person category limit`,
- receiptNotSmartScanned: 'Receipt not verified. Please confirm accuracy.',
+ receiptNotSmartScanned: 'Receipt scan incomplete. Please verify details manually.',
receiptRequired: ({formattedLimit, category}: ViolationsReceiptRequiredParams) => {
let message = 'Receipt required';
if (formattedLimit ?? category) {
@@ -5144,6 +5153,7 @@ const translations = {
RBR: 'RBR',
true: 'true',
false: 'false',
+ viewReport: 'View Report',
reasonVisibleInLHN: {
hasDraftComment: 'Has draft comment',
hasGBR: 'Has GBR',
@@ -5188,6 +5198,10 @@ const translations = {
emptySearchView: {
takeATour: 'Take a tour',
},
+ tour: {
+ takeATwoMinuteTour: 'Take a 2-minute tour',
+ exploreExpensify: 'Explore everything Expensify has to offer',
+ },
};
export default translations satisfies TranslationDeepObject;
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 96921bd40388..11a31c836add 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -2502,6 +2502,10 @@ const translations = {
setupPage: {
title: 'Abre este enlace para conectar',
body: 'Para completar la configuración, abre el siguiente enlace en la computadora donde se está ejecutando QuickBooks Desktop.',
+ setupErrorTitle: '¡Ups! Ha ocurrido un error',
+ setupErrorBody1: 'La conexión con QuickBooks Desktop no está funcionando en este momento. Por favor, inténtalo de nuevo más tarde o',
+ setupErrorBody2: 'si el problema persiste.',
+ setupErrorBodyContactConcierge: 'contacta con Concierge',
},
importDescription: 'Elige que configuraciónes de codificación son importadas desde QuickBooks Desktop a Expensify.',
classes: 'Clases',
@@ -3145,14 +3149,17 @@ const translations = {
processorLabel: 'ID del procesador',
bankLabel: 'Identificación de la institución financiera (banco)',
companyLabel: 'Empresa ID',
+ helpLabel: '¿Dónde encuentro estos IDs?',
},
gl1025: {
title: `¿Cuál es el nombre del archivo de entrega de Amex?`,
fileNameLabel: 'Nombre del archivo de entrega',
+ helpLabel: '¿Dónde encuentro el nombre del archivo de entrega?',
},
cdf: {
title: `¿Cuál es el identificador de distribución de Mastercard?`,
distributionLabel: 'ID de distribución',
+ helpLabel: '¿Dónde encuentro el ID de distribución?',
},
},
amexCorporate: 'Seleccione esto si el frente de sus tarjetas dice “Corporativa”',
@@ -3361,7 +3368,7 @@ const translations = {
cardNumber: 'Número de la tarjeta',
cardholder: 'Titular de la tarjeta',
cardName: 'Nombre de la tarjeta',
- integrationExport: ({integration, type}: IntegrationExportParams) => `Exportación a ${integration} ${type}`,
+ integrationExport: ({integration, type}: IntegrationExportParams) => `Exportación a ${integration} ${type?.toLowerCase()}`,
integrationExportTitleFirstPart: ({integration}: IntegrationExportParams) =>
`Seleccione la cuenta ${integration} donde se deben exportar las transacciones. Seleccione una cuenta diferente`,
integrationExportTitleLinkPart: 'opción de exportación',
@@ -3660,6 +3667,8 @@ const translations = {
return 'No se puede conectar a Xero.';
case CONST.POLICY.CONNECTIONS.NAME.NETSUITE:
return 'No se puede conectar a NetSuite.';
+ case CONST.POLICY.CONNECTIONS.NAME.QBD:
+ return 'No se puede conectar a QuickBooks Desktop.';
default: {
return 'No se ha podido conectar a la integración.';
}
@@ -4096,7 +4105,7 @@ const translations = {
},
companyCards: {
title: 'Tarjetas de empresa',
- description: `Las tarjetas de empresa le permiten importar los gastos de las tarjetas de empresa existentes de todos los principales emisores de tarjetas. Puede asignar tarjetas a empleados e importar transacciones automáticamente.`,
+ description: `Conecta tus tarjetas corporativas existentes a Expensify, asígnalas a empleados e importa transacciones automáticamente.`,
onlyAvailableOnPlan: 'Las tarjetas de empresa solo están disponibles en el plan Control, a partir de ',
},
rules: {
@@ -5268,7 +5277,7 @@ const translations = {
overLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Importe supera el límite${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`,
overLimitAttendee: ({formattedLimit}: ViolationsOverLimitParams) => `Importe supera el límite${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`,
perDayLimit: ({formattedLimit}: ViolationsPerDayLimitParams) => `Importe supera el límite diario de la categoría${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`,
- receiptNotSmartScanned: 'Recibo no verificado. Por favor, confirma la exactitud',
+ receiptNotSmartScanned: 'Escaneo de recibo incompleto. Por favor, verifica los detalles manualmente.',
receiptRequired: ({formattedLimit, category}: ViolationsReceiptRequiredParams) => {
let message = 'Recibo obligatorio';
if (formattedLimit ?? category) {
@@ -5659,6 +5668,7 @@ const translations = {
RBR: 'RBR',
true: 'verdadero',
false: 'falso',
+ viewReport: 'Ver Informe',
reasonVisibleInLHN: {
hasDraftComment: 'Tiene comentario en borrador',
hasGBR: 'Tiene GBR',
@@ -5703,6 +5713,10 @@ const translations = {
emptySearchView: {
takeATour: 'Haz un tour',
},
+ tour: {
+ takeATwoMinuteTour: 'Haz un tour de 2 minutos',
+ exploreExpensify: 'Explora todo lo que Expensify tiene para ofrecer',
+ },
};
export default translations satisfies TranslationDeepObject;
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 0350d59685ce..bf3f749f5bac 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -434,6 +434,7 @@ const WRITE_COMMANDS = {
SET_CARD_EXPORT_ACCOUNT: 'SetCardExportAccount',
SET_PERSONAL_DETAILS_AND_SHIP_EXPENSIFY_CARDS: 'SetPersonalDetailsAndShipExpensifyCards',
SET_INVOICING_TRANSFER_BANK_ACCOUNT: 'SetInvoicingTransferBankAccount',
+ SELF_TOUR_VIEWED: 'SelfTourViewed',
UPDATE_INVOICE_COMPANY_NAME: 'UpdateInvoiceCompanyName',
UPDATE_INVOICE_COMPANY_WEBSITE: 'UpdateInvoiceCompanyWebsite',
} as const;
@@ -861,6 +862,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.UPDATE_CARD_SETTLEMENT_FREQUENCY]: Parameters.UpdateCardSettlementFrequencyParams;
[WRITE_COMMANDS.UPDATE_CARD_SETTLEMENT_ACCOUNT]: Parameters.UpdateCardSettlementAccountParams;
[WRITE_COMMANDS.SET_PERSONAL_DETAILS_AND_SHIP_EXPENSIFY_CARDS]: Parameters.SetPersonalDetailsAndShipExpensifyCardsParams;
+ [WRITE_COMMANDS.SELF_TOUR_VIEWED]: null;
// Xero API
[WRITE_COMMANDS.UPDATE_XERO_TENANT_ID]: Parameters.UpdateXeroGenericTypeParams;
diff --git a/src/libs/BankAccountUtils.ts b/src/libs/BankAccountUtils.ts
index c781ccab3f33..89bcf96c642f 100644
--- a/src/libs/BankAccountUtils.ts
+++ b/src/libs/BankAccountUtils.ts
@@ -3,7 +3,7 @@ import type {OnyxEntry} from 'react-native-onyx';
import type * as OnyxTypes from '@src/types/onyx';
function getDefaultCompanyWebsite(session: OnyxEntry, user: OnyxEntry): string {
- return user?.isFromPublicDomain ? 'https://' : `https://www.${Str.extractEmailDomain(session?.email ?? '')}`;
+ return user?.isFromPublicDomain ? '' : `https://www.${Str.extractEmailDomain(session?.email ?? '')}`;
}
function getLastFourDigits(bankAccountNumber: string): string {
diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts
index ba2a33a367d4..b880239b8abf 100644
--- a/src/libs/ErrorUtils.ts
+++ b/src/libs/ErrorUtils.ts
@@ -134,6 +134,15 @@ function getLatestErrorFieldForAnyField Object.assign(acc, error), {});
}
+function getLatestError(errors?: Errors): Errors {
+ if (!errors || Object.keys(errors).length === 0) {
+ return {};
+ }
+
+ const key = Object.keys(errors).sort().reverse().at(0) ?? '';
+ return {[key]: getErrorMessageWithTranslationData(errors[key])};
+}
+
/**
* Method used to attach already translated message
* @param errors - An object containing current errors in the form
@@ -198,6 +207,7 @@ export {
getLatestErrorFieldForAnyField,
getLatestErrorMessage,
getLatestErrorMessageField,
+ getLatestError,
getMicroSecondOnyxErrorWithTranslationKey,
getMicroSecondOnyxErrorWithMessage,
getMicroSecondOnyxErrorObject,
diff --git a/src/libs/Fullstory/index.native.ts b/src/libs/Fullstory/index.native.ts
index 8d97b8d4307e..30a5a77ae9f3 100644
--- a/src/libs/Fullstory/index.native.ts
+++ b/src/libs/Fullstory/index.native.ts
@@ -40,7 +40,8 @@ const FS = {
// after the init function since this function is also called on updates for
// UserMetadata onyx key.
Environment.getEnvironment().then((envName: string) => {
- if (envName !== CONST.ENVIRONMENT.PRODUCTION) {
+ const isTestEmail = value.email !== undefined && value.email.startsWith('fullstory') && value.email.endsWith(CONST.EMAIL.QA_DOMAIN);
+ if (CONST.ENVIRONMENT.PRODUCTION !== envName && !isTestEmail) {
return;
}
FullStory.restart();
diff --git a/src/libs/Fullstory/index.ts b/src/libs/Fullstory/index.ts
index df65af358a55..0aa0b2094591 100644
--- a/src/libs/Fullstory/index.ts
+++ b/src/libs/Fullstory/index.ts
@@ -57,7 +57,8 @@ const FS = {
}
try {
Environment.getEnvironment().then((envName: string) => {
- if (CONST.ENVIRONMENT.PRODUCTION !== envName) {
+ const isTestEmail = value.email !== undefined && value.email.startsWith('fullstory') && value.email.endsWith(CONST.EMAIL.QA_DOMAIN);
+ if (CONST.ENVIRONMENT.PRODUCTION !== envName && !isTestEmail) {
return;
}
FS.onReady().then(() => {
diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts
index 25c8509efaad..206bb8509af6 100644
--- a/src/libs/MoneyRequestUtils.ts
+++ b/src/libs/MoneyRequestUtils.ts
@@ -38,21 +38,15 @@ function addLeadingZero(amount: string): string {
}
/**
- * Get amount regex string
- */
-function amountRegex(decimals: number, amountMaxLength: number = CONST.IOU.AMOUNT_MAX_LENGTH): string {
- return decimals === 0
- ? `^\\d{0,${amountMaxLength}}$` // Don't allow decimal point if decimals === 0
- : `^\\d{0,${amountMaxLength}}(?:(?:\\.|\\,)\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point
-}
-
-/**
- * Check if string is a valid amount
+ * 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 = amountRegex(decimals, amountMaxLength);
- const decimalNumberRegex = new RegExp(regexString);
- return decimalNumberRegex.test(amount);
+ const regexString =
+ decimals === 0
+ ? `^\\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);
}
/**
@@ -104,7 +98,6 @@ export {
stripDecimalsFromAmount,
stripSpacesFromAmount,
replaceCommasWithPeriod,
- amountRegex,
validateAmount,
validatePercentage,
};
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
index 93b3954d2f2b..34bdf866dbb8 100644
--- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
@@ -7,6 +7,7 @@ import {PressableWithFeedback} from '@components/Pressable';
import type {SearchQueryString} from '@components/Search/types';
import Text from '@components/Text';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
+import useCurrentReportID from '@hooks/useCurrentReportID';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -65,15 +66,23 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {activeWorkspaceID} = useActiveWorkspace();
+ const {currentReportID} = useCurrentReportID() ?? {currentReportID: null};
const [user] = useOnyx(ONYXKEYS.USER);
+ const [betas] = useOnyx(ONYXKEYS.BETAS);
+ const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE);
const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
+ const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS);
const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
- const [chatTabBrickRoad, setChatTabBrickRoad] = useState(getChatTabBrickRoad(activeWorkspaceID));
+ const [chatTabBrickRoad, setChatTabBrickRoad] = useState(
+ getChatTabBrickRoad(activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations),
+ );
useEffect(() => {
- setChatTabBrickRoad(getChatTabBrickRoad(activeWorkspaceID));
- }, [activeWorkspaceID, transactionViolations, reports, reportActions]);
+ setChatTabBrickRoad(getChatTabBrickRoad(activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations));
+ // We need to get a new brick road state when report actions are updated, otherwise we'll be showing an outdated brick road.
+ // That's why reportActions is added as a dependency here
+ }, [activeWorkspaceID, transactionViolations, reports, reportActions, betas, policies, priorityMode, currentReportID]);
const navigateToChats = useCallback(() => {
if (selectedTab === SCREENS.HOME) {
@@ -118,6 +127,12 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) {
selectedTab={selectedTab}
chatTabBrickRoad={chatTabBrickRoad}
activeWorkspaceID={activeWorkspaceID}
+ reports={reports}
+ currentReportID={currentReportID}
+ betas={betas}
+ policies={policies}
+ transactionViolations={transactionViolations}
+ priorityMode={priorityMode}
/>
)}
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx
index 5336954486e6..354529941e0c 100644
--- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx
@@ -1,6 +1,6 @@
import React, {useCallback, useMemo} from 'react';
import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
+import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
import Icon from '@components/Icon';
@@ -21,12 +21,18 @@ import ONYXKEYS from '@src/ONYXKEYS';
import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
-import type {ReimbursementAccount} from '@src/types/onyx';
+import type {Beta, Policy, PriorityMode, ReimbursementAccount, Report, TransactionViolations} from '@src/types/onyx';
type DebugTabViewProps = {
selectedTab?: string;
chatTabBrickRoad: BrickRoad;
activeWorkspaceID?: string;
+ currentReportID: string | null;
+ reports: OnyxCollection;
+ betas: OnyxEntry;
+ policies: OnyxCollection;
+ transactionViolations: OnyxCollection;
+ priorityMode: OnyxEntry;
};
function getSettingsMessage(status: IndicatorStatus | undefined): TranslationPaths | undefined {
@@ -91,7 +97,7 @@ function getSettingsRoute(status: IndicatorStatus | undefined, reimbursementAcco
}
}
-function DebugTabView({selectedTab = '', chatTabBrickRoad, activeWorkspaceID}: DebugTabViewProps) {
+function DebugTabView({selectedTab = '', chatTabBrickRoad, activeWorkspaceID, currentReportID, reports, betas, policies, transactionViolations, priorityMode}: DebugTabViewProps) {
const StyleUtils = useStyleUtils();
const theme = useTheme();
const styles = useThemeStyles();
@@ -131,10 +137,10 @@ function DebugTabView({selectedTab = '', chatTabBrickRoad, activeWorkspaceID}: D
const navigateTo = useCallback(() => {
if (selectedTab === SCREENS.HOME && !!chatTabBrickRoad) {
- const report = getChatTabBrickRoadReport(activeWorkspaceID);
+ const report = getChatTabBrickRoadReport(activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations);
if (report) {
- Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report.reportID));
+ Navigation.navigate(ROUTES.DEBUG_REPORT.getRoute(report.reportID));
}
}
if (selectedTab === SCREENS.SETTINGS.ROOT) {
@@ -144,7 +150,7 @@ function DebugTabView({selectedTab = '', chatTabBrickRoad, activeWorkspaceID}: D
Navigation.navigate(route);
}
}
- }, [selectedTab, chatTabBrickRoad, activeWorkspaceID, status, reimbursementAccount, policyIDWithErrors]);
+ }, [selectedTab, chatTabBrickRoad, activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations, status, reimbursementAccount, policyIDWithErrors]);
if (!([SCREENS.HOME, SCREENS.SETTINGS.ROOT] as string[]).includes(selectedTab) || !indicator) {
return null;
diff --git a/src/libs/Navigation/AppNavigator/createCustomPlatformStackBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomPlatformStackBottomTabNavigator/BottomTabBar.tsx
index 8ac3845b52c2..0c5e9bf20741 100644
--- a/src/libs/Navigation/AppNavigator/createCustomPlatformStackBottomTabNavigator/BottomTabBar.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomPlatformStackBottomTabNavigator/BottomTabBar.tsx
@@ -8,11 +8,13 @@ import {PressableWithFeedback} from '@components/Pressable';
import type {SearchQueryString} from '@components/Search/types';
import Tooltip from '@components/Tooltip';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
+import useCurrentReportID from '@hooks/useCurrentReportID';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as Session from '@libs/actions/Session';
import interceptAnonymousUser from '@libs/interceptAnonymousUser';
+import DebugTabView from '@libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView';
import Navigation from '@libs/Navigation/Navigation';
import type {AuthScreensParamList} from '@libs/Navigation/types';
import {isCentralPaneName} from '@libs/NavigationUtils';
@@ -72,12 +74,23 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) {
const navigation = useNavigation();
const {activeWorkspaceID} = useActiveWorkspace();
const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP);
- const transactionViolations = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
- const [chatTabBrickRoad, setChatTabBrickRoad] = useState(getChatTabBrickRoad(activeWorkspaceID));
+ const {currentReportID} = useCurrentReportID() ?? {currentReportID: null};
+ const [user] = useOnyx(ONYXKEYS.USER);
+ const [betas] = useOnyx(ONYXKEYS.BETAS);
+ const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE);
+ const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
+ const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
+ const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS);
+ const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
+ const [chatTabBrickRoad, setChatTabBrickRoad] = useState(
+ getChatTabBrickRoad(activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations),
+ );
useEffect(() => {
- setChatTabBrickRoad(getChatTabBrickRoad(activeWorkspaceID));
- }, [activeWorkspaceID, transactionViolations]);
+ setChatTabBrickRoad(getChatTabBrickRoad(activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations));
+ // We need to get a new brick road state when report actions are updated, otherwise we'll be showing an outdated brick road.
+ // That's why reportActions is added as a dependency here
+ }, [activeWorkspaceID, transactionViolations, reports, reportActions, betas, policies, priorityMode, currentReportID]);
useEffect(() => {
const navigationState = navigation.getState();
@@ -138,51 +151,66 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) {
}, [activeWorkspaceID, selectedTab]);
return (
-
-
-
-
-
- {!!chatTabBrickRoad && (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
+ <>
+ {!!user?.isDebugModeEnabled && (
+
+ )}
+
+
+
+
+
+ {!!chatTabBrickRoad && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+ >
);
}
diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx
index bb005fc6b763..c23c3783b3bf 100644
--- a/src/libs/Navigation/NavigationRoot.tsx
+++ b/src/libs/Navigation/NavigationRoot.tsx
@@ -108,7 +108,8 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh
}
// If there is no lastVisitedPath, we can do early return. We won't modify the default behavior.
- if (!lastVisitedPath) {
+ // The same applies to HybridApp, as we always define the route to which we want to transition.
+ if (!lastVisitedPath || NativeModules.HybridAppModule) {
return undefined;
}
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 497a2d33cf56..1296a64e571d 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -1893,7 +1893,7 @@ function getOptions(
allPersonalDetailsOptions = lodashOrderBy(allPersonalDetailsOptions, [(personalDetail) => personalDetail.text?.toLowerCase()], 'asc');
}
- const optionsToExclude: Option[] = [];
+ const optionsToExclude: Option[] = [{login: CONST.EMAIL.NOTIFICATIONS}];
// If we're including selected options from the search results, we only want to exclude them if the search input is empty
// This is because on certain pages, we show the selected options at the top when the search input is empty
diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts
index a02a456c48fe..0853bd9c18ce 100644
--- a/src/libs/Permissions.ts
+++ b/src/libs/Permissions.ts
@@ -24,22 +24,10 @@ function canUseSpotnanaTravel(betas: OnyxEntry): boolean {
return !!betas?.includes(CONST.BETAS.SPOTNANA_TRAVEL) || canUseAllBetas(betas);
}
-function canUseCompanyCardFeeds(betas: OnyxEntry): boolean {
- return !!betas?.includes(CONST.BETAS.COMPANY_CARD_FEEDS) || canUseAllBetas(betas);
-}
-
-function canUseDirectFeeds(betas: OnyxEntry): boolean {
- return !!betas?.includes(CONST.BETAS.DIRECT_FEEDS) || canUseAllBetas(betas);
-}
-
function canUseNetSuiteUSATax(betas: OnyxEntry): boolean {
return !!betas?.includes(CONST.BETAS.NETSUITE_USA_TAX) || canUseAllBetas(betas);
}
-function canUseNewDotCopilot(betas: OnyxEntry): boolean {
- return !!betas?.includes(CONST.BETAS.NEW_DOT_COPILOT) || canUseAllBetas(betas);
-}
-
function canUseCategoryAndTagApprovers(betas: OnyxEntry): boolean {
return !!betas?.includes(CONST.BETAS.CATEGORY_AND_TAG_APPROVERS) || canUseAllBetas(betas);
}
@@ -66,10 +54,7 @@ export default {
canUseDupeDetection,
canUseP2PDistanceRequests,
canUseSpotnanaTravel,
- canUseCompanyCardFeeds,
- canUseDirectFeeds,
canUseNetSuiteUSATax,
- canUseNewDotCopilot,
canUseCombinedTrackSubmit,
canUseCategoryAndTagApprovers,
canUsePerDiem,
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index fbd2cd654d93..a62716975c01 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -53,7 +53,7 @@ import type {OriginalMessageChangeLog, PaymentMethodType} from '@src/types/onyx/
import type {Status} from '@src/types/onyx/PersonalDetails';
import type {ConnectionName} from '@src/types/onyx/Policy';
import type {NotificationPreference, Participants, PendingChatMember, Participant as ReportParticipant} from '@src/types/onyx/Report';
-import type {Message, ReportActions} from '@src/types/onyx/ReportAction';
+import type {Message, OldDotReportAction, ReportActions} from '@src/types/onyx/ReportAction';
import type {Comment, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type IconAsset from '@src/types/utils/IconAsset';
@@ -258,6 +258,11 @@ type OptimisticCancelPaymentReportAction = Pick<
'actionName' | 'actorAccountID' | 'message' | 'originalMessage' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction'
>;
+type OptimisticChangeFieldAction = Pick<
+ OldDotReportAction & ReportAction,
+ 'actionName' | 'actorAccountID' | 'originalMessage' | 'person' | 'reportActionID' | 'created' | 'pendingAction' | 'message'
+>;
+
type OptimisticEditedTaskReportAction = Pick<
ReportAction,
'reportActionID' | 'actionName' | 'pendingAction' | 'actorAccountID' | 'automatic' | 'avatar' | 'created' | 'shouldShow' | 'message' | 'person' | 'delegateAccountID'
@@ -452,6 +457,7 @@ type OptimisticIOUReport = Pick<
| 'parentReportID'
| 'lastVisibleActionCreated'
| 'fieldList'
+ | 'parentReportActionID'
>;
type DisplayNameWithTooltips = Array>;
@@ -2570,6 +2576,56 @@ function getReimbursementDeQueuedActionMessage(
return Localize.translateLocal('iou.canceledRequest', {submitterDisplayName, amount: formattedAmount});
}
+/**
+ * Builds an optimistic REIMBURSEMENT_DEQUEUED report action with a randomly generated reportActionID.
+ *
+ */
+function buildOptimisticChangeFieldAction(reportField: PolicyReportField, previousReportField: PolicyReportField): OptimisticChangeFieldAction {
+ return {
+ actionName: CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD,
+ actorAccountID: currentUserAccountID,
+ message: [
+ {
+ type: 'TEXT',
+ style: 'strong',
+ text: 'You',
+ },
+ {
+ type: 'TEXT',
+ style: 'normal',
+ text: ` modified field '${reportField.name}'.`,
+ },
+ {
+ type: 'TEXT',
+ style: 'normal',
+ text: ` New value is '${reportField.value}'`,
+ },
+ {
+ type: 'TEXT',
+ style: 'normal',
+ text: ` (previously '${previousReportField.value}').`,
+ },
+ ],
+ originalMessage: {
+ fieldName: reportField.name,
+ newType: reportField.type,
+ newValue: reportField.value,
+ oldType: previousReportField.type,
+ oldValue: previousReportField.value,
+ },
+ person: [
+ {
+ style: 'strong',
+ text: getCurrentUserDisplayNameOrEmail(),
+ type: 'TEXT',
+ },
+ ],
+ reportActionID: NumberUtils.rand64(),
+ created: DateUtils.getDBTime(),
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ };
+}
+
/**
* Builds an optimistic REIMBURSEMENT_DEQUEUED report action with a randomly generated reportActionID.
*
@@ -4273,7 +4329,7 @@ function getReportDescription(report: OnyxEntry): string {
try {
const reportDescription = report?.description;
const objectDescription = JSON.parse(reportDescription) as {html: string};
- return objectDescription.html ?? '';
+ return objectDescription.html ?? reportDescription ?? '';
} catch (error) {
return report?.description ?? '';
}
@@ -4448,9 +4504,18 @@ function buildOptimisticTaskCommentReportAction(
* @param chatReportID - Report ID of the chat where the IOU is.
* @param currency - IOU currency.
* @param isSendingMoney - If we pay someone the IOU should be created as settled
+ * @param parentReportActionID - The parent report action ID of the IOU report
*/
-function buildOptimisticIOUReport(payeeAccountID: number, payerAccountID: number, total: number, chatReportID: string, currency: string, isSendingMoney = false): OptimisticIOUReport {
+function buildOptimisticIOUReport(
+ payeeAccountID: number,
+ payerAccountID: number,
+ total: number,
+ chatReportID: string,
+ currency: string,
+ isSendingMoney = false,
+ parentReportActionID?: string,
+): OptimisticIOUReport {
const formattedTotal = CurrencyUtils.convertToDisplayString(total, currency);
const personalDetails = getPersonalDetailsForAccountID(payerAccountID);
const payerEmail = 'login' in personalDetails ? personalDetails.login : '';
@@ -4480,6 +4545,7 @@ function buildOptimisticIOUReport(payeeAccountID: number, payerAccountID: number
parentReportID: chatReportID,
lastVisibleActionCreated: DateUtils.getDBTime(),
fieldList: policy?.fieldList,
+ parentReportActionID,
};
}
@@ -7260,8 +7326,7 @@ function canCreateRequest(report: OnyxEntry, policy: OnyxEntry,
return requestOptions.includes(iouType);
}
-function getWorkspaceChats(policyID: string, accountIDs: number[]): Array> {
- const allReports = ReportConnection.getAllReports();
+function getWorkspaceChats(policyID: string, accountIDs: number[], allReports: OnyxCollection = ReportConnection.getAllReports()): Array> {
return Object.values(allReports ?? {}).filter((report) => isPolicyExpenseChat(report) && (report?.policyID ?? '-1') === policyID && accountIDs.includes(report?.ownerAccountID ?? -1));
}
@@ -8660,6 +8725,7 @@ export {
hasMissingInvoiceBankAccount,
reasonForReportToBeInOptionList,
getReasonAndReportActionThatRequiresAttention,
+ buildOptimisticChangeFieldAction,
isPolicyRelatedReport,
hasReportErrorsOtherThanFailedReceipt,
shouldShowViolations,
diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts
index f33e2a82d445..fd427b7480c6 100644
--- a/src/libs/SearchAutocompleteUtils.ts
+++ b/src/libs/SearchAutocompleteUtils.ts
@@ -5,6 +5,10 @@ import type {Policy, PolicyCategories, PolicyTagLists, RecentlyUsedCategories, R
import {getTagNamesFromTagsLists} from './PolicyUtils';
import * as autocompleteParser from './SearchParser/autocompleteParser';
+/**
+ * Parses given query using the autocomplete parser.
+ * This is a smaller and simpler version of search parser used for autocomplete displaying logic.
+ */
function parseForAutocomplete(text: string) {
try {
const parsedAutocomplete = autocompleteParser.parse(text) as SearchAutocompleteResult;
@@ -14,6 +18,9 @@ function parseForAutocomplete(text: string) {
}
}
+/**
+ * Returns data for computing the `Tag` filter autocomplete list.
+ */
function getAutocompleteTags(allPoliciesTagsLists: OnyxCollection, policyID?: string) {
const singlePolicyTagsList: PolicyTagLists | undefined = allPoliciesTagsLists?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`];
if (!singlePolicyTagsList) {
@@ -28,6 +35,9 @@ function getAutocompleteTags(allPoliciesTagsLists: OnyxCollection, policyID?: string) {
const singlePolicyRecentTags: RecentlyUsedTags | undefined = allRecentTags?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`];
if (!singlePolicyRecentTags) {
@@ -41,6 +51,9 @@ function getAutocompleteRecentTags(allRecentTags: OnyxCollection, policyID?: string) {
const singlePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`];
if (!singlePolicyCategories) {
@@ -51,6 +64,9 @@ function getAutocompleteCategories(allPolicyCategories: OnyxCollection category.name);
}
+/**
+ * Returns data for computing the recent categories autocomplete list.
+ */
function getAutocompleteRecentCategories(allRecentCategories: OnyxCollection, policyID?: string) {
const singlePolicyRecentCategories = allRecentCategories?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`];
if (!singlePolicyRecentCategories) {
@@ -61,18 +77,43 @@ function getAutocompleteRecentCategories(allRecentCategories: OnyxCollection category);
}
-function getAutocompleteTaxList(allTaxRates: Record, policy?: OnyxEntry) {
+/**
+ * Returns data for computing the `Tax` filter autocomplete list
+ *
+ * Please note: taxes are stored in a quite convoluted and non-obvious way, and there can be multiple taxes with the same id
+ * because tax ids are generated based on a tax name, so they look like this: `id_My_Tax` and are not numeric.
+ * That is why this function may seem a bit complex.
+ */
+function getAutocompleteTaxList(taxRates: Record, policy?: OnyxEntry) {
if (policy) {
- return Object.keys(policy?.taxRates?.taxes ?? {}).map((taxRateName) => taxRateName);
+ const policyTaxes = policy?.taxRates?.taxes ?? {};
+
+ return Object.keys(policyTaxes).map((taxID) => ({
+ taxRateName: policyTaxes[taxID].name,
+ taxRateIds: [taxID],
+ }));
}
- return Object.keys(allTaxRates).map((taxRateName) => taxRateName);
+
+ return Object.keys(taxRates).map((taxName) => ({
+ taxRateName: taxName,
+ taxRateIds: taxRates[taxName].map((id) => taxRates[id] ?? id).flat(),
+ }));
}
-function trimSearchQueryForAutocomplete(searchQuery: string) {
- const lastColonIndex = searchQuery.lastIndexOf(':');
- const lastCommaIndex = searchQuery.lastIndexOf(',');
- const trimmedUserSearchQuery = lastColonIndex > lastCommaIndex ? searchQuery.slice(0, lastColonIndex + 1) : searchQuery.slice(0, lastCommaIndex + 1);
- return trimmedUserSearchQuery;
+/**
+ * Given a query string, this function parses it with the autocomplete parser
+ * and returns only the part of the string before autocomplete.
+ *
+ * Ex: "test from:john@doe" -> "test from:"
+ */
+function getQueryWithoutAutocompletedPart(searchQuery: string) {
+ const parsedQuery = parseForAutocomplete(searchQuery);
+ if (!parsedQuery?.autocomplete) {
+ return searchQuery;
+ }
+
+ const sliceEnd = parsedQuery.autocomplete.start;
+ return searchQuery.slice(0, sliceEnd);
}
export {
@@ -82,5 +123,5 @@ export {
getAutocompleteCategories,
getAutocompleteRecentCategories,
getAutocompleteTaxList,
- trimSearchQueryForAutocomplete,
+ getQueryWithoutAutocompletedPart,
};
diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js
index be57ff8a67a5..bd114b56e099 100644
--- a/src/libs/SearchParser/autocompleteParser.js
+++ b/src/libs/SearchParser/autocompleteParser.js
@@ -186,12 +186,13 @@ function peg$parse(input, options) {
var peg$c8 = "expenseType";
var peg$c9 = "type";
var peg$c10 = "status";
- var peg$c11 = "!=";
- var peg$c12 = ">=";
- var peg$c13 = ">";
- var peg$c14 = "<=";
- var peg$c15 = "<";
- var peg$c16 = "\"";
+ var peg$c11 = "cardID";
+ var peg$c12 = "!=";
+ var peg$c13 = ">=";
+ var peg$c14 = ">";
+ var peg$c15 = "<=";
+ var peg$c16 = "<";
+ var peg$c17 = "\"";
var peg$r0 = /^[:=]/;
var peg$r1 = /^[^ ,"\t\n\r]/;
@@ -211,21 +212,22 @@ function peg$parse(input, options) {
var peg$e9 = peg$literalExpectation("expenseType", false);
var peg$e10 = peg$literalExpectation("type", false);
var peg$e11 = peg$literalExpectation("status", false);
- var peg$e12 = peg$otherExpectation("operator");
- var peg$e13 = peg$classExpectation([":", "="], false, false);
- var peg$e14 = peg$literalExpectation("!=", false);
- var peg$e15 = peg$literalExpectation(">=", false);
- var peg$e16 = peg$literalExpectation(">", false);
- var peg$e17 = peg$literalExpectation("<=", false);
- var peg$e18 = peg$literalExpectation("<", false);
- var peg$e19 = peg$otherExpectation("quote");
- var peg$e20 = peg$classExpectation([" ", ",", "\"", "\t", "\n", "\r"], true, false);
- var peg$e21 = peg$literalExpectation("\"", false);
- var peg$e22 = peg$classExpectation(["\"", "\r", "\n"], true, false);
- var peg$e23 = peg$classExpectation([" ", ",", "\t", "\n", "\r"], true, false);
- var peg$e24 = peg$otherExpectation("word");
- var peg$e25 = peg$otherExpectation("whitespace");
- var peg$e26 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false);
+ var peg$e12 = peg$literalExpectation("cardID", false);
+ var peg$e13 = peg$otherExpectation("operator");
+ var peg$e14 = peg$classExpectation([":", "="], false, false);
+ var peg$e15 = peg$literalExpectation("!=", false);
+ var peg$e16 = peg$literalExpectation(">=", false);
+ var peg$e17 = peg$literalExpectation(">", false);
+ var peg$e18 = peg$literalExpectation("<=", false);
+ var peg$e19 = peg$literalExpectation("<", false);
+ var peg$e20 = peg$otherExpectation("quote");
+ var peg$e21 = peg$classExpectation([" ", ",", "\"", "\t", "\n", "\r"], true, false);
+ var peg$e22 = peg$literalExpectation("\"", false);
+ var peg$e23 = peg$classExpectation(["\"", "\r", "\n"], true, false);
+ var peg$e24 = peg$classExpectation([" ", ",", "\t", "\n", "\r"], true, false);
+ var peg$e25 = peg$otherExpectation("word");
+ var peg$e26 = peg$otherExpectation("whitespace");
+ var peg$e27 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false);
var peg$f0 = function(ranges) { return { autocomplete, ranges }; };
var peg$f1 = function(filters) { return filters.filter(Boolean).flat(); };
@@ -644,6 +646,15 @@ function peg$parse(input, options) {
s1 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e11); }
}
+ if (s1 === peg$FAILED) {
+ if (input.substr(peg$currPos, 6) === peg$c11) {
+ s1 = peg$c11;
+ peg$currPos += 6;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e12); }
+ }
+ }
}
}
}
@@ -740,7 +751,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e13); }
+ if (peg$silentFails === 0) { peg$fail(peg$e14); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
@@ -749,12 +760,12 @@ function peg$parse(input, options) {
s0 = s1;
if (s0 === peg$FAILED) {
s0 = peg$currPos;
- if (input.substr(peg$currPos, 2) === peg$c11) {
- s1 = peg$c11;
+ if (input.substr(peg$currPos, 2) === peg$c12) {
+ s1 = peg$c12;
peg$currPos += 2;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e14); }
+ if (peg$silentFails === 0) { peg$fail(peg$e15); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
@@ -763,12 +774,12 @@ function peg$parse(input, options) {
s0 = s1;
if (s0 === peg$FAILED) {
s0 = peg$currPos;
- if (input.substr(peg$currPos, 2) === peg$c12) {
- s1 = peg$c12;
+ if (input.substr(peg$currPos, 2) === peg$c13) {
+ s1 = peg$c13;
peg$currPos += 2;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e15); }
+ if (peg$silentFails === 0) { peg$fail(peg$e16); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
@@ -778,11 +789,11 @@ function peg$parse(input, options) {
if (s0 === peg$FAILED) {
s0 = peg$currPos;
if (input.charCodeAt(peg$currPos) === 62) {
- s1 = peg$c13;
+ s1 = peg$c14;
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e16); }
+ if (peg$silentFails === 0) { peg$fail(peg$e17); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
@@ -791,12 +802,12 @@ function peg$parse(input, options) {
s0 = s1;
if (s0 === peg$FAILED) {
s0 = peg$currPos;
- if (input.substr(peg$currPos, 2) === peg$c14) {
- s1 = peg$c14;
+ if (input.substr(peg$currPos, 2) === peg$c15) {
+ s1 = peg$c15;
peg$currPos += 2;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e17); }
+ if (peg$silentFails === 0) { peg$fail(peg$e18); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
@@ -806,11 +817,11 @@ function peg$parse(input, options) {
if (s0 === peg$FAILED) {
s0 = peg$currPos;
if (input.charCodeAt(peg$currPos) === 60) {
- s1 = peg$c15;
+ s1 = peg$c16;
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e18); }
+ if (peg$silentFails === 0) { peg$fail(peg$e19); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
@@ -825,7 +836,7 @@ function peg$parse(input, options) {
peg$silentFails--;
if (s0 === peg$FAILED) {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e12); }
+ if (peg$silentFails === 0) { peg$fail(peg$e13); }
}
return s0;
@@ -842,7 +853,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e20); }
+ if (peg$silentFails === 0) { peg$fail(peg$e21); }
}
while (s2 !== peg$FAILED) {
s1.push(s2);
@@ -851,15 +862,15 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e20); }
+ if (peg$silentFails === 0) { peg$fail(peg$e21); }
}
}
if (input.charCodeAt(peg$currPos) === 34) {
- s2 = peg$c16;
+ s2 = peg$c17;
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e21); }
+ if (peg$silentFails === 0) { peg$fail(peg$e22); }
}
if (s2 !== peg$FAILED) {
s3 = [];
@@ -868,7 +879,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s4 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e22); }
+ if (peg$silentFails === 0) { peg$fail(peg$e23); }
}
while (s4 !== peg$FAILED) {
s3.push(s4);
@@ -877,15 +888,15 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s4 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e22); }
+ if (peg$silentFails === 0) { peg$fail(peg$e23); }
}
}
if (input.charCodeAt(peg$currPos) === 34) {
- s4 = peg$c16;
+ s4 = peg$c17;
peg$currPos++;
} else {
s4 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e21); }
+ if (peg$silentFails === 0) { peg$fail(peg$e22); }
}
if (s4 !== peg$FAILED) {
s5 = [];
@@ -894,7 +905,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s6 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e23); }
+ if (peg$silentFails === 0) { peg$fail(peg$e24); }
}
while (s6 !== peg$FAILED) {
s5.push(s6);
@@ -903,7 +914,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s6 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e23); }
+ if (peg$silentFails === 0) { peg$fail(peg$e24); }
}
}
peg$savedPos = s0;
@@ -919,7 +930,7 @@ function peg$parse(input, options) {
peg$silentFails--;
if (s0 === peg$FAILED) {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e19); }
+ if (peg$silentFails === 0) { peg$fail(peg$e20); }
}
return s0;
@@ -936,7 +947,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e23); }
+ if (peg$silentFails === 0) { peg$fail(peg$e24); }
}
if (s2 !== peg$FAILED) {
while (s2 !== peg$FAILED) {
@@ -946,7 +957,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e23); }
+ if (peg$silentFails === 0) { peg$fail(peg$e24); }
}
}
} else {
@@ -960,7 +971,7 @@ function peg$parse(input, options) {
peg$silentFails--;
if (s0 === peg$FAILED) {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e24); }
+ if (peg$silentFails === 0) { peg$fail(peg$e25); }
}
return s0;
@@ -988,7 +999,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e26); }
+ if (peg$silentFails === 0) { peg$fail(peg$e27); }
}
while (s1 !== peg$FAILED) {
s0.push(s1);
@@ -997,12 +1008,12 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e26); }
+ if (peg$silentFails === 0) { peg$fail(peg$e27); }
}
}
peg$silentFails--;
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e25); }
+ if (peg$silentFails === 0) { peg$fail(peg$e26); }
return s0;
}
diff --git a/src/libs/SearchParser/autocompleteParser.peggy b/src/libs/SearchParser/autocompleteParser.peggy
index 89d89fd07cd4..e2a8bed9a9cc 100644
--- a/src/libs/SearchParser/autocompleteParser.peggy
+++ b/src/libs/SearchParser/autocompleteParser.peggy
@@ -61,6 +61,7 @@ autocompleteKey "key"
/ "expenseType"
/ "type"
/ "status"
+ / "cardID"
)
identifier
diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts
index c84e42704fb9..5e2a6d737984 100644
--- a/src/libs/SearchQueryUtils.ts
+++ b/src/libs/SearchQueryUtils.ts
@@ -125,11 +125,11 @@ function getFilters(queryJSON: SearchQueryJSON) {
return;
}
- if (typeof node?.left === 'object' && node.left) {
+ if (typeof node.left === 'object' && node.left) {
traverse(node.left);
}
- if (typeof node?.right === 'object' && node.right && !Array.isArray(node.right)) {
+ if (typeof node.right === 'object' && node.right && !Array.isArray(node.right)) {
traverse(node.right);
}
@@ -148,7 +148,7 @@ function getFilters(queryJSON: SearchQueryJSON) {
node.right.forEach((element) => {
filterArray.push({
operator: node.operator,
- value: element as string | number,
+ value: element,
});
});
}
@@ -163,52 +163,66 @@ function getFilters(queryJSON: SearchQueryJSON) {
}
/**
- * @private
* Given a filter name and its value, this function returns the corresponding ID found in Onyx data.
+ * Returns a function that can be used as a computeNodeValue callback for traversing the filters tree
*/
-function findIDFromDisplayValue(filterName: ValueOf, filter: string | string[], cardList: OnyxTypes.CardList, taxRates: Record) {
- if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) {
- if (typeof filter === 'string') {
- const email = filter;
- return PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? filter;
+function getFindIDFromDisplayValue(cardList: OnyxTypes.CardList, taxRates: Record) {
+ return (filterName: ValueOf, filter: string | string[]) => {
+ if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) {
+ if (typeof filter === 'string') {
+ const email = filter;
+ return PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? filter;
+ }
+ const emails = filter;
+ return emails.map((email) => PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? email);
}
- const emails = filter;
- return emails.map((email) => PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? email);
- }
- if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) {
- const names = Array.isArray(filter) ? filter : ([filter] as string[]);
- return names.map((name) => taxRates[name] ?? name).flat();
- }
- if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) {
- if (typeof filter === 'string') {
- const bank = filter;
- const ids =
- Object.values(cardList)
- .filter((card) => card.bank === bank)
- .map((card) => card.cardID.toString()) ?? filter;
- return ids.length > 0 ? ids : bank;
+ if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) {
+ const names = Array.isArray(filter) ? filter : ([filter] as string[]);
+ return names.map((name) => taxRates[name] ?? name).flat();
}
- const banks = filter;
- return banks
- .map(
- (bank) =>
+ if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) {
+ if (typeof filter === 'string') {
+ const bank = filter;
+ const ids =
Object.values(cardList)
.filter((card) => card.bank === bank)
- .map((card) => card.cardID.toString()) ?? bank,
- )
- .flat();
- }
- if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) {
- if (typeof filter === 'string') {
- const backendAmount = CurrencyUtils.convertToBackendAmount(Number(filter));
- return Number.isNaN(backendAmount) ? filter : backendAmount.toString();
+ .map((card) => card.cardID.toString()) ?? filter;
+ return ids.length > 0 ? ids : bank;
+ }
+ const banks = filter;
+ return banks
+ .map(
+ (bank) =>
+ Object.values(cardList)
+ .filter((card) => card.bank === bank)
+ .map((card) => card.cardID.toString()) ?? bank,
+ )
+ .flat();
}
- return filter.map((amount) => {
- const backendAmount = CurrencyUtils.convertToBackendAmount(Number(amount));
- return Number.isNaN(backendAmount) ? amount : backendAmount.toString();
- });
+ if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) {
+ return getUpdatedAmountValue(filterName, filter);
+ }
+
+ return filter;
+ };
+}
+
+/**
+ * Returns an updated amount value for query filters, correctly formatted to "backend" amount
+ */
+function getUpdatedAmountValue(filterName: ValueOf, filter: string | string[]) {
+ if (filterName !== CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) {
+ return filter;
}
- return filter;
+
+ if (typeof filter === 'string') {
+ const backendAmount = CurrencyUtils.convertToBackendAmount(Number(filter));
+ return Number.isNaN(backendAmount) ? filter : backendAmount.toString();
+ }
+ return filter.map((amount) => {
+ const backendAmount = CurrencyUtils.convertToBackendAmount(Number(amount));
+ return Number.isNaN(backendAmount) ? amount : backendAmount.toString();
+ });
}
/**
@@ -216,15 +230,10 @@ function findIDFromDisplayValue(filterName: ValueOf {
filter.filters.sort((a, b) => localeCompare(a.value.toString(), b.value.toString()));
@@ -235,7 +244,16 @@ function getQueryHash(query: SearchQueryJSON): number {
.sort()
.forEach((filterString) => (orderedQuery += ` ${filterString}`));
- return UserUtils.hashText(orderedQuery, 2 ** 32);
+ const recentSearchHash = UserUtils.hashText(orderedQuery, 2 ** 32);
+
+ orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY}:${query.sortBy}`;
+ orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_ORDER}:${query.sortOrder}`;
+ if (query.policyID) {
+ orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID}:${query.policyID} `;
+ }
+ const primaryHash = UserUtils.hashText(orderedQuery, 2 ** 32);
+
+ return {primaryHash, recentSearchHash};
}
/**
@@ -252,7 +270,9 @@ function buildSearchQueryJSON(query: SearchQueryString) {
// Add the full input and hash to the results
result.inputQuery = query;
result.flatFilters = flatFilters;
- result.hash = getQueryHash(result);
+ const {primaryHash, recentSearchHash} = getQueryHashes(result);
+ result.hash = primaryHash;
+ result.recentSearchHash = recentSearchHash;
return result;
} catch (e) {
@@ -555,7 +575,9 @@ function buildUserReadableQueryString(
})
.flat();
- displayQueryFilters = taxRateNames.map((taxRate) => ({
+ const uniqueTaxRateNames = [...new Set(taxRateNames)];
+
+ displayQueryFilters = uniqueTaxRateNames.map((taxRate) => ({
operator: queryFilter.at(0)?.operator ?? CONST.SEARCH.SYNTAX_OPERATORS.AND,
value: taxRate,
}));
@@ -604,23 +626,23 @@ function isCannedSearchQuery(queryJSON: SearchQueryJSON) {
/**
* Given a search query, this function will standardize the query by replacing display values with their corresponding IDs.
*/
-function standardizeQueryJSON(queryJSON: SearchQueryJSON, cardList: OnyxTypes.CardList, taxRates: Record) {
+function traverseAndUpdatedQuery(queryJSON: SearchQueryJSON, computeNodeValue: (left: ValueOf, right: string | string[]) => string | string[]) {
const standardQuery = cloneDeep(queryJSON);
const filters = standardQuery.filters;
const traverse = (node: ASTNode) => {
if (!node.operator) {
return;
}
- if (typeof node.left === 'object' && node.left) {
+ if (typeof node.left === 'object') {
traverse(node.left);
}
- if (typeof node.right === 'object' && node.right && !Array.isArray(node.right)) {
+ if (typeof node.right === 'object' && !Array.isArray(node.right)) {
traverse(node.right);
}
- if (typeof node.left !== 'object') {
+ if (typeof node.left !== 'object' && (Array.isArray(node.right) || typeof node.right === 'string')) {
// eslint-disable-next-line no-param-reassign
- node.right = findIDFromDisplayValue(node.left, node.right as string | string[], cardList, taxRates);
+ node.right = computeNodeValue(node.left, node.right);
}
};
@@ -641,6 +663,8 @@ export {
getPolicyIDFromSearchQuery,
buildCannedSearchQuery,
isCannedSearchQuery,
- standardizeQueryJSON,
+ traverseAndUpdatedQuery,
+ getFindIDFromDisplayValue,
+ getUpdatedAmountValue,
sanitizeSearchValue,
};
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index e7399a6d3982..d47cee3745a0 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -2,7 +2,7 @@ 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';
-import type {PolicySelector, ReportActionsSelector} from '@hooks/useReportIDs';
+import type {PolicySelector} from '@hooks/useReportIDs';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PersonalDetails, PersonalDetailsList, ReportActions, TransactionViolation} from '@src/types/onyx';
@@ -92,7 +92,6 @@ function getOrderedReportIDs(
betas: OnyxEntry,
policies: OnyxCollection,
priorityMode: OnyxEntry,
- allReportActions: OnyxCollection,
transactionViolations: OnyxCollection,
currentPolicyID = '',
policyMemberAccountIDs: number[] = [],
diff --git a/src/libs/Sound/BaseSound.ts b/src/libs/Sound/BaseSound.ts
index e7fc5fadd259..1b1853eb30a6 100644
--- a/src/libs/Sound/BaseSound.ts
+++ b/src/libs/Sound/BaseSound.ts
@@ -1,11 +1,15 @@
import Onyx from 'react-native-onyx';
+import getPlatform from '@libs/getPlatform';
import ONYXKEYS from '@src/ONYXKEYS';
let isMuted = false;
Onyx.connect({
- key: ONYXKEYS.USER,
- callback: (val) => (isMuted = !!val?.isMutedAllSounds),
+ key: ONYXKEYS.NVP_MUTED_PLATFORMS,
+ callback: (val) => {
+ const platform = getPlatform(true);
+ isMuted = !!val?.[platform];
+ },
});
const SOUNDS = {
diff --git a/src/libs/TourUtils.ts b/src/libs/TourUtils.ts
new file mode 100644
index 000000000000..a88ee47cc563
--- /dev/null
+++ b/src/libs/TourUtils.ts
@@ -0,0 +1,14 @@
+import type {ValueOf} from 'type-fest';
+import CONST from '@src/CONST';
+import type {OnboardingPurposeType} from '@src/CONST';
+
+function getNavatticURL(environment: ValueOf, introSelected?: OnboardingPurposeType) {
+ const adminTourURL = environment === CONST.ENVIRONMENT.PRODUCTION ? CONST.NAVATTIC.ADMIN_TOUR_PRODUCTION : CONST.NAVATTIC.ADMIN_TOUR_STAGING;
+ const employeeTourURL = environment === CONST.ENVIRONMENT.PRODUCTION ? CONST.NAVATTIC.EMPLOYEE_TOUR_PRODUCTION : CONST.NAVATTIC.EMPLOYEE_TOUR_STAGING;
+ return introSelected === CONST.SELECTABLE_ONBOARDING_CHOICES.MANAGE_TEAM ? adminTourURL : employeeTourURL;
+}
+
+export {
+ // eslint-disable-next-line import/prefer-default-export
+ getNavatticURL,
+};
diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts
index 17d0e361e7d2..6d08a128a253 100644
--- a/src/libs/TransactionUtils/index.ts
+++ b/src/libs/TransactionUtils/index.ts
@@ -4,6 +4,8 @@ import lodashSet from 'lodash/set';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
+import {getPolicyCategoriesData} from '@libs/actions/Policy/Category';
+import {getPolicyTagsData} from '@libs/actions/Policy/Tag';
import type {TransactionMergeParams} from '@libs/API/parameters';
import {getCurrencyDecimals} from '@libs/CurrencyUtils';
import DateUtils from '@libs/DateUtils';
@@ -1034,7 +1036,7 @@ function removeSettledAndApprovedTransactions(transactionIDs: string[]) {
* 6. It returns the 'keep' and 'change' objects.
*/
-function compareDuplicateTransactionFields(transactionID: string): {keep: Partial; change: FieldsToChange} {
+function compareDuplicateTransactionFields(transactionID: string, reportID: string): {keep: Partial; change: FieldsToChange} {
const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`];
const duplicates = transactionViolations?.find((violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION)?.data?.duplicates ?? [];
const transactions = removeSettledAndApprovedTransactions([transactionID, ...duplicates]).map((item) => getTransaction(item));
@@ -1095,7 +1097,10 @@ function compareDuplicateTransactionFields(transactionID: string): {keep: Partia
const keys = fieldsToCompare[fieldName];
const firstTransaction = transactions.at(0);
const isFirstTransactionCommentEmptyObject = typeof firstTransaction?.comment === 'object' && firstTransaction?.comment?.comment === '';
+ const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? null;
+ const policy = PolicyUtils.getPolicy(report?.policyID);
+ const areAllFieldsEqualForKey = areAllFieldsEqual(transactions, (item) => keys.map((key) => item?.[key]).join('|'));
if (fieldName === 'description') {
const allCommentsAreEqual = areAllCommentsEqual(transactions, firstTransaction);
const allCommentsAreEmpty = isFirstTransactionCommentEmptyObject && transactions.every((item) => getDescription(item) === '');
@@ -1110,7 +1115,52 @@ function compareDuplicateTransactionFields(transactionID: string): {keep: Partia
} else {
processChanges(fieldName, transactions, keys);
}
- } else if (areAllFieldsEqual(transactions, (item) => keys.map((key) => item?.[key]).join('|'))) {
+ } else if (fieldName === 'taxCode') {
+ const differentValues = getDifferentValues(transactions, keys);
+ const validTaxes = differentValues?.filter((taxID) => {
+ const tax = PolicyUtils.getTaxByID(policy, (taxID as string) ?? '');
+ return tax?.name && !tax.isDisabled && tax.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
+ });
+
+ if (!areAllFieldsEqualForKey && validTaxes.length > 1) {
+ change[fieldName] = validTaxes;
+ } else if (areAllFieldsEqualForKey) {
+ keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]];
+ }
+ } else if (fieldName === 'category') {
+ const differentValues = getDifferentValues(transactions, keys);
+ const policyCategories = getPolicyCategoriesData(report?.policyID ?? '-1');
+ const availableCategories = Object.values(policyCategories)
+ .filter((category) => differentValues.includes(category.name) && category.enabled && category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)
+ .map((e) => e.name);
+
+ if (!areAllFieldsEqualForKey && policy?.areCategoriesEnabled && (availableCategories.length > 1 || (availableCategories.length === 1 && differentValues.includes('')))) {
+ change[fieldName] = [...availableCategories, ...(differentValues.includes('') ? [''] : [])];
+ } else if (areAllFieldsEqualForKey) {
+ keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]];
+ }
+ } else if (fieldName === 'tag') {
+ const policyTags = getPolicyTagsData(report?.policyID ?? '-1');
+ const isMultiLevelTags = PolicyUtils.isMultiLevelTags(policyTags);
+ if (isMultiLevelTags) {
+ if (areAllFieldsEqualForKey || !policy?.areTagsEnabled) {
+ keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]];
+ } else {
+ processChanges(fieldName, transactions, keys);
+ }
+ } else {
+ const differentValues = getDifferentValues(transactions, keys);
+ const policyTagsObj = Object.values(Object.values(policyTags).at(0)?.tags ?? {});
+ const availableTags = policyTagsObj
+ .filter((tag) => differentValues.includes(tag.name) && tag.enabled && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)
+ .map((e) => e.name);
+ if (!areAllFieldsEqualForKey && policy?.areTagsEnabled && (availableTags.length > 1 || (availableTags.length === 1 && differentValues.includes('')))) {
+ change[fieldName] = [...availableTags, ...(differentValues.includes('') ? [''] : [])];
+ } else if (areAllFieldsEqualForKey) {
+ keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]];
+ }
+ }
+ } else if (areAllFieldsEqualForKey) {
keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]];
} else {
processChanges(fieldName, transactions, keys);
diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts
index b7f754f9cac6..f2ce5113af81 100644
--- a/src/libs/TripReservationUtils.ts
+++ b/src/libs/TripReservationUtils.ts
@@ -1,5 +1,6 @@
import {Str} from 'expensify-common';
import type {Dispatch, SetStateAction} from 'react';
+import {NativeModules} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {LocaleContextProps} from '@components/LocaleContextProvider';
@@ -13,6 +14,7 @@ import type Transaction from '@src/types/onyx/Transaction';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type IconAsset from '@src/types/utils/IconAsset';
import * as Link from './actions/Link';
+import Log from './Log';
import Navigation from './Navigation/Navigation';
import * as PolicyUtils from './PolicyUtils';
@@ -40,6 +42,14 @@ Onyx.connect({
},
});
+let isSingleNewDotEntry: boolean | undefined;
+Onyx.connect({
+ key: ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY,
+ callback: (val) => {
+ isSingleNewDotEntry = val;
+ },
+});
+
function getTripReservationIcon(reservationType: ReservationType): IconAsset {
switch (reservationType) {
case CONST.RESERVATION_TYPE.FLIGHT:
@@ -91,8 +101,17 @@ function bookATrip(translate: LocaleContextProps['translate'], setCtaErrorMessag
if (ctaErrorMessage) {
setCtaErrorMessage('');
}
- Link.openTravelDotLink(activePolicyID)?.catch(() => {
- setCtaErrorMessage(translate('travel.errorMessage'));
- });
+ Link.openTravelDotLink(activePolicyID)
+ ?.then(() => {
+ if (!NativeModules.HybridAppModule || !isSingleNewDotEntry) {
+ return;
+ }
+
+ Log.info('[HybridApp] Returning to OldDot after opening TravelDot');
+ NativeModules.HybridAppModule.closeReactNativeApp(false, false);
+ })
+ ?.catch(() => {
+ setCtaErrorMessage(translate('travel.errorMessage'));
+ });
}
export {getTripReservationIcon, getReservationsFromTripTransactions, getTripEReceiptIcon, bookATrip};
diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts
index 80c765f0edf1..fbc1aefe30ce 100644
--- a/src/libs/ValidationUtils.ts
+++ b/src/libs/ValidationUtils.ts
@@ -20,16 +20,24 @@ import StringUtils from './StringUtils';
*/
function validateCardNumber(value: string): boolean {
let sum = 0;
- for (let i = 0; i < value.length; i++) {
- let intVal = parseInt(value.substr(i, 1), 10);
- if (i % 2 === 0) {
+ let shouldDouble = false;
+
+ // Loop through the card number from right to left
+ for (let i = value.length - 1; i >= 0; i--) {
+ let intVal = parseInt(value[i], 10);
+
+ // Double every second digit from the right
+ if (shouldDouble) {
intVal *= 2;
if (intVal > 9) {
- intVal = 1 + (intVal % 10);
+ intVal -= 9;
}
}
+
sum += intVal;
+ shouldDouble = !shouldDouble;
}
+
return sum % 10 === 0;
}
diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts
index a27d518fe727..e06382edffdc 100644
--- a/src/libs/WorkspacesSettingsUtils.ts
+++ b/src/libs/WorkspacesSettingsUtils.ts
@@ -2,17 +2,18 @@ import Onyx from 'react-native-onyx';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import type {LocaleContextProps} from '@components/LocaleContextProvider';
+import type {PolicySelector} from '@hooks/useReportIDs';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {Policy, ReimbursementAccount, Report, ReportAction, ReportActions, TransactionViolations} from '@src/types/onyx';
+import type {Beta, Policy, PriorityMode, ReimbursementAccount, Report, ReportAction, ReportActions, TransactionViolation, TransactionViolations} from '@src/types/onyx';
import type {PolicyConnectionSyncProgress, Unit} from '@src/types/onyx/Policy';
import {isConnectionInProgress} from './actions/connections';
import * as CurrencyUtils from './CurrencyUtils';
import {hasCustomUnitsError, hasEmployeeListError, hasPolicyError, hasSyncError, hasTaxRateError} from './PolicyUtils';
import * as ReportActionsUtils from './ReportActionsUtils';
-import * as ReportConnection from './ReportConnection';
import * as ReportUtils from './ReportUtils';
+import SidebarUtils from './SidebarUtils';
type CheckingMethod = () => boolean;
@@ -119,12 +120,23 @@ function hasWorkspaceSettingsRBR(policy: Policy) {
return Object.keys(reimbursementAccount?.errors ?? {}).length > 0 || hasPolicyError(policy) || hasCustomUnitsError(policy) || policyMemberError || taxRateError;
}
-function getChatTabBrickRoadReport(policyID?: string): OnyxEntry {
- const allReports = ReportConnection.getAllReports();
- if (!allReports) {
+function getChatTabBrickRoadReport(
+ policyID: string | undefined,
+ currentReportId: string | null,
+ reports: OnyxCollection,
+ betas: OnyxEntry,
+ policies: OnyxCollection,
+ priorityMode: OnyxEntry,
+ transactionViolations: OnyxCollection,
+ policyMemberAccountIDs: number[] = [],
+): OnyxEntry {
+ const reportIDs = SidebarUtils.getOrderedReportIDs(currentReportId, reports, betas, policies, priorityMode, transactionViolations, policyID, policyMemberAccountIDs);
+ if (!reportIDs.length) {
return undefined;
}
+ const allReports = reportIDs.map((reportID) => reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]);
+
// If policyID is undefined, then all reports are checked whether they contain any brick road
const policyReports = policyID ? Object.values(allReports).filter((report) => report?.policyID === policyID) : Object.values(allReports);
@@ -150,8 +162,17 @@ function getChatTabBrickRoadReport(policyID?: string): OnyxEntry {
return undefined;
}
-function getChatTabBrickRoad(policyID?: string): BrickRoad | undefined {
- const report = getChatTabBrickRoadReport(policyID);
+function getChatTabBrickRoad(
+ policyID: string | undefined,
+ currentReportId: string | null,
+ reports: OnyxCollection,
+ betas: OnyxEntry,
+ policies: OnyxCollection,
+ priorityMode: OnyxEntry,
+ transactionViolations: OnyxCollection,
+ policyMemberAccountIDs: number[] = [],
+): BrickRoad | undefined {
+ const report = getChatTabBrickRoadReport(policyID, currentReportId, reports, betas, policies, priorityMode, transactionViolations, policyMemberAccountIDs);
return report ? getBrickRoadForPolicy(report) : undefined;
}
diff --git a/src/libs/actions/CompanyCards.ts b/src/libs/actions/CompanyCards.ts
index a106fbeff510..2955a62f28c7 100644
--- a/src/libs/actions/CompanyCards.ts
+++ b/src/libs/actions/CompanyCards.ts
@@ -51,13 +51,37 @@ function clearAddNewCardFlow() {
});
}
-function addNewCompanyCardsFeed(policyID: string, feedType: string, feedDetails: string) {
+function addNewCompanyCardsFeed(policyID: string, feedType: CompanyCardFeed, feedDetails: string, lastSelectedFeed?: CompanyCardFeed) {
const authToken = NetworkStore.getAuthToken();
if (!authToken) {
return;
}
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`,
+ value: feedType,
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`,
+ value: lastSelectedFeed ?? null,
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`,
+ value: feedType,
+ },
+ ];
+
const parameters: RequestFeedSetupParams = {
policyID,
authToken,
@@ -65,7 +89,7 @@ function addNewCompanyCardsFeed(policyID: string, feedType: string, feedDetails:
feedDetails,
};
- API.write(WRITE_COMMANDS.REQUEST_FEED_SETUP, parameters);
+ API.write(WRITE_COMMANDS.REQUEST_FEED_SETUP, parameters, {optimisticData, failureData, successData});
}
function setWorkspaceCompanyCardFeedName(policyID: string, workspaceAccountID: number, bankName: string, userDefinedName: string) {
diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts
index 28f2019bb231..e294a57e6c5f 100644
--- a/src/libs/actions/Delegate.ts
+++ b/src/libs/actions/Delegate.ts
@@ -47,7 +47,11 @@ function connect(email: string) {
key: ONYXKEYS.ACCOUNT,
value: {
delegatedAccess: {
- delegators: delegatedAccess.delegators.map((delegator) => (delegator.email === email ? {...delegator, errorFields: {connect: null}} : delegator)),
+ errorFields: {
+ connect: {
+ [email]: null,
+ },
+ },
},
},
},
@@ -59,7 +63,11 @@ function connect(email: string) {
key: ONYXKEYS.ACCOUNT,
value: {
delegatedAccess: {
- delegators: delegatedAccess.delegators.map((delegator) => (delegator.email === email ? {...delegator, errorFields: undefined} : delegator)),
+ errorFields: {
+ connect: {
+ [email]: null,
+ },
+ },
},
},
},
@@ -71,9 +79,11 @@ function connect(email: string) {
key: ONYXKEYS.ACCOUNT,
value: {
delegatedAccess: {
- delegators: delegatedAccess.delegators.map((delegator) =>
- delegator.email === email ? {...delegator, errorFields: {connect: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError')}} : delegator,
- ),
+ errorFields: {
+ connect: {
+ [email]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError'),
+ },
+ },
},
},
},
@@ -112,7 +122,7 @@ function disconnect() {
key: ONYXKEYS.ACCOUNT,
value: {
delegatedAccess: {
- errorFields: {connect: null},
+ errorFields: {disconnect: null},
},
},
},
@@ -136,7 +146,7 @@ function disconnect() {
key: ONYXKEYS.ACCOUNT,
value: {
delegatedAccess: {
- errorFields: {connect: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError')},
+ errorFields: {disconnect: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError')},
},
},
},
@@ -190,7 +200,6 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) {
: {
...delegate,
isLoading: true,
- errorFields: {addDelegate: null},
pendingFields: {email: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, role: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD},
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
},
@@ -204,7 +213,6 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) {
email,
role,
isLoading: true,
- errorFields: {addDelegate: null},
pendingFields: {email: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, role: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD},
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
},
@@ -218,6 +226,11 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) {
value: {
delegatedAccess: {
delegates: optimisticDelegateData(),
+ errorFields: {
+ addDelegate: {
+ [email]: null,
+ },
+ },
},
isLoading: true,
},
@@ -233,7 +246,6 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) {
: {
...delegate,
isLoading: false,
- errorFields: {addDelegate: null},
pendingAction: null,
pendingFields: {email: null, role: null},
optimisticAccountID: undefined,
@@ -247,7 +259,6 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) {
{
email,
role,
- errorFields: {addDelegate: null},
isLoading: false,
pendingAction: null,
pendingFields: {email: null, role: null},
@@ -263,6 +274,11 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) {
value: {
delegatedAccess: {
delegates: successDelegateData(),
+ errorFields: {
+ addDelegate: {
+ [email]: null,
+ },
+ },
},
isLoading: false,
},
@@ -278,7 +294,6 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) {
: {
...delegate,
isLoading: false,
- errorFields: {addDelegate: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('contacts.genericFailureMessages.validateSecondaryLogin')},
},
) ?? []
);
@@ -289,9 +304,6 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) {
{
email,
role,
- errorFields: {
- addDelegate: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('contacts.genericFailureMessages.validateSecondaryLogin'),
- },
isLoading: false,
pendingFields: {email: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, role: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD},
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
@@ -328,11 +340,15 @@ function removeDelegate(email: string) {
key: ONYXKEYS.ACCOUNT,
value: {
delegatedAccess: {
+ errorFields: {
+ removeDelegate: {
+ [email]: null,
+ },
+ },
delegates: delegatedAccess.delegates?.map((delegate) =>
delegate.email === email
? {
...delegate,
- errorFields: {removeDelegate: null},
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
pendingFields: {email: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, role: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE},
}
@@ -361,13 +377,15 @@ function removeDelegate(email: string) {
key: ONYXKEYS.ACCOUNT,
value: {
delegatedAccess: {
+ errorFields: {
+ removeDelegate: {
+ [email]: null,
+ },
+ },
delegates: delegatedAccess.delegates?.map((delegate) =>
delegate.email === email
? {
...delegate,
- errorFields: {
- removeDelegate: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError'),
- },
pendingAction: null,
pendingFields: undefined,
}
@@ -383,14 +401,18 @@ function removeDelegate(email: string) {
API.write(WRITE_COMMANDS.REMOVE_DELEGATE, parameters, {optimisticData, successData, failureData});
}
-function clearAddDelegateErrors(email: string, fieldName: string) {
+function clearDelegateErrorsByField(email: string, fieldName: string) {
if (!delegatedAccess?.delegates) {
return;
}
Onyx.merge(ONYXKEYS.ACCOUNT, {
delegatedAccess: {
- delegates: delegatedAccess.delegates.map((delegate) => (delegate.email !== email ? delegate : {...delegate, errorFields: {...delegate.errorFields, [fieldName]: null}})),
+ errorFields: {
+ [fieldName]: {
+ [email]: null,
+ },
+ },
},
});
}
@@ -422,12 +444,16 @@ function updateDelegateRole(email: string, role: DelegateRole, validateCode: str
key: ONYXKEYS.ACCOUNT,
value: {
delegatedAccess: {
+ errorFields: {
+ updateDelegateRole: {
+ [email]: null,
+ },
+ },
delegates: delegatedAccess.delegates.map((delegate) =>
delegate.email === email
? {
...delegate,
role,
- errorFields: {updateDelegateRole: null},
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
pendingFields: {role: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
isLoading: true,
@@ -445,12 +471,16 @@ function updateDelegateRole(email: string, role: DelegateRole, validateCode: str
key: ONYXKEYS.ACCOUNT,
value: {
delegatedAccess: {
+ errorFields: {
+ updateDelegateRole: {
+ [email]: null,
+ },
+ },
delegates: delegatedAccess.delegates.map((delegate) =>
delegate.email === email
? {
...delegate,
role,
- errorFields: {updateDelegateRole: null},
pendingAction: null,
pendingFields: {role: null},
isLoading: false,
@@ -472,9 +502,6 @@ function updateDelegateRole(email: string, role: DelegateRole, validateCode: str
delegate.email === email
? {
...delegate,
- errorFields: {
- updateDelegateRole: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError'),
- },
isLoading: false,
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
pendingFields: {role: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
@@ -502,12 +529,16 @@ function updateDelegateRoleOptimistically(email: string, role: DelegateRole) {
key: ONYXKEYS.ACCOUNT,
value: {
delegatedAccess: {
+ errorFields: {
+ updateDelegateRole: {
+ [email]: null,
+ },
+ },
delegates: delegatedAccess.delegates.map((delegate) =>
delegate.email === email
? {
...delegate,
role,
- errorFields: {updateDelegateRole: null},
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
pendingFields: {role: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
}
@@ -568,7 +599,7 @@ export {
clearDelegatorErrors,
addDelegate,
requestValidationCode,
- clearAddDelegateErrors,
+ clearDelegateErrorsByField,
removePendingDelegate,
restoreDelegateSession,
isConnectedAsDelegate,
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 6893a66f2050..7a72df9f1d87 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -6571,7 +6571,15 @@ function getReportFromHoldRequestsOnyxData(
false,
newParentReportActionID,
)
- : ReportUtils.buildOptimisticIOUReport(recipient.accountID ?? 1, iouReport?.managerID ?? 1, holdTransactionAmount, chatReport.reportID, getCurrency(firstHoldTransaction), false);
+ : ReportUtils.buildOptimisticIOUReport(
+ iouReport?.ownerAccountID ?? -1,
+ iouReport?.managerID ?? -1,
+ holdTransactionAmount,
+ chatReport.reportID,
+ getCurrency(firstHoldTransaction),
+ false,
+ newParentReportActionID,
+ );
const optimisticExpenseReportPreview = ReportUtils.buildOptimisticReportPreview(
chatReport,
diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts
index 13fcea0df85d..4cda676d89e8 100644
--- a/src/libs/actions/Link.ts
+++ b/src/libs/actions/Link.ts
@@ -111,7 +111,7 @@ function openTravelDotLink(policyID: OnyxEntry, postLoginPath?: string)
policyID,
};
- return new Promise((_, reject) => {
+ return new Promise((resolve, reject) => {
const error = new Error('Failed to generate spotnana token.');
asyncOpenURL(
@@ -122,7 +122,9 @@ function openTravelDotLink(policyID: OnyxEntry, postLoginPath?: string)
reject(error);
throw error;
}
- return buildTravelDotURL(response.spotnanaToken, postLoginPath);
+ const travelURL = buildTravelDotURL(response.spotnanaToken, postLoginPath);
+ resolve(undefined);
+ return travelURL;
})
.catch(() => {
reject(error);
diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts
index 78b0f2dec9e2..41771ac5aa0e 100644
--- a/src/libs/actions/Policy/Category.ts
+++ b/src/libs/actions/Policy/Category.ts
@@ -1346,6 +1346,10 @@ function setPolicyCategoryTax(policyID: string, categoryName: string, taxID: str
API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, parameters, onyxData);
}
+function getPolicyCategoriesData(policyID: string) {
+ return allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`] ?? {};
+}
+
export {
getPolicyCategories,
openPolicyCategoriesPage,
@@ -1370,4 +1374,5 @@ export {
setPolicyCategoryTax,
importPolicyCategories,
downloadCategoriesCSV,
+ getPolicyCategoriesData,
};
diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts
index d5b2adc54de3..8fb551cdec81 100644
--- a/src/libs/actions/Policy/Member.ts
+++ b/src/libs/actions/Policy/Member.ts
@@ -159,7 +159,10 @@ function buildAnnounceRoomMembersOnyxData(policyID: string, accountIDs: number[]
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`,
value: {
- participants: announceReport?.participants ?? null,
+ participants: accountIDs.reduce((acc, curr) => {
+ Object.assign(acc, {[curr]: null});
+ return acc;
+ }, {}),
pendingChatMembers: announceReport?.pendingChatMembers ?? null,
},
});
diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts
index b419431bbbb3..ee898a73bcd0 100644
--- a/src/libs/actions/Policy/Policy.ts
+++ b/src/libs/actions/Policy/Policy.ts
@@ -1013,6 +1013,16 @@ function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: I
isLoadingInitialReportActions: false,
},
});
+
+ workspaceMembersChats.onyxFailureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReport.reportID}`,
+ value: {
+ errorFields: {
+ createChat: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'),
+ },
+ },
+ });
});
return workspaceMembersChats;
}
diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts
index 7708921f57b5..772e748ad4f2 100644
--- a/src/libs/actions/Policy/Tag.ts
+++ b/src/libs/actions/Policy/Tag.ts
@@ -1040,6 +1040,10 @@ function downloadTagsCSV(policyID: string, onDownloadFailed: () => void) {
fileDownload(ApiUtils.getCommandURL({command: WRITE_COMMANDS.EXPORT_TAGS_CSV}), fileName, '', false, formData, CONST.NETWORK.METHOD.POST, onDownloadFailed);
}
+function getPolicyTagsData(policyID: string) {
+ return allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {};
+}
+
export {
buildOptimisticPolicyRecentlyUsedTags,
setPolicyRequiresTag,
@@ -1058,6 +1062,7 @@ export {
setPolicyTagApprover,
importPolicyTags,
downloadTagsCSV,
+ getPolicyTagsData,
};
export type {NewCustomUnit};
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index b560dc244d89..17f0590e7557 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -58,6 +58,8 @@ import DateUtils from '@libs/DateUtils';
import {prepareDraftComment} from '@libs/DraftCommentUtils';
import * as EmojiUtils from '@libs/EmojiUtils';
import * as Environment from '@libs/Environment/Environment';
+import getEnvironment from '@libs/Environment/getEnvironment';
+import type EnvironmentType from '@libs/Environment/getEnvironment/types';
import * as ErrorUtils from '@libs/ErrorUtils';
import fileDownload from '@libs/fileDownload';
import HttpUtils from '@libs/HttpUtils';
@@ -84,6 +86,7 @@ import type {OptimisticAddCommentReportAction} from '@libs/ReportUtils';
import * as ReportUtils from '@libs/ReportUtils';
import {doesReportBelongToWorkspace} from '@libs/ReportUtils';
import shouldSkipDeepLinkNavigation from '@libs/shouldSkipDeepLinkNavigation';
+import {getNavatticURL} from '@libs/TourUtils';
import Visibility from '@libs/Visibility';
import CONFIG from '@src/CONFIG';
import type {OnboardingAccountingType, OnboardingCompanySizeType, OnboardingPurposeType} from '@src/CONST';
@@ -281,6 +284,11 @@ Onyx.connect({
let environmentURL: string;
Environment.getEnvironmentURL().then((url: string) => (environmentURL = url));
+let environment: EnvironmentType;
+getEnvironment().then((env) => {
+ environment = env;
+});
+
registerPaginationConfig({
initialCommand: WRITE_COMMANDS.OPEN_REPORT,
previousCommand: READ_COMMANDS.GET_OLDER_ACTIONS,
@@ -1935,6 +1943,8 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre
const fieldViolation = ReportUtils.getFieldViolation(reportViolations, reportField);
const recentlyUsedValues = allRecentlyUsedReportFields?.[fieldKey] ?? [];
+ const optimisticChangeFieldAction = ReportUtils.buildOptimisticChangeFieldAction(reportField, previousReportField);
+
const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -1948,6 +1958,13 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre
},
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
+ value: {
+ [optimisticChangeFieldAction.reportActionID]: optimisticChangeFieldAction,
+ },
+ },
];
if (fieldViolation) {
@@ -1988,6 +2005,15 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre
},
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
+ value: {
+ [optimisticChangeFieldAction.reportActionID]: {
+ errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericUpdateReportFieldFailureMessage'),
+ },
+ },
+ },
];
if (reportField.type === 'dropdown') {
@@ -2013,11 +2039,21 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre
},
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
+ value: {
+ [optimisticChangeFieldAction.reportActionID]: {
+ pendingAction: null,
+ },
+ },
+ },
];
const parameters = {
reportID,
reportFields: JSON.stringify({[fieldKey]: reportField}),
+ reportFieldsActionIDs: JSON.stringify({[fieldKey]: optimisticChangeFieldAction.reportActionID}),
};
API.write(WRITE_COMMANDS.SET_REPORT_FIELD, parameters, {optimisticData, failureData, successData});
@@ -3436,7 +3472,6 @@ function completeOnboarding(
reportComment: videoComment.commentText,
};
}
-
const tasksData = data.tasks
.filter((task) => {
if (['setupCategories', 'setupTags'].includes(task.type) && userReportedIntegration) {
@@ -3456,6 +3491,7 @@ function completeOnboarding(
workspaceCategoriesLink: `${environmentURL}/${ROUTES.WORKSPACE_CATEGORIES.getRoute(onboardingPolicyID ?? '-1')}`,
workspaceMembersLink: `${environmentURL}/${ROUTES.WORKSPACE_MEMBERS.getRoute(onboardingPolicyID ?? '-1')}`,
workspaceMoreFeaturesLink: `${environmentURL}/${ROUTES.WORKSPACE_MORE_FEATURES.getRoute(onboardingPolicyID ?? '-1')}`,
+ navatticURL: getNavatticURL(environment, engagementChoice),
integrationName,
workspaceAccountingLink: `${environmentURL}/${ROUTES.POLICY_ACCOUNTING.getRoute(onboardingPolicyID ?? '-1')}`,
workspaceSettingsLink: `${environmentURL}/${ROUTES.WORKSPACE_INITIAL.getRoute(onboardingPolicyID ?? '01')}`,
@@ -3510,7 +3546,7 @@ function completeOnboarding(
parentReportActionID: taskReportAction.reportAction.reportActionID,
assigneeChatReportID: '',
createdTaskReportActionID: taskCreatedAction.reportActionID,
- completedTaskReportActionID: completedTaskReportAction?.reportActionID ?? '-1',
+ completedTaskReportActionID: completedTaskReportAction?.reportActionID ?? undefined,
title: currentTask.reportName ?? '',
description: taskDescription ?? '',
}));
diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts
index 37488442525d..d75c5064f93a 100644
--- a/src/libs/actions/Session/index.ts
+++ b/src/libs/actions/Session/index.ts
@@ -483,28 +483,43 @@ function signUpUser() {
function signInAfterTransitionFromOldDot(transitionURL: string) {
const [route, queryParams] = transitionURL.split('?');
- const {email, authToken, encryptedAuthToken, accountID, autoGeneratedLogin, autoGeneratedPassword, clearOnyxOnStart, completedHybridAppOnboarding} = Object.fromEntries(
- queryParams.split('&').map((param) => {
- const [key, value] = param.split('=');
- return [key, value];
- }),
- );
-
- const setSessionDataAndOpenApp = () => {
- Onyx.multiSet({
- [ONYXKEYS.SESSION]: {email, authToken, encryptedAuthToken: decodeURIComponent(encryptedAuthToken), accountID: Number(accountID)},
- [ONYXKEYS.CREDENTIALS]: {autoGeneratedLogin, autoGeneratedPassword},
- [ONYXKEYS.NVP_TRYNEWDOT]: {classicRedirect: {completedHybridAppOnboarding: completedHybridAppOnboarding === 'true'}},
- }).then(App.openApp);
+ const {email, authToken, encryptedAuthToken, accountID, autoGeneratedLogin, autoGeneratedPassword, clearOnyxOnStart, completedHybridAppOnboarding, isSingleNewDotEntry, primaryLogin} =
+ Object.fromEntries(
+ queryParams.split('&').map((param) => {
+ const [key, value] = param.split('=');
+ return [key, value];
+ }),
+ );
+
+ const clearOnyxForNewAccount = () => {
+ if (clearOnyxOnStart !== 'true') {
+ return Promise.resolve();
+ }
+
+ return Onyx.clear(KEYS_TO_PRESERVE);
};
- if (clearOnyxOnStart === 'true') {
- Onyx.clear(KEYS_TO_PRESERVE).then(setSessionDataAndOpenApp);
- } else {
- setSessionDataAndOpenApp();
- }
+ const setSessionDataAndOpenApp = new Promise((resolve) => {
+ clearOnyxForNewAccount()
+ .then(() =>
+ Onyx.multiSet({
+ [ONYXKEYS.SESSION]: {email, authToken, encryptedAuthToken: decodeURIComponent(encryptedAuthToken), accountID: Number(accountID)},
+ [ONYXKEYS.ACCOUNT]: {primaryLogin},
+ [ONYXKEYS.CREDENTIALS]: {autoGeneratedLogin, autoGeneratedPassword},
+ [ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY]: isSingleNewDotEntry === 'true',
+ [ONYXKEYS.NVP_TRYNEWDOT]: {classicRedirect: {completedHybridAppOnboarding: completedHybridAppOnboarding === 'true'}},
+ }),
+ )
+ .then(App.openApp)
+ .catch((error) => {
+ Log.hmmm('[HybridApp] Initialization of HybridApp has failed. Forcing transition', {error});
+ })
+ .finally(() => {
+ resolve(`${route}?singleNewDotEntry=${isSingleNewDotEntry}` as Route);
+ });
+ });
- return route as Route;
+ return setSessionDataAndOpenApp;
}
/**
diff --git a/src/libs/actions/Welcome/index.ts b/src/libs/actions/Welcome/index.ts
index d504c5550331..19a570ab610f 100644
--- a/src/libs/actions/Welcome/index.ts
+++ b/src/libs/actions/Welcome/index.ts
@@ -2,7 +2,7 @@ import {NativeModules} from 'react-native';
import type {OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import * as API from '@libs/API';
-import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types';
+import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
import Log from '@libs/Log';
import type {OnboardingCompanySizeType, OnboardingPurposeType} from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -183,6 +183,20 @@ function resetAllChecks() {
OnboardingFlow.clearInitialPath();
}
+function setSelfTourViewed() {
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.NVP_ONBOARDING,
+ value: {
+ selfTourViewed: true,
+ },
+ },
+ ];
+
+ API.write(WRITE_COMMANDS.SELF_TOUR_VIEWED, null, {optimisticData});
+}
+
export {
onServerDataReady,
isOnboardingFlowCompleted,
@@ -195,4 +209,5 @@ export {
completeHybridAppOnboarding,
setOnboardingErrorMessage,
setOnboardingCompanySize,
+ setSelfTourViewed,
};
diff --git a/src/libs/actions/connections/index.ts b/src/libs/actions/connections/index.ts
index e4ef3e4ed047..b2cb6ffffe94 100644
--- a/src/libs/actions/connections/index.ts
+++ b/src/libs/actions/connections/index.ts
@@ -327,6 +327,21 @@ function isConnectionUnverified(policy: OnyxEntry, connectionName: Polic
return !(policy?.connections?.[connectionName]?.lastSync?.isConnected ?? true);
}
+function setConnectionError(policyID: string, connectionName: PolicyConnectionName, errorMessage?: string) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ connections: {
+ [connectionName]: {
+ lastSync: {
+ isSuccessful: false,
+ isConnected: false,
+ errorDate: new Date().toISOString(),
+ errorMessage,
+ },
+ },
+ },
+ });
+}
+
function copyExistingPolicyConnection(connectedPolicyID: string, targetPolicyID: string, connectionName: ConnectionName) {
let stageInProgress;
switch (connectionName) {
@@ -389,4 +404,5 @@ export {
isConnectionUnverified,
isConnectionInProgress,
hasSynchronizationErrorMessage,
+ setConnectionError,
};
diff --git a/src/libs/getPlatform/index.ts b/src/libs/getPlatform/index.ts
index 5f5b45ac6e7d..aedb4610673e 100644
--- a/src/libs/getPlatform/index.ts
+++ b/src/libs/getPlatform/index.ts
@@ -1,6 +1,10 @@
+import * as Browser from '@libs/Browser';
import CONST from '@src/CONST';
import type Platform from './types';
-export default function getPlatform(): Platform {
+export default function getPlatform(shouldMobileWebBeDistinctFromWeb = false): Platform {
+ if (shouldMobileWebBeDistinctFromWeb && Browser.isMobile()) {
+ return CONST.PLATFORM.MOBILEWEB;
+ }
return CONST.PLATFORM.WEB;
}
diff --git a/src/libs/onboardingSelectors.ts b/src/libs/onboardingSelectors.ts
index efa67d2aed48..c1e7d0ed0778 100644
--- a/src/libs/onboardingSelectors.ts
+++ b/src/libs/onboardingSelectors.ts
@@ -35,4 +35,19 @@ function hasCompletedHybridAppOnboardingFlowSelector(tryNewDotData: OnyxValue): boolean | undefined {
+ if (Array.isArray(onboarding)) {
+ return false;
+ }
+
+ return onboarding?.selfTourViewed;
+}
+
+export {hasCompletedGuidedSetupFlowSelector, hasCompletedHybridAppOnboardingFlowSelector, hasSeenTourSelector};
diff --git a/src/pages/Debug/DebugDetails.tsx b/src/pages/Debug/DebugDetails.tsx
index c64e8e3a9331..6ee14660dbe9 100644
--- a/src/pages/Debug/DebugDetails.tsx
+++ b/src/pages/Debug/DebugDetails.tsx
@@ -14,7 +14,6 @@ import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import type {ObjectType, OnyxDataType} from '@libs/DebugUtils';
import DebugUtils from '@libs/DebugUtils';
-import Navigation from '@libs/Navigation/Navigation';
import Debug from '@userActions/Debug';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -236,7 +235,6 @@ function DebugDetails({data, children, onSave, onDelete, validate}: DebugDetails
text={translate('common.delete')}
onPress={() => {
onDelete();
- Navigation.goBack();
}}
/>
diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx
index 675ff28b3be3..5fa26cbf1835 100644
--- a/src/pages/Debug/Report/DebugReportPage.tsx
+++ b/src/pages/Debug/Report/DebugReportPage.tsx
@@ -4,6 +4,7 @@ import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import * as Expensicons from '@components/Icon/Expensicons';
import ScreenWrapper from '@components/ScreenWrapper';
import TabSelector from '@components/TabSelector/TabSelector';
import Text from '@components/Text';
@@ -11,6 +12,7 @@ import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import {navigateToConciergeChatAndDeleteReport} from '@libs/actions/Report';
import DebugUtils from '@libs/DebugUtils';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import Navigation from '@libs/Navigation/Navigation';
@@ -19,6 +21,7 @@ import type {DebugParamList} from '@libs/Navigation/types';
import * as ReportUtils from '@libs/ReportUtils';
import DebugDetails from '@pages/Debug/DebugDetails';
import DebugJSON from '@pages/Debug/DebugJSON';
+import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
import Debug from '@userActions/Debug';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -112,6 +115,10 @@ function DebugReportPage({
];
}, [parentReportAction, report, reportActions, reportID, transactionViolations, translate]);
+ if (!report) {
+ return ;
+ }
+
return (
{
Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null);
+ navigateToConciergeChatAndDeleteReport(reportID, true, true);
}}
validate={DebugUtils.validateReportDraftProperty}
>
@@ -157,6 +165,13 @@ function DebugReportPage({
)}
))}
+
)}
diff --git a/src/pages/Debug/ReportAction/DebugReportActionPage.tsx b/src/pages/Debug/ReportAction/DebugReportActionPage.tsx
index 89377310571e..68743278d871 100644
--- a/src/pages/Debug/ReportAction/DebugReportActionPage.tsx
+++ b/src/pages/Debug/ReportAction/DebugReportActionPage.tsx
@@ -60,6 +60,7 @@ function DebugReportActionPage({
}}
onDelete={() => {
Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: null});
+ Navigation.goBack();
}}
validate={DebugUtils.validateReportActionDraftProperty}
/>
diff --git a/src/pages/ErrorPage/SessionExpiredPage.tsx b/src/pages/ErrorPage/SessionExpiredPage.tsx
index a6b555e29077..5ccf70c40ab6 100644
--- a/src/pages/ErrorPage/SessionExpiredPage.tsx
+++ b/src/pages/ErrorPage/SessionExpiredPage.tsx
@@ -34,7 +34,7 @@ function SessionExpiredPage() {
onPress={() => {
if (!NativeModules.HybridAppModule) {
Session.clearSignInData();
- Navigation.navigate();
+ Navigation.goBack();
return;
}
NativeModules.HybridAppModule.closeReactNativeApp(true, false);
diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx
index 13fbbc35b5da..c28290e353e7 100755
--- a/src/pages/NewChatPage.tsx
+++ b/src/pages/NewChatPage.tsx
@@ -34,13 +34,9 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft';
-type NewChatPageProps = {
- isGroupChat?: boolean;
-};
+const excludedGroupEmails: string[] = CONST.EXPENSIFY_EMAILS.filter((value) => value !== CONST.EMAIL.CONCIERGE);
-const excludedGroupEmails = CONST.EXPENSIFY_EMAILS.filter((value) => value !== CONST.EMAIL.CONCIERGE);
-
-function useOptions({isGroupChat}: NewChatPageProps) {
+function useOptions() {
const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');
const [selectedOptions, setSelectedOptions] = useState>([]);
const [betas] = useOnyx(ONYXKEYS.BETAS);
@@ -57,22 +53,20 @@ function useOptions({isGroupChat}: NewChatPageProps) {
personalDetails: listOptions.personalDetails ?? [],
betas: betas ?? [],
selectedOptions,
- excludeLogins: isGroupChat ? excludedGroupEmails : [],
maxRecentReportsToShow: 0,
includeSelfDM: true,
});
return filteredOptions;
- }, [betas, isGroupChat, listOptions.personalDetails, listOptions.reports, selectedOptions]);
+ }, [betas, listOptions.personalDetails, listOptions.reports, selectedOptions]);
const options = useMemo(() => {
const filteredOptions = OptionsListUtils.filterOptions(defaultOptions, debouncedSearchTerm, {
selectedOptions,
- excludeLogins: isGroupChat ? excludedGroupEmails : [],
maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW,
});
return filteredOptions;
- }, [debouncedSearchTerm, defaultOptions, isGroupChat, selectedOptions]);
+ }, [debouncedSearchTerm, defaultOptions, selectedOptions]);
const cleanSearchTerm = useMemo(() => debouncedSearchTerm.trim().toLowerCase(), [debouncedSearchTerm]);
const headerMessage = useMemo(() => {
return OptionsListUtils.getHeaderMessage(
@@ -129,7 +123,7 @@ function useOptions({isGroupChat}: NewChatPageProps) {
};
}
-function NewChatPage({isGroupChat}: NewChatPageProps) {
+function NewChatPage() {
const {translate} = useLocalize();
const {isOffline} = useNetwork();
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to show offline indicator on small screen only
@@ -142,9 +136,7 @@ function NewChatPage({isGroupChat}: NewChatPageProps) {
const selectionListRef = useRef(null);
const {headerMessage, searchTerm, debouncedSearchTerm, setSearchTerm, selectedOptions, setSelectedOptions, recentReports, personalDetails, userToInvite, areOptionsInitialized} =
- useOptions({
- isGroupChat,
- });
+ useOptions();
const [sections, firstKeyForList] = useMemo(() => {
const sectionsList: OptionsListUtils.CategorySection[] = [];
@@ -217,7 +209,7 @@ function NewChatPage({isGroupChat}: NewChatPageProps) {
const itemRightSideComponent = useCallback(
(item: ListItem & OptionsListUtils.Option, isFocused?: boolean) => {
- if (!!item.isSelfDM || (item.accountID && CONST.NON_ADDABLE_ACCOUNT_IDS.includes(item.accountID))) {
+ if (!!item.isSelfDM || (item.login && excludedGroupEmails.includes(item.login))) {
return null;
}
/**
diff --git a/src/pages/ReimbursementAccount/BankAccountStep.tsx b/src/pages/ReimbursementAccount/BankAccountStep.tsx
index 4244a94dbdf2..f6fab3056cf2 100644
--- a/src/pages/ReimbursementAccount/BankAccountStep.tsx
+++ b/src/pages/ReimbursementAccount/BankAccountStep.tsx
@@ -181,7 +181,7 @@ function BankAccountStep({
return;
}
if (!account?.validated) {
- selectedSubStep.current = CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL;
+ selectedSubStep.current = CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID;
toggleValidateCodeActionModal?.(true);
return;
}
@@ -198,7 +198,7 @@ function BankAccountStep({
title={translate('bankAccount.connectManually')}
onPress={() => {
if (!account?.validated) {
- selectedSubStep.current = CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID;
+ selectedSubStep.current = CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL;
toggleValidateCodeActionModal?.(true);
return;
}
diff --git a/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx b/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx
index 0a94e22cde1f..7f45de42122b 100644
--- a/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx
+++ b/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx
@@ -1,3 +1,4 @@
+import {Str} from 'expensify-common';
import lodashPick from 'lodash/pick';
import React, {useCallback, useMemo} from 'react';
import {useOnyx} from 'react-native-onyx';
@@ -60,6 +61,7 @@ function BusinessInfo({onBackButtonPress}: BusinessInfoProps) {
const submit = useCallback(
(isConfirmPage: boolean) => {
+ const companyWebsite = Str.sanitizeURL(values.website, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME);
BankAccounts.updateCompanyInformationForBankAccount(
Number(reimbursementAccount?.achData?.bankAccountID ?? '-1'),
{
@@ -67,7 +69,7 @@ function BusinessInfo({onBackButtonPress}: BusinessInfoProps) {
...getBankAccountFields(['routingNumber', 'accountNumber', 'bankName', 'plaidAccountID', 'plaidAccessToken', 'isSavings']),
companyTaxID: values.companyTaxID?.replace(CONST.REGEX.NON_NUMERIC, ''),
companyPhone: parsePhoneNumber(values.companyPhone ?? '', {regionCode: CONST.COUNTRY.US}).number?.significant,
- website: ValidationUtils.isValidWebsite(values.website) ? values.website : undefined,
+ website: ValidationUtils.isValidWebsite(companyWebsite) ? companyWebsite : undefined,
},
policyID,
isConfirmPage,
diff --git a/src/pages/ReimbursementAccount/BusinessInfo/substeps/WebsiteBusiness.tsx b/src/pages/ReimbursementAccount/BusinessInfo/substeps/WebsiteBusiness.tsx
index 3d8fcd944f4f..5ef5e7c75c8d 100644
--- a/src/pages/ReimbursementAccount/BusinessInfo/substeps/WebsiteBusiness.tsx
+++ b/src/pages/ReimbursementAccount/BusinessInfo/substeps/WebsiteBusiness.tsx
@@ -1,3 +1,4 @@
+import {Str} from 'expensify-common';
import React, {useCallback, useMemo} from 'react';
import {useOnyx} from 'react-native-onyx';
import FormProvider from '@components/Form/FormProvider';
@@ -33,7 +34,7 @@ function WebsiteBusiness({onNext, isEditing}: SubStepProps) {
(values: FormOnyxValues): FormInputErrors => {
const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS);
- if (values.website && !ValidationUtils.isValidWebsite(values.website)) {
+ if (values.website && !ValidationUtils.isValidWebsite(Str.sanitizeURL(values.website, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME))) {
errors.website = translate('bankAccount.error.website');
}
@@ -44,7 +45,8 @@ function WebsiteBusiness({onNext, isEditing}: SubStepProps) {
const handleSubmit = useReimbursementAccountStepFormSubmit({
fieldIds: STEP_FIELDS,
onNext: (values) => {
- BankAccounts.addBusinessWebsiteForDraft((values as {website: string})?.website);
+ const website = Str.sanitizeURL((values as {website: string})?.website, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME);
+ BankAccounts.addBusinessWebsiteForDraft(website);
onNext();
},
shouldSaveDraft: true,
diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx
index c2b3bd60cb99..29173817f5ac 100644
--- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx
+++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx
@@ -170,16 +170,6 @@ function ReimbursementAccountPage({route, policy}: ReimbursementAccountPageProps
const currentStep = !isPreviousPolicy ? CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT : achData?.currentStep || CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT;
const [nonUSDBankAccountStep, setNonUSDBankAccountStep] = useState(CONST.NON_USD_BANK_ACCOUNT.STEP.COUNTRY);
- /**
- When this page is first opened, `reimbursementAccount` prop might not yet be fully loaded from Onyx.
- Calculating `shouldShowContinueSetupButton` immediately on initial render doesn't make sense as
- it relies on incomplete data. Thus, we should wait to calculate it until we have received
- the full `reimbursementAccount` data from the server. This logic is handled within the useEffect hook,
- which acts similarly to `componentDidUpdate` when the `reimbursementAccount` dependency changes.
- */
- const [hasACHDataBeenLoaded, setHasACHDataBeenLoaded] = useState(reimbursementAccount !== CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA && isPreviousPolicy);
- const [shouldShowContinueSetupButton, setShouldShowContinueSetupButton] = useState(getShouldShowContinueSetupButtonInitialValue());
-
function getBankAccountFields(fieldNames: InputID[]): Partial {
return {
...lodashPick(reimbursementAccount?.achData, ...fieldNames),
@@ -205,6 +195,16 @@ function ReimbursementAccountPage({route, policy}: ReimbursementAccountPageProps
return achData?.state === BankAccount.STATE.PENDING || [CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT, ''].includes(getStepToOpenFromRouteParams(route));
}
+ /**
+ When this page is first opened, `reimbursementAccount` prop might not yet be fully loaded from Onyx.
+ Calculating `shouldShowContinueSetupButton` immediately on initial render doesn't make sense as
+ it relies on incomplete data. Thus, we should wait to calculate it until we have received
+ the full `reimbursementAccount` data from the server. This logic is handled within the useEffect hook,
+ which acts similarly to `componentDidUpdate` when the `reimbursementAccount` dependency changes.
+ */
+ const [hasACHDataBeenLoaded, setHasACHDataBeenLoaded] = useState(reimbursementAccount !== CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA && isPreviousPolicy);
+ const [shouldShowContinueSetupButton, setShouldShowContinueSetupButton] = useState(getShouldShowContinueSetupButtonInitialValue());
+
const handleNextNonUSDBankAccountStep = () => {
switch (nonUSDBankAccountStep) {
case CONST.NON_USD_BANK_ACCOUNT.STEP.COUNTRY:
diff --git a/src/pages/ReimbursementAccount/utils/getInitialSubstepForBusinessInfo.ts b/src/pages/ReimbursementAccount/utils/getInitialSubstepForBusinessInfo.ts
index 099eacaa690f..7173455afd05 100644
--- a/src/pages/ReimbursementAccount/utils/getInitialSubstepForBusinessInfo.ts
+++ b/src/pages/ReimbursementAccount/utils/getInitialSubstepForBusinessInfo.ts
@@ -1,4 +1,6 @@
+import {Str} from 'expensify-common';
import * as ValidationUtils from '@libs/ValidationUtils';
+import CONST from '@src/CONST';
import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
import type {CompanyStepProps} from '@src/types/form/ReimbursementAccountForm';
@@ -16,7 +18,7 @@ function getInitialSubstepForBusinessInfo(data: CompanyStepProps): number {
return 1;
}
- if (!ValidationUtils.isValidWebsite(data[businessInfoStepKeys.COMPANY_WEBSITE])) {
+ if (!ValidationUtils.isValidWebsite(Str.sanitizeURL(data[businessInfoStepKeys.COMPANY_WEBSITE], CONST.COMPANY_WEBSITE_DEFAULT_SCHEME))) {
return 2;
}
diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx
index 9ec3691f49a8..9e438f0549e2 100644
--- a/src/pages/ReportDetailsPage.tsx
+++ b/src/pages/ReportDetailsPage.tsx
@@ -298,6 +298,10 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) {
const shouldShowCancelPaymentButton = caseID === CASES.MONEY_REPORT && isPayer && isSettled && ReportUtils.isExpenseReport(moneyRequestReport);
const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport?.chatReportID ?? '-1'}`);
+ const iouTransactionID = ReportActionsUtils.isMoneyRequestAction(requestParentReportAction)
+ ? ReportActionsUtils.getOriginalMessage(requestParentReportAction)?.IOUTransactionID ?? ''
+ : '';
+
const cancelPayment = useCallback(() => {
if (!chatReport) {
return;
@@ -371,6 +375,42 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) {
});
}
+ if (isTrackExpenseReport) {
+ const actionReportID = ReportUtils.getOriginalReportID(report.reportID, parentReportAction) ?? '0';
+ const whisperAction = ReportActionsUtils.getTrackExpenseActionableWhisper(iouTransactionID, moneyRequestReport?.reportID ?? '0');
+ const actionableWhisperReportActionID = whisperAction?.reportActionID ?? '0';
+ items.push({
+ key: CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS,
+ translationKey: 'actionableMentionTrackExpense.submit',
+ icon: Expensicons.Send,
+ isAnonymousAction: false,
+ shouldShowRightIcon: true,
+ action: () => {
+ ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(iouTransactionID, actionReportID, CONST.IOU.ACTION.SUBMIT, actionableWhisperReportActionID);
+ },
+ });
+ items.push({
+ key: CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS,
+ translationKey: 'actionableMentionTrackExpense.categorize',
+ icon: Expensicons.Folder,
+ isAnonymousAction: false,
+ shouldShowRightIcon: true,
+ action: () => {
+ ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(iouTransactionID, actionReportID, CONST.IOU.ACTION.CATEGORIZE, actionableWhisperReportActionID);
+ },
+ });
+ items.push({
+ key: CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS,
+ translationKey: 'actionableMentionTrackExpense.share',
+ icon: Expensicons.UserPlus,
+ isAnonymousAction: false,
+ shouldShowRightIcon: true,
+ action: () => {
+ ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(iouTransactionID, actionReportID, CONST.IOU.ACTION.SHARE, actionableWhisperReportActionID);
+ },
+ });
+ }
+
// Prevent displaying private notes option for threads and task reports
if (!isChatThread && !isMoneyRequestReport && !isInvoiceReport && !isTaskReport) {
items.push({
@@ -517,6 +557,10 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) {
isExpenseReport,
backTo,
canActionTask,
+ isTrackExpenseReport,
+ iouTransactionID,
+ parentReportAction,
+ moneyRequestReport?.reportID,
]);
const displayNamesWithTooltips = useMemo(() => {
@@ -590,10 +634,6 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) {
);
}, [report, icons, isMoneyRequestReport, isInvoiceReport, isGroupChat, isThread, styles]);
- const iouTransactionID = ReportActionsUtils.isMoneyRequestAction(requestParentReportAction)
- ? ReportActionsUtils.getOriginalMessage(requestParentReportAction)?.IOUTransactionID ?? ''
- : '';
-
const canHoldUnholdReportAction = ReportUtils.canHoldUnholdReportAction(moneyRequestAction);
const shouldShowHoldAction =
caseID !== CASES.DEFAULT &&
diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx
index ce4daabc983a..58fd159b5bed 100644
--- a/src/pages/Search/AdvancedSearchFilters.tsx
+++ b/src/pages/Search/AdvancedSearchFilters.tsx
@@ -9,7 +9,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import {usePersonalDetails} from '@components/OnyxProvider';
import ScrollView from '@components/ScrollView';
-import type {AdvancedFiltersKeys} from '@components/Search/types';
+import type {SearchFilterKey} from '@components/Search/types';
import useLocalize from '@hooks/useLocalize';
import useSingleExecution from '@hooks/useSingleExecution';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -150,8 +150,8 @@ const sortOptionsWithEmptyValue = (a: string, b: string) => {
return localeCompare(a, b);
};
-function getFilterDisplayTitle(filters: Partial, fieldName: AdvancedFiltersKeys, translate: LocaleContextProps['translate']) {
- if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE) {
+function getFilterDisplayTitle(filters: Partial, filterKey: SearchFilterKey, translate: LocaleContextProps['translate']) {
+ if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE) {
// the value of date filter is a combination of dateBefore + dateAfter values
const {dateAfter, dateBefore} = filters;
let dateValue = '';
@@ -168,7 +168,7 @@ function getFilterDisplayTitle(filters: Partial, fiel
return dateValue;
}
- if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) {
+ if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) {
const {lessThan, greaterThan} = filters;
if (lessThan && greaterThan) {
return translate('search.filters.amount.between', {
@@ -186,32 +186,32 @@ function getFilterDisplayTitle(filters: Partial, fiel
return;
}
- if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY && filters[fieldName]) {
- const filterArray = filters[fieldName] ?? [];
+ if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY && filters[filterKey]) {
+ const filterArray = filters[filterKey] ?? [];
return filterArray.sort(localeCompare).join(', ');
}
- if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY && filters[fieldName]) {
- const filterArray = filters[fieldName] ?? [];
+ if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY && filters[filterKey]) {
+ const filterArray = filters[filterKey] ?? [];
return filterArray
.sort(sortOptionsWithEmptyValue)
.map((value) => (value === CONST.SEARCH.EMPTY_VALUE ? translate('search.noCategory') : value))
.join(', ');
}
- if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG && filters[fieldName]) {
- const filterArray = filters[fieldName] ?? [];
+ if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG && filters[filterKey]) {
+ const filterArray = filters[filterKey] ?? [];
return filterArray
.sort(sortOptionsWithEmptyValue)
.map((value) => (value === CONST.SEARCH.EMPTY_VALUE ? translate('search.noTag') : value))
.join(', ');
}
- if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION) {
- return filters[fieldName];
+ if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION) {
+ return filters[filterKey];
}
- const filterValue = filters[fieldName];
+ const filterValue = filters[filterKey];
return Array.isArray(filterValue) ? filterValue.join(', ') : filterValue;
}
diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx
index 8e61978c169e..19d00a06771e 100644
--- a/src/pages/Search/EmptySearchView.tsx
+++ b/src/pages/Search/EmptySearchView.tsx
@@ -10,12 +10,14 @@ import MenuItem from '@components/MenuItem';
import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
+import useEnvironment from '@hooks/useEnvironment';
import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import interceptAnonymousUser from '@libs/interceptAnonymousUser';
import * as ReportUtils from '@libs/ReportUtils';
+import {getNavatticURL} from '@libs/TourUtils';
import * as TripsResevationUtils from '@libs/TripReservationUtils';
import variables from '@styles/variables';
import * as IOU from '@userActions/IOU';
@@ -93,7 +95,8 @@ function EmptySearchView({type}: EmptySearchViewProps) {
}, [styles, translate, ctaErrorMessage]);
const [onboardingPurpose] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: (introSelected) => introSelected?.choice});
- const navatticLink = onboardingPurpose === CONST.SELECTABLE_ONBOARDING_CHOICES.MANAGE_TEAM ? CONST.NAVATTIC.ADMIN_TOUR : CONST.NAVATTIC.EMPLOYEE_TOUR;
+ const {environment} = useEnvironment();
+ const navatticURL = getNavatticURL(environment, onboardingPurpose);
const content = useMemo(() => {
switch (type) {
@@ -120,7 +123,7 @@ function EmptySearchView({type}: EmptySearchViewProps) {
title: translate('search.searchResults.emptyExpenseResults.title'),
subtitle: translate('search.searchResults.emptyExpenseResults.subtitle'),
buttons: [
- {buttonText: translate('emptySearchView.takeATour'), buttonAction: () => Link.openExternalLink(navatticLink)},
+ {buttonText: translate('emptySearchView.takeATour'), buttonAction: () => Link.openExternalLink(navatticURL)},
{
buttonText: translate('iou.createExpense'),
buttonAction: () => interceptAnonymousUser(() => IOU.startMoneyRequest(CONST.IOU.TYPE.CREATE, ReportUtils.generateReportID())),
@@ -140,7 +143,7 @@ function EmptySearchView({type}: EmptySearchViewProps) {
headerContentStyles: styles.emptyStateFolderWebStyles,
};
}
- }, [type, StyleUtils, translate, theme, styles, subtitleComponent, ctaErrorMessage, navatticLink]);
+ }, [type, StyleUtils, translate, theme, styles, subtitleComponent, ctaErrorMessage, navatticURL]);
return (
{
SearchActions.clearAdvancedFilters();
+ Navigation.dismissModal();
Navigation.navigate(
ROUTES.SEARCH_CENTRAL_PANE.getRoute({
query: q,
diff --git a/src/pages/TransactionDuplicate/Confirmation.tsx b/src/pages/TransactionDuplicate/Confirmation.tsx
index 87748a9697a7..90497a05a4fc 100644
--- a/src/pages/TransactionDuplicate/Confirmation.tsx
+++ b/src/pages/TransactionDuplicate/Confirmation.tsx
@@ -40,7 +40,7 @@ function Confirmation() {
const [reviewDuplicates, reviewDuplicatesResult] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES);
const transaction = useMemo(() => TransactionUtils.buildNewTransactionAfterReviewingDuplicates(reviewDuplicates), [reviewDuplicates]);
const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? '');
- const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID);
+ const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1');
const {goBack} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'confirmation', route.params.threadReportID, route.params.backTo);
const [report, reportResult] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`);
const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`);
diff --git a/src/pages/TransactionDuplicate/ReviewBillable.tsx b/src/pages/TransactionDuplicate/ReviewBillable.tsx
index 666741daf303..166c61209a42 100644
--- a/src/pages/TransactionDuplicate/ReviewBillable.tsx
+++ b/src/pages/TransactionDuplicate/ReviewBillable.tsx
@@ -1,6 +1,7 @@
import type {RouteProp} from '@react-navigation/native';
import {useRoute} from '@react-navigation/native';
import React, {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
@@ -8,6 +9,7 @@ import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'
import {setReviewDuplicatesKey} from '@libs/actions/Transaction';
import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types';
import * as TransactionUtils from '@libs/TransactionUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import type {FieldItemType} from './ReviewFields';
import ReviewFields from './ReviewFields';
@@ -16,7 +18,8 @@ function ReviewBillable() {
const route = useRoute>();
const {translate} = useLocalize();
const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? '');
- const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID);
+ const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES);
+ const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1');
const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString());
const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation(
Object.keys(compareResult.change ?? {}),
diff --git a/src/pages/TransactionDuplicate/ReviewCategory.tsx b/src/pages/TransactionDuplicate/ReviewCategory.tsx
index 09cbdcd28327..b28cb6863137 100644
--- a/src/pages/TransactionDuplicate/ReviewCategory.tsx
+++ b/src/pages/TransactionDuplicate/ReviewCategory.tsx
@@ -1,6 +1,7 @@
import type {RouteProp} from '@react-navigation/native';
import {useRoute} from '@react-navigation/native';
import React, {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
@@ -8,6 +9,7 @@ import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'
import {setReviewDuplicatesKey} from '@libs/actions/Transaction';
import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types';
import * as TransactionUtils from '@libs/TransactionUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import type {FieldItemType} from './ReviewFields';
import ReviewFields from './ReviewFields';
@@ -16,7 +18,8 @@ function ReviewCategory() {
const route = useRoute>();
const {translate} = useLocalize();
const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? '');
- const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID);
+ const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES);
+ const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1');
const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString());
const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation(
Object.keys(compareResult.change ?? {}),
diff --git a/src/pages/TransactionDuplicate/ReviewDescription.tsx b/src/pages/TransactionDuplicate/ReviewDescription.tsx
index 3d74d8cc36e1..d3c379517cf1 100644
--- a/src/pages/TransactionDuplicate/ReviewDescription.tsx
+++ b/src/pages/TransactionDuplicate/ReviewDescription.tsx
@@ -1,6 +1,7 @@
import type {RouteProp} from '@react-navigation/native';
import {useRoute} from '@react-navigation/native';
import React, {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
@@ -8,6 +9,7 @@ import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'
import {setReviewDuplicatesKey} from '@libs/actions/Transaction';
import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types';
import * as TransactionUtils from '@libs/TransactionUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import type {FieldItemType} from './ReviewFields';
import ReviewFields from './ReviewFields';
@@ -16,7 +18,8 @@ function ReviewDescription() {
const route = useRoute>();
const {translate} = useLocalize();
const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? '');
- const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID);
+ const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES);
+ const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1');
const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString());
const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation(
Object.keys(compareResult.change ?? {}),
diff --git a/src/pages/TransactionDuplicate/ReviewMerchant.tsx b/src/pages/TransactionDuplicate/ReviewMerchant.tsx
index 47dd43d1d334..d49a67d7d911 100644
--- a/src/pages/TransactionDuplicate/ReviewMerchant.tsx
+++ b/src/pages/TransactionDuplicate/ReviewMerchant.tsx
@@ -1,6 +1,7 @@
import type {RouteProp} from '@react-navigation/native';
import {useRoute} from '@react-navigation/native';
import React, {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
@@ -8,6 +9,7 @@ import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'
import {setReviewDuplicatesKey} from '@libs/actions/Transaction';
import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types';
import * as TransactionUtils from '@libs/TransactionUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import type {FieldItemType} from './ReviewFields';
import ReviewFields from './ReviewFields';
@@ -16,7 +18,8 @@ function ReviewMerchant() {
const route = useRoute>();
const {translate} = useLocalize();
const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? '');
- const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID);
+ const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES);
+ const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1');
const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString());
const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation(
Object.keys(compareResult.change ?? {}),
diff --git a/src/pages/TransactionDuplicate/ReviewReimbursable.tsx b/src/pages/TransactionDuplicate/ReviewReimbursable.tsx
index 0b932e8085db..361b92c2af5a 100644
--- a/src/pages/TransactionDuplicate/ReviewReimbursable.tsx
+++ b/src/pages/TransactionDuplicate/ReviewReimbursable.tsx
@@ -1,6 +1,7 @@
import type {RouteProp} from '@react-navigation/native';
import {useRoute} from '@react-navigation/native';
import React, {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
@@ -8,6 +9,7 @@ import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'
import {setReviewDuplicatesKey} from '@libs/actions/Transaction';
import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types';
import * as TransactionUtils from '@libs/TransactionUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import type {FieldItemType} from './ReviewFields';
import ReviewFields from './ReviewFields';
@@ -16,7 +18,8 @@ function ReviewReimbursable() {
const route = useRoute>();
const {translate} = useLocalize();
const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? '');
- const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID);
+ const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES);
+ const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1');
const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString());
const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation(
Object.keys(compareResult.change ?? {}),
diff --git a/src/pages/TransactionDuplicate/ReviewTag.tsx b/src/pages/TransactionDuplicate/ReviewTag.tsx
index 03fb627abd8e..16138865cfd0 100644
--- a/src/pages/TransactionDuplicate/ReviewTag.tsx
+++ b/src/pages/TransactionDuplicate/ReviewTag.tsx
@@ -1,6 +1,7 @@
import type {RouteProp} from '@react-navigation/native';
import {useRoute} from '@react-navigation/native';
import React, {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
@@ -9,6 +10,7 @@ import {setReviewDuplicatesKey} from '@libs/actions/Transaction';
import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import type {FieldItemType} from './ReviewFields';
import ReviewFields from './ReviewFields';
@@ -18,7 +20,8 @@ function ReviewTag() {
const {translate} = useLocalize();
const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? '');
- const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID);
+ const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES);
+ const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1');
const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString());
const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation(
Object.keys(compareResult.change ?? {}),
diff --git a/src/pages/TransactionDuplicate/ReviewTaxCode.tsx b/src/pages/TransactionDuplicate/ReviewTaxCode.tsx
index 78b7c1934715..857a93429f00 100644
--- a/src/pages/TransactionDuplicate/ReviewTaxCode.tsx
+++ b/src/pages/TransactionDuplicate/ReviewTaxCode.tsx
@@ -20,10 +20,11 @@ import ReviewFields from './ReviewFields';
function ReviewTaxRate() {
const route = useRoute>();
const {translate} = useLocalize();
- const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`);
+ const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES);
+ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reviewDuplicates?.reportID ?? route.params.threadReportID}`);
const policy = PolicyUtils.getPolicy(report?.policyID ?? '');
const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? '');
- const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID);
+ const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1');
const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString());
const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation(
Object.keys(compareResult.change ?? {}),
diff --git a/src/pages/Travel/MyTripsPage.tsx b/src/pages/Travel/MyTripsPage.tsx
index be29e8dc8c12..58db57685ab7 100644
--- a/src/pages/Travel/MyTripsPage.tsx
+++ b/src/pages/Travel/MyTripsPage.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import {NativeModules} from 'react-native';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
@@ -20,7 +21,7 @@ function MyTripsPage() {
>
-
+ Navigation.goBack()}
diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx
index 235cab0753f2..4c3ed5c705a5 100644
--- a/src/pages/home/ReportScreen.tsx
+++ b/src/pages/home/ReportScreen.tsx
@@ -720,6 +720,9 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
const lastRoute = usePrevious(route);
const lastReportActionIDFromRoute = usePrevious(reportActionIDFromRoute);
+ const onComposerFocus = useCallback(() => setIsComposerFocus(true), []);
+ const onComposerBlur = useCallback(() => setIsComposerFocus(false), []);
+
// Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger.
// If we have cached reportActions, they will be shown immediately.
// We aim to display a loader first, then fetch relevant reportActions, and finally show them.
@@ -822,8 +825,8 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
{isCurrentReportLoadedFromOnyx ? (
setIsComposerFocus(true)}
- onComposerBlur={() => setIsComposerFocus(false)}
+ onComposerFocus={onComposerFocus}
+ onComposerBlur={onComposerBlur}
report={report}
reportMetadata={reportMetadata}
policy={policy}
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
index 6f3a1e8e565a..a3dc9cbba25f 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
@@ -309,7 +309,8 @@ const ContextMenuActions: ContextMenuAction[] = [
const isThreadFirstChat = ReportUtils.isThreadFirstChat(reportAction, reportID);
const isWhisperAction = ReportActionsUtils.isWhisperAction(reportAction) || ReportActionsUtils.isActionableTrackExpense(reportAction);
const isExpenseReportAction = ReportActionsUtils.isMoneyRequestAction(reportAction) || ReportActionsUtils.isReportPreviewAction(reportAction);
- return !subscribed && !isWhisperAction && !isExpenseReportAction && !isThreadFirstChat && (shouldDisplayThreadReplies || (!isDeletedAction && !isArchivedRoom));
+ const isTaskAction = ReportActionsUtils.isCreatedTaskReportAction(reportAction);
+ return !subscribed && !isWhisperAction && !isTaskAction && !isExpenseReportAction && !isThreadFirstChat && (shouldDisplayThreadReplies || (!isDeletedAction && !isArchivedRoom));
},
onPress: (closePopover, {reportAction, reportID}) => {
const childReportNotificationPreference = ReportUtils.getChildReportNotificationPreference(reportAction);
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
index 9ae2eaa2eaad..1cb70fe6c926 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
@@ -1,6 +1,6 @@
import {useIsFocused, useNavigation} from '@react-navigation/native';
import lodashDebounce from 'lodash/debounce';
-import type {ForwardedRef, MutableRefObject, RefAttributes, RefObject} from 'react';
+import type {ForwardedRef, MutableRefObject, RefObject} from 'react';
import React, {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import type {
LayoutChangeEvent,
@@ -14,7 +14,7 @@ import type {
import {DeviceEventEmitter, findNodeHandle, InteractionManager, NativeModules, View} from 'react-native';
import {useFocusedInputHandler} from 'react-native-keyboard-controller';
import type {OnyxEntry} from 'react-native-onyx';
-import {useOnyx, withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import {useAnimatedRef, useSharedValue} from 'react-native-reanimated';
import type {Emoji} from '@assets/emojis/types';
import type {FileObject} from '@components/AttachmentModal';
@@ -65,113 +65,95 @@ type SyncSelection = {
type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string};
-type ComposerWithSuggestionsOnyxProps = {
- /** The parent report actions for the report */
- parentReportActions: OnyxEntry;
+type ComposerWithSuggestionsProps = Partial & {
+ /** Report ID */
+ reportID: string;
- /** The modal state */
- modal: OnyxEntry;
+ /** Callback to focus composer */
+ onFocus: () => void;
- /** The preferred skin tone of the user */
- preferredSkinTone: number;
+ /** Callback to blur composer */
+ onBlur: (event: NativeSyntheticEvent) => void;
- /** Whether the input is focused */
- editFocused: OnyxEntry;
-};
-
-type ComposerWithSuggestionsProps = ComposerWithSuggestionsOnyxProps &
- Partial & {
- /** Report ID */
- reportID: string;
-
- /** Callback to focus composer */
- onFocus: () => void;
-
- /** Callback to blur composer */
- onBlur: (event: NativeSyntheticEvent) => void;
-
- /** Callback when layout of composer changes */
- onLayout?: (event: LayoutChangeEvent) => void;
+ /** Callback when layout of composer changes */
+ onLayout?: (event: LayoutChangeEvent) => void;
- /** Callback to update the value of the composer */
- onValueChange: (value: string) => void;
+ /** Callback to update the value of the composer */
+ onValueChange: (value: string) => void;
- /** Callback when the composer got cleared on the UI thread */
- onCleared?: (text: string) => void;
+ /** Callback when the composer got cleared on the UI thread */
+ onCleared?: (text: string) => void;
- /** Whether the composer is full size */
- isComposerFullSize: boolean;
+ /** Whether the composer is full size */
+ isComposerFullSize: boolean;
- /** Whether the menu is visible */
- isMenuVisible: boolean;
+ /** Whether the menu is visible */
+ isMenuVisible: boolean;
- /** The placeholder for the input */
- inputPlaceholder: string;
+ /** The placeholder for the input */
+ inputPlaceholder: string;
- /** Function to display a file in a modal */
- displayFileInModal: (file: FileObject) => void;
+ /** Function to display a file in a modal */
+ displayFileInModal: (file: FileObject) => void;
- /** Whether the user is blocked from concierge */
- isBlockedFromConcierge: boolean;
+ /** Whether the user is blocked from concierge */
+ isBlockedFromConcierge: boolean;
- /** Whether the input is disabled */
- disabled: boolean;
+ /** Whether the input is disabled */
+ disabled: boolean;
- /** Whether the full composer is available */
- isFullComposerAvailable: boolean;
+ /** Whether the full composer is available */
+ isFullComposerAvailable: boolean;
- /** Function to set whether the full composer is available */
- setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void;
+ /** Function to set whether the full composer is available */
+ setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void;
- /** Function to set whether the comment is empty */
- setIsCommentEmpty: (isCommentEmpty: boolean) => void;
+ /** Function to set whether the comment is empty */
+ setIsCommentEmpty: (isCommentEmpty: boolean) => void;
- /** Function to handle sending a message */
- handleSendMessage: () => void;
+ /** Function to handle sending a message */
+ handleSendMessage: () => void;
- /** Whether the compose input should show */
- shouldShowComposeInput: OnyxEntry;
+ /** Whether the compose input should show */
+ shouldShowComposeInput: OnyxEntry;
- /** Function to measure the parent container */
- measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void;
+ /** Function to measure the parent container */
+ measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void;
- /** Whether the scroll is likely to trigger a layout */
- isScrollLikelyLayoutTriggered: RefObject;
+ /** Whether the scroll is likely to trigger a layout */
+ isScrollLikelyLayoutTriggered: RefObject;
- /** Function to raise the scroll is likely layout triggered */
- raiseIsScrollLikelyLayoutTriggered: () => void;
+ /** Function to raise the scroll is likely layout triggered */
+ raiseIsScrollLikelyLayoutTriggered: () => void;
- /** The ref to the suggestions */
- suggestionsRef: React.RefObject;
+ /** The ref to the suggestions */
+ suggestionsRef: React.RefObject;
- /** The ref to the next modal will open */
- isNextModalWillOpenRef: MutableRefObject;
+ /** The ref to the next modal will open */
+ isNextModalWillOpenRef: MutableRefObject;
- /** Whether the edit is focused */
- editFocused: boolean;
+ /** Wheater chat is empty */
+ isEmptyChat?: boolean;
- /** Wheater chat is empty */
- isEmptyChat?: boolean;
+ /** The last report action */
+ lastReportAction?: OnyxEntry;
- /** The last report action */
- lastReportAction?: OnyxEntry;
+ /** Whether to include chronos */
+ includeChronos?: boolean;
- /** Whether to include chronos */
- includeChronos?: boolean;
+ /** The parent report action ID */
+ parentReportActionID?: string;
- /** The parent report action ID */
- parentReportActionID?: string;
+ /** The parent report ID */
+ // eslint-disable-next-line react/no-unused-prop-types -- its used in the withOnyx HOC
+ parentReportID: string | undefined;
- /** The parent report ID */
- // eslint-disable-next-line react/no-unused-prop-types -- its used in the withOnyx HOC
- parentReportID: string | undefined;
+ /** Whether report is from group policy */
+ isGroupPolicyReport: boolean;
- /** Whether report is from group policy */
- isGroupPolicyReport: boolean;
-
- /** policy ID of the report */
- policyID: string;
- };
+ /** policy ID of the report */
+ policyID: string;
+};
type SwitchToCurrentReportProps = {
preexistingReportID: string;
@@ -223,13 +205,9 @@ const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
*/
function ComposerWithSuggestions(
{
- // Onyx
- modal,
- preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE,
- parentReportActions,
-
// Props: Report
reportID,
+ parentReportID,
includeChronos,
isEmptyChat,
lastReportAction,
@@ -263,7 +241,6 @@ function ComposerWithSuggestions(
// Refs
suggestionsRef,
isNextModalWillOpenRef,
- editFocused,
// For testing
children,
@@ -290,6 +267,13 @@ function ComposerWithSuggestions(
});
const commentRef = useRef(value);
+ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
+ const [modal] = useOnyx(ONYXKEYS.MODAL);
+ const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, {selector: EmojiUtils.getPreferredSkinToneIndex});
+ const [editFocused] = useOnyx(ONYXKEYS.INPUT_FOCUSED);
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID || '-1'}`, {canEvict: false, initWithStoredValues: false});
+
const lastTextRef = useRef(value);
useEffect(() => {
lastTextRef.current = value;
@@ -298,8 +282,7 @@ function ComposerWithSuggestions(
const {shouldUseNarrowLayout} = useResponsiveLayout();
const maxComposerLines = shouldUseNarrowLayout ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES;
- const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
- const parentReportAction = parentReportActions?.[parentReportActionID ?? '-1'];
+ const parentReportAction = useMemo(() => parentReportActions?.[parentReportActionID ?? '-1'], [parentReportActionID, parentReportActions]);
const shouldAutoFocus =
!modal?.isVisible &&
Modal.areAllModalsHidden() &&
@@ -653,6 +636,7 @@ function ComposerWithSuggestions(
const prevIsModalVisible = usePrevious(modal?.isVisible);
const prevIsFocused = usePrevious(isFocused);
+
useEffect(() => {
if (modal?.isVisible && !prevIsModalVisible) {
// eslint-disable-next-line react-compiler/react-compiler, no-param-reassign
@@ -683,6 +667,7 @@ function ComposerWithSuggestions(
updateMultilineInputRange(textInputRef.current, !!shouldAutoFocus);
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, []);
+
useImperativeHandle(
ref,
() => ({
@@ -836,24 +821,6 @@ function ComposerWithSuggestions(
ComposerWithSuggestions.displayName = 'ComposerWithSuggestions';
-const ComposerWithSuggestionsWithRef = forwardRef(ComposerWithSuggestions);
-
-export default withOnyx, ComposerWithSuggestionsOnyxProps>({
- modal: {
- key: ONYXKEYS.MODAL,
- },
- preferredSkinTone: {
- key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
- selector: EmojiUtils.getPreferredSkinToneIndex,
- },
- editFocused: {
- key: ONYXKEYS.INPUT_FOCUSED,
- },
- parentReportActions: {
- key: ({parentReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`,
- canEvict: false,
- initWithStoredValues: false,
- },
-})(memo(ComposerWithSuggestionsWithRef));
+export default memo(forwardRef(ComposerWithSuggestions));
export type {ComposerWithSuggestionsProps, ComposerRef};
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx
index f3390fe10694..336b5ab22424 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx
@@ -57,11 +57,12 @@ function ComposerWithSuggestionsE2e(props: ComposerWithSuggestionsProps, ref: Fo
textInputRef.current?.blur();
setFocus();
- // Simulate user behavior and don't set focus immediately
- }, 5_000);
+ // 1000ms is enough time for any keyboard to open
+ }, 1_000);
};
- setFocus();
+ // Simulate user behavior and don't set focus immediately
+ setTimeout(setFocus, 2_000);
}, []);
return (
diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
index 14908014ca03..23b059f2fda2 100644
--- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
+++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
@@ -393,6 +393,16 @@ function ReportActionCompose({
],
);
+ const onValueChange = useCallback(
+ (value: string) => {
+ if (value.length === 0 && isComposerFullSize) {
+ Report.setIsComposerFullSize(reportID, false);
+ }
+ validateCommentMaxLength(value, {reportID});
+ },
+ [isComposerFullSize, reportID, validateCommentMaxLength],
+ );
+
return (
@@ -490,12 +500,7 @@ function ReportActionCompose({
onFocus={onFocus}
onBlur={onBlur}
measureParentContainer={measureContainer}
- onValueChange={(value) => {
- if (value.length === 0 && isComposerFullSize) {
- Report.setIsComposerFullSize(reportID, false);
- }
- validateCommentMaxLength(value, {reportID});
- }}
+ onValueChange={onValueChange}
/>
{
diff --git a/src/pages/home/report/withReportAndReportActionOrNotFound.tsx b/src/pages/home/report/withReportAndReportActionOrNotFound.tsx
index f291d71c75e8..aaddb37f7822 100644
--- a/src/pages/home/report/withReportAndReportActionOrNotFound.tsx
+++ b/src/pages/home/report/withReportAndReportActionOrNotFound.tsx
@@ -38,13 +38,15 @@ export default function > {
function WithReportOrNotFound(props: TProps, ref: ForwardedRef) {
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${props.route.params.reportID}`);
- const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report ? report.parentReportID : '-1'}`);
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID || '-1'}`);
const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${props.route.params.reportID}`);
const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA);
const [betas] = useOnyx(ONYXKEYS.BETAS);
const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${props.route.params.reportID}`, {canEvict: false});
- const [parentReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '-1'}`, {
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const [parentReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.parentReportID || '-1'}`, {
selector: (parentReportActions) => {
const parentReportActionID = report?.parentReportActionID;
if (!parentReportActionID) {
diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
index a49b474b185e..88f0c389ba2e 100644
--- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
+++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
@@ -1,21 +1,23 @@
import {useIsFocused as useIsFocusedOriginal, useNavigationState} from '@react-navigation/native';
import type {ImageContentFit} from 'expo-image';
-import type {ForwardedRef, RefAttributes} from 'react';
+import type {ForwardedRef} from 'react';
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
-import {useOnyx, withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import type {SvgProps} from 'react-native-svg';
import FloatingActionButton from '@components/FloatingActionButton';
import * as Expensicons from '@components/Icon/Expensicons';
import type {PopoverMenuItem} from '@components/PopoverMenu';
import PopoverMenu from '@components/PopoverMenu';
import Text from '@components/Text';
+import useEnvironment from '@hooks/useEnvironment';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import usePermissions from '@hooks/usePermissions';
import usePrevious from '@hooks/usePrevious';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import getIconForAction from '@libs/getIconForAction';
@@ -23,14 +25,19 @@ import interceptAnonymousUser from '@libs/interceptAnonymousUser';
import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute';
import Navigation from '@libs/Navigation/Navigation';
import type {CentralPaneName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types';
+import {hasSeenTourSelector} from '@libs/onboardingSelectors';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as SubscriptionUtils from '@libs/SubscriptionUtils';
+import {getNavatticURL} from '@libs/TourUtils';
+import variables from '@styles/variables';
import * as App from '@userActions/App';
import * as IOU from '@userActions/IOU';
+import * as Link from '@userActions/Link';
import * as Policy from '@userActions/Policy/Policy';
import * as Report from '@userActions/Report';
import * as Task from '@userActions/Task';
+import * as Welcome from '@userActions/Welcome';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -39,6 +46,7 @@ import SCREENS from '@src/SCREENS';
import type * as OnyxTypes from '@src/types/onyx';
import type {QuickActionName} from '@src/types/onyx/QuickAction';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import mapOnyxCollectionItems from '@src/utils/mapOnyxCollectionItems';
// On small screen we hide the search page from central pane to show the search bottom tab page with bottom tab bar.
// We need to take this in consideration when checking if the screen is focused.
@@ -51,33 +59,7 @@ const useIsFocused = () => {
type PolicySelector = Pick;
-type FloatingActionButtonAndPopoverOnyxProps = {
- /** The list of policies the user has access to. */
- allPolicies: OnyxCollection;
-
- /** Whether app is in loading state */
- isLoading: OnyxEntry;
-
- /** Information on the last taken action to display as Quick Action */
- quickAction: OnyxEntry;
-
- /** The report data of the quick action */
- quickActionReport: OnyxEntry;
-
- /** The policy data of the quick action */
- quickActionPolicy: OnyxEntry;
-
- /** The current session */
- session: OnyxEntry;
-
- /** Personal details of all the users */
- personalDetails: OnyxEntry;
-
- /** Has user seen track expense training interstitial */
- hasSeenTrackTraining: OnyxEntry;
-};
-
-type FloatingActionButtonAndPopoverProps = FloatingActionButtonAndPopoverOnyxProps & {
+type FloatingActionButtonAndPopoverProps = {
/* Callback function when the menu is shown */
onShowCreateMenu?: () => void;
@@ -161,24 +143,30 @@ const getQuickActionTitle = (action: QuickActionName): TranslationPaths => {
* Responsible for rendering the {@link PopoverMenu}, and the accompanying
* FAB that can open or close the menu.
*/
-function FloatingActionButtonAndPopover(
- {
- onHideCreateMenu,
- onShowCreateMenu,
- isLoading = false,
- allPolicies,
- quickAction,
- quickActionReport,
- quickActionPolicy,
- session,
- personalDetails,
- hasSeenTrackTraining,
- }: FloatingActionButtonAndPopoverProps,
- ref: ForwardedRef,
-) {
+function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: FloatingActionButtonAndPopoverProps, ref: ForwardedRef) {
const styles = useThemeStyles();
+ const theme = useTheme();
const {translate} = useLocalize();
+ const [isLoading = false] = useOnyx(ONYXKEYS.IS_LOADING_APP);
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
+ const [session] = useOnyx(ONYXKEYS.SESSION);
+ const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE);
+ const [quickActionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${quickAction?.chatReportID}`);
const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${quickActionReport?.reportID ?? -1}`);
+ const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
+ const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
+ const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`);
+ const policyChatForActivePolicy = useMemo(() => {
+ if (isEmptyObject(activePolicy) || !activePolicy?.isPolicyExpenseChatEnabled) {
+ return {} as OnyxTypes.Report;
+ }
+ const policyChatsForActivePolicy = ReportUtils.getWorkspaceChats(activePolicyID ?? '-1', [session?.accountID ?? -1], allReports);
+ return policyChatsForActivePolicy.length > 0 ? policyChatsForActivePolicy.at(0) : ({} as OnyxTypes.Report);
+ }, [activePolicy, activePolicyID, session?.accountID, allReports]);
+ const [quickActionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${quickActionReport?.policyID}`);
+ const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: (c) => mapOnyxCollectionItems(c, policySelector)});
+ const [hasSeenTrackTraining] = useOnyx(ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING);
+
const [isCreateMenuActive, setIsCreateMenuActive] = useState(false);
const fabRef = useRef(null);
const {windowHeight} = useWindowDimensions();
@@ -189,16 +177,25 @@ function FloatingActionButtonAndPopover(
const {canUseSpotnanaTravel, canUseCombinedTrackSubmit} = usePermissions();
const canSendInvoice = useMemo(() => PolicyUtils.canSendInvoice(allPolicies as OnyxCollection, session?.email), [allPolicies, session?.email]);
+ const {environment} = useEnvironment();
+ const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
+ const navatticURL = getNavatticURL(environment, introSelected?.choice);
+ const [hasSeenTour = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
+ selector: hasSeenTourSelector,
+ });
const quickActionAvatars = useMemo(() => {
if (quickActionReport) {
const avatars = ReportUtils.getIcons(quickActionReport, personalDetails);
return avatars.length <= 1 || ReportUtils.isPolicyExpenseChat(quickActionReport) ? avatars : avatars.filter((avatar) => avatar.id !== session?.accountID);
}
+ if (!isEmptyObject(policyChatForActivePolicy)) {
+ return ReportUtils.getIcons(policyChatForActivePolicy, personalDetails);
+ }
return [];
// Policy is needed as a dependency in order to update the shortcut details when the workspace changes
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- }, [personalDetails, session?.accountID, quickActionReport, quickActionPolicy]);
+ }, [personalDetails, session?.accountID, quickActionReport, quickActionPolicy, policyChatForActivePolicy]);
const renderQuickActionTooltip = useCallback(
() => (
@@ -233,16 +230,18 @@ function FloatingActionButtonAndPopover(
return quickAction?.action === CONST.QUICK_ACTIONS.SEND_MONEY && displayName.length === 0;
}, [personalDetails, quickActionReport, quickAction?.action, quickActionAvatars]);
- const navigateToQuickAction = () => {
- const selectOption = (onSelected: () => void, shouldRestrictAction: boolean) => {
+ const selectOption = useCallback(
+ (onSelected: () => void, shouldRestrictAction: boolean) => {
if (shouldRestrictAction && quickActionReport?.policyID && SubscriptionUtils.shouldRestrictUserBillableActions(quickActionReport.policyID)) {
Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(quickActionReport.policyID));
return;
}
-
onSelected();
- };
+ },
+ [quickActionReport?.policyID],
+ );
+ const navigateToQuickAction = useCallback(() => {
const isValidReport = !(isEmptyObject(quickActionReport) || ReportUtils.isArchivedRoom(quickActionReport, reportNameValuePairs));
const quickActionReportID = isValidReport ? quickActionReport?.reportID ?? '-1' : ReportUtils.generateReportID();
@@ -282,7 +281,7 @@ function FloatingActionButtonAndPopover(
break;
default:
}
- };
+ }, [quickAction, quickActionReport, reportNameValuePairs, selectOption]);
/**
* Check if LHN status changed from active to inactive.
@@ -413,6 +412,77 @@ function FloatingActionButtonAndPopover(
];
}, [canUseCombinedTrackSubmit, translate, selfDMReportID, hasSeenTrackTraining, isOffline]);
+ const quickActionMenuItems = useMemo(() => {
+ // Define common properties in baseQuickAction
+ const baseQuickAction = {
+ label: translate('quickAction.header'),
+ isLabelHoverable: false,
+ floatRightAvatars: quickActionAvatars,
+ floatRightAvatarSize: CONST.AVATAR_SIZE.SMALL,
+ numberOfLinesDescription: 1,
+ tooltipAnchorAlignment: {
+ vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
+ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
+ },
+ tooltipShiftHorizontal: styles.popoverMenuItem.paddingHorizontal,
+ tooltipShiftVertical: styles.popoverMenuItem.paddingVertical / 2,
+ renderTooltipContent: renderQuickActionTooltip,
+ tooltipWrapperStyle: styles.quickActionTooltipWrapper,
+ };
+
+ if (quickAction?.action) {
+ return [
+ {
+ ...baseQuickAction,
+ icon: getQuickActionIcon(quickAction?.action),
+ text: quickActionTitle,
+ description: !hideQABSubtitle ? ReportUtils.getReportName(quickActionReport) ?? translate('quickAction.updateDestination') : '',
+ onSelected: () => interceptAnonymousUser(() => navigateToQuickAction()),
+ shouldShowSubscriptRightAvatar: ReportUtils.isPolicyExpenseChat(quickActionReport),
+ shouldRenderTooltip: quickAction.isFirstQuickAction,
+ },
+ ];
+ }
+ if (!isEmptyObject(policyChatForActivePolicy)) {
+ return [
+ {
+ ...baseQuickAction,
+ icon: Expensicons.ReceiptScan,
+ text: translate('quickAction.scanReceipt'),
+ description: ReportUtils.getReportName(policyChatForActivePolicy),
+ onSelected: () =>
+ interceptAnonymousUser(() => {
+ selectOption(() => {
+ const isValidReport = !(isEmptyObject(policyChatForActivePolicy) || ReportUtils.isArchivedRoom(policyChatForActivePolicy, reportNameValuePairs));
+ const quickActionReportID = isValidReport ? policyChatForActivePolicy?.reportID ?? '-1' : ReportUtils.generateReportID();
+ IOU.startMoneyRequest(CONST.IOU.TYPE.SUBMIT, quickActionReportID ?? '-1', CONST.IOU.REQUEST_TYPE.SCAN, true);
+ }, true);
+ }),
+ shouldShowSubscriptRightAvatar: true,
+ shouldRenderTooltip: false,
+ },
+ ];
+ }
+
+ return [];
+ }, [
+ translate,
+ quickActionAvatars,
+ styles.popoverMenuItem.paddingHorizontal,
+ styles.popoverMenuItem.paddingVertical,
+ styles.quickActionTooltipWrapper,
+ renderQuickActionTooltip,
+ quickAction?.action,
+ quickAction?.isFirstQuickAction,
+ policyChatForActivePolicy,
+ quickActionTitle,
+ hideQABSubtitle,
+ quickActionReport,
+ navigateToQuickAction,
+ selectOption,
+ reportNameValuePairs,
+ ]);
+
return (
{
+ Welcome.setSelfTourViewed();
+ Link.openExternalLink(navatticURL);
+ },
+ },
+ ]
+ : []),
...(!isLoading && !Policy.hasActiveChatEnabledPolicies(allPolicies)
? [
{
displayInDefaultIconColor: true,
contentFit: 'contain' as ImageContentFit,
icon: Expensicons.NewWorkspace,
- iconWidth: 46,
- iconHeight: 40,
+ iconWidth: variables.w46,
+ iconHeight: variables.h40,
text: translate('workspace.new.newWorkspace'),
description: translate('workspace.new.getTheExpensifyCardAndMore'),
onSelected: () => interceptAnonymousUser(() => App.createWorkspaceWithPolicyDraftAndNavigateToIt()),
},
]
: []),
- ...(quickAction?.action
- ? [
- {
- icon: getQuickActionIcon(quickAction?.action),
- text: quickActionTitle,
- label: translate('quickAction.header'),
- isLabelHoverable: false,
- floatRightAvatars: quickActionAvatars,
- floatRightAvatarSize: CONST.AVATAR_SIZE.SMALL,
- description: !hideQABSubtitle ? ReportUtils.getReportName(quickActionReport) ?? translate('quickAction.updateDestination') : '',
- numberOfLinesDescription: 1,
- onSelected: () => interceptAnonymousUser(() => navigateToQuickAction()),
- shouldShowSubscriptRightAvatar: ReportUtils.isPolicyExpenseChat(quickActionReport),
- shouldRenderTooltip: quickAction.isFirstQuickAction,
- tooltipAnchorAlignment: {
- vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
- horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
- },
- tooltipShiftHorizontal: styles.popoverMenuItem.paddingHorizontal,
- tooltipShiftVertical: styles.popoverMenuItem.paddingVertical / 2,
- renderTooltipContent: renderQuickActionTooltip,
- tooltipWrapperStyle: styles.quickActionTooltipWrapper,
- },
- ]
- : []),
+ ...quickActionMenuItems,
]}
withoutOverlay
anchorRef={fabRef}
@@ -510,32 +571,6 @@ function FloatingActionButtonAndPopover(
FloatingActionButtonAndPopover.displayName = 'FloatingActionButtonAndPopover';
-export default withOnyx, FloatingActionButtonAndPopoverOnyxProps>({
- allPolicies: {
- key: ONYXKEYS.COLLECTION.POLICY,
- selector: policySelector,
- },
- isLoading: {
- key: ONYXKEYS.IS_LOADING_APP,
- },
- quickAction: {
- key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE,
- },
- quickActionReport: {
- key: ({quickAction}) => `${ONYXKEYS.COLLECTION.REPORT}${quickAction?.chatReportID}`,
- },
- quickActionPolicy: {
- key: ({quickActionReport}) => `${ONYXKEYS.COLLECTION.POLICY}${quickActionReport?.policyID}`,
- },
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
- session: {
- key: ONYXKEYS.SESSION,
- },
- hasSeenTrackTraining: {
- key: ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING,
- },
-})(forwardRef(FloatingActionButtonAndPopover));
+export default forwardRef(FloatingActionButtonAndPopover);
export type {PolicySelector};
diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx
index ce3d03138dde..9c6f39ea8c5a 100644
--- a/src/pages/iou/request/step/IOURequestStepAmount.tsx
+++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx
@@ -1,7 +1,7 @@
import {useFocusEffect} from '@react-navigation/native';
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
-import {useOnyx, withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
@@ -11,6 +11,7 @@ import * as CurrencyUtils from '@libs/CurrencyUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as ReportUtils from '@libs/ReportUtils';
+import playSound, {SOUNDS} from '@libs/Sound';
import * as TransactionUtils from '@libs/TransactionUtils';
import {getRequestType} from '@libs/TransactionUtils';
import MoneyRequestAmountForm from '@pages/iou/MoneyRequestAmountForm';
@@ -32,25 +33,7 @@ type AmountParams = {
paymentMethod?: PaymentMethodType;
};
-type IOURequestStepAmountOnyxProps = {
- /** The draft transaction that holds data to be persisted on the current transaction */
- splitDraftTransaction: OnyxEntry;
-
- /** Whether the confirmation step should be skipped */
- skipConfirmation: OnyxEntry;
-
- /** The draft transaction object being modified in Onyx */
- draftTransaction: OnyxEntry;
-
- /** Personal details of all users */
- personalDetails: OnyxEntry;
-
- /** The policy which the user has access to and which the report is tied to */
- policy: OnyxEntry;
-};
-
-type IOURequestStepAmountProps = IOURequestStepAmountOnyxProps &
- WithCurrentUserPersonalDetailsProps &
+type IOURequestStepAmountProps = WithCurrentUserPersonalDetailsProps &
WithWritableReportOrNotFoundProps & {
/** The transaction object being modified in Onyx */
transaction: OnyxEntry;
@@ -65,14 +48,15 @@ function IOURequestStepAmount({
params: {iouType, reportID, transactionID, backTo, pageIndex, action, currency: selectedCurrency = ''},
},
transaction,
- policy,
- personalDetails,
currentUserPersonalDetails,
- splitDraftTransaction,
- skipConfirmation,
- draftTransaction,
shouldKeepUserInput = false,
}: IOURequestStepAmountProps) {
+ const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID ?? -1}`);
+ const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID ?? -1}`);
+ const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? -1}`);
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : -1}`);
+
const {translate} = useLocalize();
const textInput = useRef(null);
const focusTimeoutRef = useRef(null);
@@ -85,7 +69,7 @@ function IOURequestStepAmount({
const isEditingSplitBill = isEditing && isSplitBill;
const currentTransaction = isEditingSplitBill && !isEmptyObject(splitDraftTransaction) ? splitDraftTransaction : transaction;
const {amount: transactionAmount} = ReportUtils.getTransactionDetails(currentTransaction) ?? {amount: 0};
- const {currency: originalCurrency} = ReportUtils.getTransactionDetails(isEditing ? draftTransaction : transaction) ?? {currency: CONST.CURRENCY.USD};
+ const {currency: originalCurrency} = ReportUtils.getTransactionDetails(isEditing && !isEmptyObject(draftTransaction) ? draftTransaction : transaction) ?? {currency: CONST.CURRENCY.USD};
const currency = CurrencyUtils.isValidCurrencyCode(selectedCurrency) ? selectedCurrency : originalCurrency;
// For quick button actions, we'll skip the confirmation page unless the report is archived or this is a workspace request, as
@@ -195,6 +179,7 @@ function IOURequestStepAmount({
if (shouldSkipConfirmation) {
// Only skip confirmation when the split is not configurable, for now Smartscanned splits cannot be configured
if (iouType === CONST.IOU.TYPE.SPLIT && transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.SCAN) {
+ playSound(SOUNDS.DONE);
IOU.splitBill({
participants,
currentUserLogin: currentUserPersonalDetails.login ?? '',
@@ -223,6 +208,7 @@ function IOURequestStepAmount({
return;
}
if (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.REQUEST) {
+ playSound(SOUNDS.DONE);
IOU.requestMoney(
report,
backendAmount,
@@ -239,6 +225,7 @@ function IOURequestStepAmount({
return;
}
if (iouType === CONST.IOU.TYPE.TRACK) {
+ playSound(SOUNDS.DONE);
IOU.trackExpense(
report,
backendAmount,
@@ -332,34 +319,7 @@ function IOURequestStepAmount({
IOURequestStepAmount.displayName = 'IOURequestStepAmount';
-const IOURequestStepAmountWithOnyx = withOnyx({
- splitDraftTransaction: {
- key: ({route}) => {
- const transactionID = route.params.transactionID ?? -1;
- return `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`;
- },
- },
- draftTransaction: {
- key: ({route}) => {
- const transactionID = route.params.transactionID ?? 0;
- return `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`;
- },
- },
- skipConfirmation: {
- key: ({route}) => {
- const transactionID = route.params.transactionID ?? -1;
- return `${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`;
- },
- },
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
- policy: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '-1'}`,
- },
-})(IOURequestStepAmount);
-
-const IOURequestStepAmountWithCurrentUserPersonalDetails = withCurrentUserPersonalDetails(IOURequestStepAmountWithOnyx);
+const IOURequestStepAmountWithCurrentUserPersonalDetails = withCurrentUserPersonalDetails(IOURequestStepAmount);
// eslint-disable-next-line rulesdir/no-negated-variables
const IOURequestStepAmountWithWritableReportOrNotFound = withWritableReportOrNotFound(IOURequestStepAmountWithCurrentUserPersonalDetails, true);
// eslint-disable-next-line rulesdir/no-negated-variables
diff --git a/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx b/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx
index d53940a4cfcb..1c2f14dbbb2b 100644
--- a/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx
+++ b/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx
@@ -1,5 +1,5 @@
import {Str} from 'expensify-common';
-import React, {useCallback} from 'react';
+import React, {useCallback, useMemo} from 'react';
import {useOnyx} from 'react-native-onyx';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
@@ -11,6 +11,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'
import useLocalize from '@hooks/useLocalize';
import usePolicy from '@hooks/usePolicy';
import useThemeStyles from '@hooks/useThemeStyles';
+import {getDefaultCompanyWebsite} from '@libs/BankAccountUtils';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import playSound, {SOUNDS} from '@libs/Sound';
import * as Url from '@libs/Url';
@@ -37,6 +38,9 @@ function IOURequestStepCompanyInfo({route, report, transaction}: IOURequestStepC
const {translate} = useLocalize();
const {inputCallbackRef} = useAutoFocusInput();
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
+ const [session] = useOnyx(ONYXKEYS.SESSION);
+ const [user] = useOnyx(ONYXKEYS.USER);
+ const defaultWebsiteExample = useMemo(() => getDefaultCompanyWebsite(session, user), [session, user]);
const policy = usePolicy(IOU.getIOURequestPolicyID(transaction, report));
const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${IOU.getIOURequestPolicyID(transaction, report)}`);
@@ -47,9 +51,9 @@ function IOURequestStepCompanyInfo({route, report, transaction}: IOURequestStepC
const validate = useCallback(
(values: FormOnyxValues): FormInputErrors => {
const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.COMPANY_NAME, INPUT_IDS.COMPANY_WEBSITE]);
-
if (values.companyWebsite) {
- if (!ValidationUtils.isValidWebsite(values.companyWebsite)) {
+ const companyWebsite = Str.sanitizeURL(values.companyWebsite, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME);
+ if (!ValidationUtils.isValidWebsite(companyWebsite)) {
errors.companyWebsite = translate('bankAccount.error.website');
} else {
const domain = Url.extractUrlDomain(values.companyWebsite);
@@ -68,8 +72,9 @@ function IOURequestStepCompanyInfo({route, report, transaction}: IOURequestStepC
);
const submit = (values: FormOnyxValues) => {
+ const companyWebsite = Str.sanitizeURL(values.companyWebsite, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME);
playSound(SOUNDS.DONE);
- IOU.sendInvoice(currentUserPersonalDetails.accountID, transaction, report, undefined, policy, policyTags, policyCategories, values.companyName, values.companyWebsite);
+ IOU.sendInvoice(currentUserPersonalDetails.accountID, transaction, report, undefined, policy, policyTags, policyCategories, values.companyName, companyWebsite);
};
return (
@@ -107,6 +112,7 @@ function IOURequestStepCompanyInfo({route, report, transaction}: IOURequestStepC
accessibilityLabel={translate('iou.yourCompanyWebsite')}
role={CONST.ROLE.PRESENTATION}
hint={translate('iou.yourCompanyWebsiteNote')}
+ defaultValue={defaultWebsiteExample}
/>
diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx
index 03cffc1ec1dd..aec3b0146138 100644
--- a/src/pages/iou/request/step/IOURequestStepDistance.tsx
+++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx
@@ -29,6 +29,7 @@ import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
+import playSound, {SOUNDS} from '@libs/Sound';
import * as TransactionUtils from '@libs/TransactionUtils';
import * as IOU from '@userActions/IOU';
import * as MapboxToken from '@userActions/MapboxToken';
@@ -162,9 +163,11 @@ function IOURequestStepDistance({
}
return (
- !ReportUtils.isArchivedRoom(report, reportNameValuePairs) && !(ReportUtils.isPolicyExpenseChat(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false)))
+ iouType !== CONST.IOU.TYPE.SPLIT &&
+ !ReportUtils.isArchivedRoom(report, reportNameValuePairs) &&
+ !(ReportUtils.isPolicyExpenseChat(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false)))
);
- }, [report, skipConfirmation, policy, reportNameValuePairs]);
+ }, [report, skipConfirmation, policy, reportNameValuePairs, iouType]);
let buttonText = !isCreatingNewRequest ? translate('common.save') : translate('common.next');
if (shouldSkipConfirmation) {
if (iouType === CONST.IOU.TYPE.SPLIT) {
@@ -301,28 +304,11 @@ function IOURequestStepDistance({
});
setDistanceRequestData(participants);
if (shouldSkipConfirmation) {
- if (iouType === CONST.IOU.TYPE.SPLIT) {
- IOU.splitBill({
- participants,
- currentUserLogin: currentUserPersonalDetails.login ?? '',
- currentUserAccountID: currentUserPersonalDetails.accountID,
- amount: 0,
- comment: '',
- currency: transaction?.currency ?? 'USD',
- merchant: translate('iou.fieldPending'),
- created: transaction?.created ?? '',
- category: '',
- tag: '',
- billable: false,
- iouRequestType,
- existingSplitChatReportID: report?.reportID,
- });
- return;
- }
IOU.setMoneyRequestPendingFields(transactionID, {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD});
IOU.setMoneyRequestMerchant(transactionID, translate('iou.fieldPending'), false);
const participant = participants.at(0);
if (iouType === CONST.IOU.TYPE.TRACK && participant) {
+ playSound(SOUNDS.DONE);
IOU.trackExpense(
report,
0,
@@ -353,6 +339,7 @@ function IOURequestStepDistance({
return;
}
+ playSound(SOUNDS.DONE);
IOU.createDistanceRequest(
report,
participants,
@@ -401,7 +388,6 @@ function IOURequestStepDistance({
navigateToParticipantPage,
navigateToConfirmationPage,
policy,
- iouRequestType,
reportNameValuePairs,
customUnitRateID,
setDistanceRequestData,
diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
index 34e77b19d078..65e041180408 100644
--- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx
+++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
@@ -1,7 +1,6 @@
import {useIsFocused} from '@react-navigation/core';
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import FormHelpMessage from '@components/FormHelpMessage';
import useLocalize from '@hooks/useLocalize';
import usePermissions from '@hooks/usePermissions';
@@ -26,13 +25,7 @@ import withFullTransactionOrNotFound from './withFullTransactionOrNotFound';
import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound';
import withWritableReportOrNotFound from './withWritableReportOrNotFound';
-type IOURequestStepParticipantsOnyxProps = {
- /** Whether the confirmation step should be skipped */
- skipConfirmation: OnyxEntry;
-};
-
-type IOURequestStepParticipantsProps = IOURequestStepParticipantsOnyxProps &
- WithWritableReportOrNotFoundProps &
+type IOURequestStepParticipantsProps = WithWritableReportOrNotFoundProps &
WithFullTransactionOrNotFoundProps;
function IOURequestStepParticipants({
@@ -40,13 +33,13 @@ function IOURequestStepParticipants({
params: {iouType, reportID, transactionID, action},
},
transaction,
- skipConfirmation,
}: IOURequestStepParticipantsProps) {
const participants = transaction?.participants;
const {translate} = useLocalize();
const styles = useThemeStyles();
const isFocused = useIsFocused();
const {canUseP2PDistanceRequests} = usePermissions(iouType);
+ const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? -1}`);
// We need to set selectedReportID if user has navigated back from confirmation page and navigates to confirmation page with already selected participant
const selectedReportID = useRef(participants?.length === 1 ? participants.at(0)?.reportID ?? reportID : reportID);
@@ -161,6 +154,8 @@ function IOURequestStepParticipants({
return;
}
+ const rateID = DistanceRequestUtils.getCustomUnitRateID(selfDMReportID, !canUseP2PDistanceRequests);
+ IOU.setCustomUnitRateID(transactionID, rateID);
IOU.setMoneyRequestParticipantsFromReport(transactionID, ReportUtils.getReport(selfDMReportID));
const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, CONST.IOU.TYPE.TRACK, transactionID, selfDMReportID);
Navigation.navigate(iouConfirmationPageRoute);
@@ -207,13 +202,4 @@ function IOURequestStepParticipants({
IOURequestStepParticipants.displayName = 'IOURequestStepParticipants';
-const IOURequestStepParticipantsWithOnyx = withOnyx({
- skipConfirmation: {
- key: ({route}) => {
- const transactionID = route.params.transactionID ?? -1;
- return `${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`;
- },
- },
-})(IOURequestStepParticipants);
-
-export default withWritableReportOrNotFound(withFullTransactionOrNotFound(IOURequestStepParticipantsWithOnyx));
+export default withWritableReportOrNotFound(withFullTransactionOrNotFound(IOURequestStepParticipants));
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
index d3f0c9cb496d..f7e575b898fd 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
@@ -30,11 +30,13 @@ import useThemeStyles from '@hooks/useThemeStyles';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import getPhotoSource from '@libs/fileDownload/getPhotoSource';
import getCurrentPosition from '@libs/getCurrentPosition';
+import getPlatform from '@libs/getPlatform';
import * as IOUUtils from '@libs/IOUUtils';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as ReportUtils from '@libs/ReportUtils';
+import playSound, {SOUNDS} from '@libs/Sound';
import * as TransactionUtils from '@libs/TransactionUtils';
import StepScreenWrapper from '@pages/iou/request/step/StepScreenWrapper';
import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound';
@@ -74,7 +76,9 @@ function IOURequestStepScan({
const policy = usePolicy(report?.policyID);
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? -1}`);
- const [user] = useOnyx(ONYXKEYS.USER);
+ const platform = getPlatform(true);
+ const [mutedPlatforms = {}] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS);
+ const isPlatformMuted = mutedPlatforms[platform];
const [cameraPermissionStatus, setCameraPermissionStatus] = useState(null);
const [didCapturePhoto, setDidCapturePhoto] = useState(false);
const [isLoadingReceipt, setIsLoadingReceipt] = useState(false);
@@ -295,6 +299,7 @@ function IOURequestStepScan({
receipt.source = source;
receipt.state = CONST.IOU.RECEIPT_STATE.SCANREADY;
if (iouType === CONST.IOU.TYPE.SPLIT) {
+ playSound(SOUNDS.DONE);
IOU.startSplitBill({
participants,
currentUserLogin: currentUserPersonalDetails?.login ?? '',
@@ -318,6 +323,7 @@ function IOURequestStepScan({
if (locationPermissionGranted) {
getCurrentPosition(
(successData) => {
+ playSound(SOUNDS.DONE);
if (iouType === CONST.IOU.TYPE.TRACK && report) {
IOU.trackExpense(
report,
@@ -374,6 +380,7 @@ function IOURequestStepScan({
(errorData) => {
Log.info('[IOURequestStepScan] getCurrentPosition failed', false, errorData);
// When there is an error, the money can still be requested, it just won't include the GPS coordinates
+ playSound(SOUNDS.DONE);
createTransaction(receipt, participant);
},
{
@@ -383,6 +390,7 @@ function IOURequestStepScan({
);
return;
}
+ playSound(SOUNDS.DONE);
createTransaction(receipt, participant);
return;
}
@@ -489,7 +497,7 @@ function IOURequestStepScan({
camera?.current
?.takePhoto({
flash: flash && hasFlash ? 'on' : 'off',
- enableShutterSound: !user?.isMutedAllSounds,
+ enableShutterSound: !isPlatformMuted,
})
.then((photo: PhotoFile) => {
// Store the receipt on the transaction object in Onyx
@@ -535,7 +543,7 @@ function IOURequestStepScan({
didCapturePhoto,
flash,
hasFlash,
- user?.isMutedAllSounds,
+ isPlatformMuted,
translate,
transactionID,
isEditing,
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx
index 4ed956e5ce7e..46f2fe6262ec 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx
@@ -35,6 +35,7 @@ import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as ReportUtils from '@libs/ReportUtils';
+import playSound, {SOUNDS} from '@libs/Sound';
import * as TransactionUtils from '@libs/TransactionUtils';
import ReceiptDropUI from '@pages/iou/ReceiptDropUI';
import StepScreenDragAndDropWrapper from '@pages/iou/request/step/StepScreenDragAndDropWrapper';
@@ -328,6 +329,7 @@ function IOURequestStepScan({
receipt.source = source;
receipt.state = CONST.IOU.RECEIPT_STATE.SCANREADY;
if (iouType === CONST.IOU.TYPE.SPLIT) {
+ playSound(SOUNDS.DONE);
IOU.startSplitBill({
participants,
currentUserLogin: currentUserPersonalDetails?.login ?? '',
@@ -351,6 +353,7 @@ function IOURequestStepScan({
if (locationPermissionGranted) {
getCurrentPosition(
(successData) => {
+ playSound(SOUNDS.DONE);
if (iouType === CONST.IOU.TYPE.TRACK && report) {
IOU.trackExpense(
report,
@@ -407,6 +410,7 @@ function IOURequestStepScan({
(errorData) => {
Log.info('[IOURequestStepScan] getCurrentPosition failed', false, errorData);
// When there is an error, the money can still be requested, it just won't include the GPS coordinates
+ playSound(SOUNDS.DONE);
createTransaction(receipt, participant);
},
{
@@ -416,6 +420,7 @@ function IOURequestStepScan({
);
return;
}
+ playSound(SOUNDS.DONE);
createTransaction(receipt, participant);
return;
}
diff --git a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx
index 66736dc80b52..8b4519d2758d 100644
--- a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx
+++ b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx
@@ -3,7 +3,7 @@ import {useIsFocused} from '@react-navigation/native';
import type {ComponentType, ForwardedRef, RefAttributes} from 'react';
import React, {forwardRef} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import getComponentDisplayName from '@libs/getComponentDisplayName';
import * as IOUUtils from '@libs/IOUUtils';
@@ -38,14 +38,24 @@ type MoneyRequestRouteName =
| typeof SCREENS.MONEY_REQUEST.STEP_SEND_FROM
| typeof SCREENS.MONEY_REQUEST.STEP_COMPANY_INFO;
-type Route = RouteProp;
+type Route = RouteProp;
-type WithFullTransactionOrNotFoundProps = WithFullTransactionOrNotFoundOnyxProps & {route: Route};
+type WithFullTransactionOrNotFoundProps = WithFullTransactionOrNotFoundOnyxProps & {
+ route: Route;
+};
-export default function , TRef>(WrappedComponent: ComponentType>) {
+export default function , TRef>(
+ WrappedComponent: ComponentType>,
+): React.ComponentType & RefAttributes> {
// eslint-disable-next-line rulesdir/no-negated-variables
- function WithFullTransactionOrNotFound(props: TProps, ref: ForwardedRef) {
- const transactionID = props.transaction?.transactionID;
+ function WithFullTransactionOrNotFound(props: Omit, ref: ForwardedRef) {
+ const {route} = props;
+ const transactionID = route.params.transactionID ?? -1;
+ const userAction = 'action' in route.params && route.params.action ? route.params.action : CONST.IOU.ACTION.CREATE;
+
+ const shouldUseTransactionDraft = IOUUtils.shouldUseTransactionDraft(userAction);
+ const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`);
+ const [transactionDraft] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`);
const isFocused = useIsFocused();
@@ -55,31 +65,19 @@ export default function ;
}
-
return (
);
}
WithFullTransactionOrNotFound.displayName = `withFullTransactionOrNotFound(${getComponentDisplayName(WrappedComponent)})`;
- // eslint-disable-next-line deprecation/deprecation
- return withOnyx, WithFullTransactionOrNotFoundOnyxProps>({
- transaction: {
- key: ({route}) => {
- const transactionID = route.params.transactionID ?? -1;
- const userAction = 'action' in route.params && route.params.action ? route.params.action : CONST.IOU.ACTION.CREATE;
- if (IOUUtils.shouldUseTransactionDraft(userAction)) {
- return `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}` as `${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}`;
- }
- return `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`;
- },
- },
- })(forwardRef(WithFullTransactionOrNotFound));
+ return forwardRef(WithFullTransactionOrNotFound);
}
export type {WithFullTransactionOrNotFoundProps};
diff --git a/src/pages/settings/Preferences/PreferencesPage.tsx b/src/pages/settings/Preferences/PreferencesPage.tsx
index 5dee30518533..6616d342aa3c 100755
--- a/src/pages/settings/Preferences/PreferencesPage.tsx
+++ b/src/pages/settings/Preferences/PreferencesPage.tsx
@@ -13,7 +13,6 @@ import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
-import * as Browser from '@libs/Browser';
import getPlatform from '@libs/getPlatform';
import LocaleUtils from '@libs/LocaleUtils';
import Navigation from '@libs/Navigation/Navigation';
@@ -25,7 +24,7 @@ import ROUTES from '@src/ROUTES';
function PreferencesPage() {
const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE);
- const platform = Browser.isMobile() ? CONST.PLATFORM.MOBILEWEB : getPlatform();
+ const platform = getPlatform(true);
const [mutedPlatforms = {}] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS);
const isPlatformMuted = mutedPlatforms[platform];
const [user] = useOnyx(ONYXKEYS.USER);
diff --git a/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx b/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx
index 4f6770bd98ff..e466b862ae9a 100644
--- a/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx
+++ b/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx
@@ -23,16 +23,17 @@ function DelegateMagicCodeModal({login, role, onClose, isValidateCodeActionModal
const [account] = useOnyx(ONYXKEYS.ACCOUNT);
const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === login);
- const validateLoginError = ErrorUtils.getLatestErrorField(currentDelegate, 'addDelegate');
+ const addDelegateErrors = account?.delegatedAccess?.errorFields?.addDelegate?.[login];
+ const validateLoginError = ErrorUtils.getLatestError(addDelegateErrors);
useEffect(() => {
- if (!currentDelegate || !!currentDelegate.pendingFields?.email || !!currentDelegate.errorFields?.addDelegate) {
+ if (!currentDelegate || !!currentDelegate.pendingFields?.email || !!addDelegateErrors) {
return;
}
// Dismiss modal on successful magic code verification
Navigation.navigate(ROUTES.SETTINGS_SECURITY);
- }, [login, currentDelegate, role]);
+ }, [login, currentDelegate, role, addDelegateErrors]);
const onBackButtonPress = () => {
onClose?.();
@@ -42,7 +43,7 @@ function DelegateMagicCodeModal({login, role, onClose, isValidateCodeActionModal
if (!validateLoginError) {
return;
}
- Delegate.clearAddDelegateErrors(currentDelegate?.email ?? '', 'addDelegate');
+ Delegate.clearDelegateErrorsByField(currentDelegate?.email ?? '', 'addDelegate');
};
return (
diff --git a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage.tsx b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage.tsx
index 2c1bc55e0e92..3bc82e8d7e65 100644
--- a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage.tsx
+++ b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage.tsx
@@ -28,15 +28,16 @@ function UpdateDelegateMagicCodePage({route}: UpdateDelegateMagicCodePageProps)
const validateCodeFormRef = useRef(null);
const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === login);
+ const updateDelegateErrors = account?.delegatedAccess?.errorFields?.addDelegate?.[login];
useEffect(() => {
- if (!currentDelegate || !!currentDelegate.pendingFields?.role || !!currentDelegate.errorFields?.updateDelegateRole) {
+ if (!currentDelegate || !!currentDelegate.pendingFields?.role || !!updateDelegateErrors) {
return;
}
// Dismiss modal on successful magic code verification
Navigation.dismissModal();
- }, [login, currentDelegate, role]);
+ }, [login, currentDelegate, role, updateDelegateErrors]);
const onBackButtonPress = () => {
Navigation.goBack(ROUTES.SETTINGS_UPDATE_DELEGATE_ROLE.getRoute(login, role));
diff --git a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx
index 4c07803ef0e3..7c35d1478eb2 100644
--- a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx
+++ b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx
@@ -66,7 +66,8 @@ function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () =>
const focusTimeoutRef = useRef(null);
const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === delegate);
- const validateLoginError = ErrorUtils.getLatestErrorField(currentDelegate, 'updateDelegateRole');
+ const errorFields = account?.delegatedAccess?.errorFields ?? {};
+ const validateLoginError = ErrorUtils.getLatestError(errorFields.updateDelegateRole?.[currentDelegate?.email ?? '']);
const shouldDisableResendValidateCode = !!isOffline || currentDelegate?.isLoading;
@@ -127,7 +128,7 @@ function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () =>
setValidateCode(text);
setFormError({});
if (validateLoginError) {
- Delegate.clearAddDelegateErrors(currentDelegate?.email ?? '', 'updateDelegateRole');
+ Delegate.clearDelegateErrorsByField(currentDelegate?.email ?? '', 'updateDelegateRole');
}
},
[currentDelegate?.email, validateLoginError],
diff --git a/src/pages/settings/Security/SecuritySettingsPage.tsx b/src/pages/settings/Security/SecuritySettingsPage.tsx
index ec82000d06c8..cd8e7c14d882 100644
--- a/src/pages/settings/Security/SecuritySettingsPage.tsx
+++ b/src/pages/settings/Security/SecuritySettingsPage.tsx
@@ -21,12 +21,11 @@ import Section from '@components/Section';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
import useLocalize from '@hooks/useLocalize';
-import usePermissions from '@hooks/usePermissions';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import useWaitForNavigation from '@hooks/useWaitForNavigation';
import useWindowDimensions from '@hooks/useWindowDimensions';
-import {clearAddDelegateErrors, removeDelegate} from '@libs/actions/Delegate';
+import {clearDelegateErrorsByField, removeDelegate} from '@libs/actions/Delegate';
import * as ErrorUtils from '@libs/ErrorUtils';
import getClickedTargetLocation from '@libs/getClickedTargetLocation';
import {formatPhoneNumber} from '@libs/LocalePhoneNumber';
@@ -46,7 +45,6 @@ function SecuritySettingsPage() {
const {translate} = useLocalize();
const waitForNavigate = useWaitForNavigation();
const {shouldUseNarrowLayout} = useResponsiveLayout();
- const {canUseNewDotCopilot} = usePermissions();
const {windowWidth} = useWindowDimensions();
const personalDetails = usePersonalDetails();
@@ -56,6 +54,7 @@ function SecuritySettingsPage() {
const [shouldShowDelegatePopoverMenu, setShouldShowDelegatePopoverMenu] = useState(false);
const [shouldShowRemoveDelegateModal, setShouldShowRemoveDelegateModal] = useState(false);
const [selectedDelegate, setSelectedDelegate] = useState();
+ const errorFields = account?.delegatedAccess?.errorFields ?? {};
const [anchorPosition, setAnchorPosition] = useState({
anchorPositionHorizontal: 0,
@@ -136,9 +135,10 @@ function SecuritySettingsPage() {
() =>
delegates
.filter((d) => !d.optimisticAccountID)
- .map(({email, role, pendingAction, errorFields, pendingFields}) => {
+ .map(({email, role, pendingAction, pendingFields}) => {
const personalDetail = getPersonalDetailByEmail(email);
- const error = ErrorUtils.getLatestErrorField({errorFields}, 'addDelegate');
+ const addDelegateErrors = errorFields?.addDelegate?.[email];
+ const error = ErrorUtils.getLatestError(addDelegateErrors);
const onPress = (e: GestureResponderEvent | KeyboardEvent) => {
if (isEmptyObject(pendingAction)) {
@@ -171,14 +171,14 @@ function SecuritySettingsPage() {
shouldShowRightIcon: true,
pendingAction,
shouldForceOpacity: !!pendingAction,
- onPendingActionDismiss: () => clearAddDelegateErrors(email, 'addDelegate'),
+ onPendingActionDismiss: () => clearDelegateErrorsByField(email, 'addDelegate'),
error,
onPress,
};
}),
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
- [delegates, translate, styles, personalDetails],
+ [delegates, translate, styles, personalDetails, errorFields],
);
const delegatorMenuItems: MenuItemProps[] = useMemo(
@@ -236,50 +236,48 @@ function SecuritySettingsPage() {
shouldUseSingleExecution
/>
- {!!canUseNewDotCopilot && (
-
- (
-
- {translate('delegate.copilotDelegatedAccessDescription')}
-
- {translate('common.learnMore')}
-
-
- )}
- isCentralPane
- subtitleMuted
- titleStyles={styles.accountSettingsSectionTitle}
- childrenStyles={styles.pt5}
- >
- {hasDelegates && (
- <>
- {translate('delegate.membersCanAccessYourAccount')}
-
- >
- )}
- {!isActingAsDelegate && (
-
-
- )}
+
+ (
+
+ {translate('delegate.copilotDelegatedAccessDescription')}
+
+ {translate('common.learnMore')}
+
+
+ )}
+ isCentralPane
+ subtitleMuted
+ titleStyles={styles.accountSettingsSectionTitle}
+ childrenStyles={styles.pt5}
+ >
+ {hasDelegates && (
+ <>
+ {translate('delegate.membersCanAccessYourAccount')}
+
+ >
+ )}
+ {!isActingAsDelegate && (
+
+ }
diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx
index 23d0b5ab6550..676083f31004 100644
--- a/src/pages/settings/Wallet/PaymentMethodList.tsx
+++ b/src/pages/settings/Wallet/PaymentMethodList.tsx
@@ -9,8 +9,6 @@ import type {ValueOf} from 'type-fest';
import type {RenderSuggestionMenuItemProps} from '@components/AutoCompleteSuggestions/types';
import Button from '@components/Button';
import FormAlertWrapper from '@components/FormAlertWrapper';
-import getBankIcon from '@components/Icon/BankIcons';
-import type {BankName} from '@components/Icon/BankIconsUtils';
import * as Expensicons from '@components/Icon/Expensicons';
import MenuItem from '@components/MenuItem';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
@@ -30,7 +28,7 @@ import * as PaymentMethods from '@userActions/PaymentMethods';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {AccountData} from '@src/types/onyx';
+import type {AccountData, CompanyCardFeed} from '@src/types/onyx';
import type {BankIcon} from '@src/types/onyx/Bank';
import type {Errors} from '@src/types/onyx/OnyxCommon';
import type PaymentMethod from '@src/types/onyx/PaymentMethod';
@@ -223,12 +221,12 @@ function PaymentMethodList({
const assignedCardsGrouped: PaymentMethodItem[] = [];
assignedCardsSorted.forEach((card) => {
- const icon = getBankIcon({bankName: card.bank as BankName, isCard: true, styles});
+ const icon = CardUtils.getCardFeedIcon(card.bank as CompanyCardFeed);
if (!CardUtils.isExpensifyCard(card.cardID)) {
assignedCardsGrouped.push({
key: card.cardID.toString(),
- title: card.bank,
+ title: card.cardName,
description: getDescriptionForPolicyDomainCard(card.domainName),
shouldShowRightIcon: false,
interactive: false,
@@ -238,7 +236,9 @@ function PaymentMethodList({
card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN || card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL
? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR
: undefined,
- ...icon,
+ icon,
+ iconStyles: [styles.assignedCardsIconContainer],
+ iconSize: variables.iconSizeExtraLarge,
});
return;
}
@@ -275,7 +275,10 @@ function PaymentMethodList({
card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN || card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL
? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR
: undefined,
- ...icon,
+ icon,
+ iconStyles: [styles.assignedCardsIconContainer],
+ iconWidth: variables.bankCardWidth,
+ iconHeight: variables.bankCardHeight,
});
});
return assignedCardsGrouped;
diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx
index 2c9c2975d1d2..11acbda1ebbe 100644
--- a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx
+++ b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx
@@ -12,7 +12,6 @@ import Section from '@components/Section';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
-import usePermissions from '@hooks/usePermissions';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import * as CardUtils from '@libs/CardUtils';
@@ -63,7 +62,6 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro
const styles = useThemeStyles();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const {translate} = useLocalize();
- const {canUseCompanyCardFeeds} = usePermissions();
const hasAccountingConnection = !isEmptyObject(policy?.connections);
const isAccountingEnabled = !!policy?.areConnectionsEnabled || !isEmptyObject(policy?.connections);
const isSyncTaxEnabled =
@@ -120,31 +118,27 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro
},
];
- if (canUseCompanyCardFeeds) {
- spendItems.push({
- icon: Illustrations.CompanyCard,
- titleTranslationKey: 'workspace.moreFeatures.companyCards.title',
- subtitleTranslationKey: 'workspace.moreFeatures.companyCards.subtitle',
- isActive: policy?.areCompanyCardsEnabled ?? false,
- pendingAction: policy?.pendingFields?.areCompanyCardsEnabled,
- disabled: !isEmptyObject(CardUtils.removeExpensifyCardFromCompanyCards(cardFeeds?.settings?.companyCards)),
- action: (isEnabled: boolean) => {
- if (!policyID) {
- return;
- }
- if (isEnabled && !isControlPolicy(policy)) {
- Navigation.navigate(
- ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards.alias, ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)),
- );
- return;
- }
- Policy.enableCompanyCards(policyID, isEnabled);
- },
- disabledAction: () => {
- setIsDisableCompanyCardsWarningModalOpen(true);
- },
- });
- }
+ spendItems.push({
+ icon: Illustrations.CompanyCard,
+ titleTranslationKey: 'workspace.moreFeatures.companyCards.title',
+ subtitleTranslationKey: 'workspace.moreFeatures.companyCards.subtitle',
+ isActive: policy?.areCompanyCardsEnabled ?? false,
+ pendingAction: policy?.pendingFields?.areCompanyCardsEnabled,
+ disabled: !isEmptyObject(CardUtils.removeExpensifyCardFromCompanyCards(cardFeeds?.settings?.companyCards)),
+ action: (isEnabled: boolean) => {
+ if (!policyID) {
+ return;
+ }
+ if (isEnabled && !isControlPolicy(policy)) {
+ Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards.alias, ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)));
+ return;
+ }
+ Policy.enableCompanyCards(policyID, isEnabled);
+ },
+ disabledAction: () => {
+ setIsDisableCompanyCardsWarningModalOpen(true);
+ },
+ });
const manageItems: Item[] = [
{
diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx
index 82503134b09e..db21700a0c47 100755
--- a/src/pages/workspace/WorkspacesListPage.tsx
+++ b/src/pages/workspace/WorkspacesListPage.tsx
@@ -384,10 +384,7 @@ function WorkspacesListPage() {
shouldDisplaySearchRouter
onBackButtonPress={() => Navigation.goBack()}
icon={Illustrations.BigRocket}
- >
- {!shouldUseNarrowLayout && getHeaderButton()}
-
- {shouldUseNarrowLayout && {getHeaderButton()}}
+ />
('');
+ const hasResultOfFetchingSetupLink = !!codatSetupLink || hasError;
- const ContentWrapper = codatSetupLink ? ({children}: React.PropsWithChildren) => children : FullPageOfflineBlockingView;
+ const ContentWrapper = hasResultOfFetchingSetupLink ? ({children}: React.PropsWithChildren) => children : FullPageOfflineBlockingView;
const fetchSetupLink = useCallback(() => {
setIsLoading(true);
+ setHasError(false);
// eslint-disable-next-line rulesdir/no-thenable-actions-in-views
QuickbooksDesktop.getQuickbooksDesktopCodatSetupLink(policyID).then((response) => {
- setCodatSetupLink(String(response?.setupUrl ?? ''));
+ if (response?.jsonCode) {
+ if (response.jsonCode === CONST.JSON_CODE.SUCCESS) {
+ setCodatSetupLink(String(response?.setupUrl ?? ''));
+ } else {
+ setConnectionError(policyID, CONST.POLICY.CONNECTIONS.NAME.QBD, translate('workspace.qbd.setupPage.setupErrorTitle'));
+ setHasError(true);
+ }
+ }
+
setIsLoading(false);
});
- }, [policyID]);
+ }, [policyID, translate]);
useEffect(() => {
// Since QBD doesn't support Taxes, we should disable them from the LHN when connecting to QBD
@@ -52,13 +70,16 @@ function RequireQuickBooksDesktopModal({route}: RequireQuickBooksDesktopModalPro
useNetwork({
onReconnect: () => {
- if (codatSetupLink) {
+ if (hasResultOfFetchingSetupLink) {
return;
}
fetchSetupLink();
},
});
+ const shouldShowLoading = isLoading || !hasResultOfFetchingSetupLink;
+ const shouldShowError = !shouldShowLoading && hasError;
+
return (
Navigation.dismissModal()}
/>
- {isLoading || !codatSetupLink ? (
-
- ) : (
+ {shouldShowLoading && }
+ {shouldShowError && (
+
+
+ {translate('workspace.qbd.setupPage.setupErrorTitle')}
+
+ {translate('workspace.qbd.setupPage.setupErrorBody1')}{' '}
+
+ {translate('workspace.qbd.setupPage.setupErrorBodyContactConcierge')}
+ {' '}
+ {translate('workspace.qbd.setupPage.setupErrorBody2')}
+
+
+ )}
+ {!shouldShowLoading && !shouldShowError && (
diff --git a/src/pages/workspace/categories/SpendCategorySelectorListItem.tsx b/src/pages/workspace/categories/SpendCategorySelectorListItem.tsx
index dd7e2e2bd751..691a66918797 100644
--- a/src/pages/workspace/categories/SpendCategorySelectorListItem.tsx
+++ b/src/pages/workspace/categories/SpendCategorySelectorListItem.tsx
@@ -15,7 +15,6 @@ function SpendCategorySelectorListItem({item, onSelectRo
return (
{
@@ -227,7 +227,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) {
return acc;
}, {});
options.push({
- icon: Expensicons.Document,
+ icon: Expensicons.Checkmark,
text: translate(disabledCategories.length === 1 ? 'workspace.categories.enableCategory' : 'workspace.categories.enableCategories'),
value: CONST.POLICY.BULK_ACTION_TYPES.ENABLE,
onSelected: () => {
diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardEditCardNamePage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardEditCardNamePage.tsx
index 476a6320b760..0b61b37ba402 100644
--- a/src/pages/workspace/companyCards/WorkspaceCompanyCardEditCardNamePage.tsx
+++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardEditCardNamePage.tsx
@@ -6,10 +6,12 @@ import InputWrapper from '@components/Form/InputWrapper';
import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
import TextInput from '@components/TextInput';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as ErrorUtils from '@libs/ErrorUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
import Navigation from '@navigation/Navigation';
@@ -39,8 +41,14 @@ function WorkspaceCompanyCardEditCardNamePage({route}: WorkspaceCompanyCardEditC
Navigation.goBack();
};
- const validate = (values: FormOnyxValues): FormInputErrors =>
- ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.NAME]);
+ const validate = (values: FormOnyxValues): FormInputErrors => {
+ const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.NAME]);
+ const length = values.name.length;
+ if (length > CONST.STANDARD_LENGTH_LIMIT) {
+ ErrorUtils.addErrorMessage(errors, INPUT_IDS.NAME, translate('common.error.characterLimitExceedCounter', {length, limit: CONST.STANDARD_LENGTH_LIMIT}));
+ }
+ return errors;
+ };
return (
Navigation.goBack(ROUTES.WORKSPACE_COMPANY_CARD_DETAILS.getRoute(policyID, cardID, bank))}
/>
+ {translate('workspace.moreFeatures.companyCards.giveItNameInstruction')}
diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx
index 4b8f34897076..1d5f2bd6dde5 100644
--- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx
+++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx
@@ -37,10 +37,11 @@ function WorkspaceCompanyCardsListHeaderButtons({policyID, selectedFeed}: Worksp
const workspaceAccountID = PolicyUtils.getWorkspaceAccountID(policyID);
const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`);
const shouldChangeLayout = isMediumScreenWidth || shouldUseNarrowLayout;
- const feedName = cardFeeds?.settings?.companyCardNicknames?.[selectedFeed] ?? CardUtils.getCardFeedName(selectedFeed);
+ const feedName = CardUtils.getCardFeedName(selectedFeed);
const formattedFeedName = translate('workspace.companyCards.feedName', {feedName});
const isCustomFeed =
CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD === selectedFeed || CONST.COMPANY_CARD.FEED_BANK_NAME.VISA === selectedFeed || CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX === selectedFeed;
+ const currentFeedData = cardFeeds?.settings?.companyCards?.[selectedFeed] ?? {pending: true, errors: {}};
return (
diff --git a/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx b/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx
index b2c95ca22f29..84e7ea5723a7 100644
--- a/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx
+++ b/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx
@@ -42,7 +42,7 @@ function CardSelectionStep({feed, policyID}: CardSelectionStepProps) {
const accountCardList = cardFeeds?.settings?.oAuthAccountDetails?.[feed]?.accountList ?? [];
const isEditing = assignCard?.isEditing;
- const assignee = assignCard?.data?.email ?? '';
+ const assigneeDisplayName = PersonalDetailsUtils.getPersonalDetailByEmail(assignCard?.data?.email ?? '')?.displayName ?? '';
const {cardList, ...cards} = list ?? {};
// We need to filter out cards which already has been assigned
const filteredCardList = Object.fromEntries(
@@ -124,6 +124,7 @@ function CardSelectionStep({feed, policyID}: CardSelectionStepProps) {
startStepIndex={listOptions.length ? 1 : undefined}
stepNames={listOptions.length ? CONST.COMPANY_CARD.STEP_NAMES : undefined}
headerTitle={translate('workspace.companyCards.assignCard')}
+ headerSubtitle={assigneeDisplayName}
>
{!listOptions.length ? (
@@ -149,7 +150,7 @@ function CardSelectionStep({feed, policyID}: CardSelectionStepProps) {
{translate('workspace.companyCards.chooseCard')}
{translate('workspace.companyCards.chooseCardFor', {
- assignee: PersonalDetailsUtils.getPersonalDetailByEmail(assignee ?? '')?.displayName ?? '',
+ assignee: assigneeDisplayName,
feed: CardUtils.getCardFeedName(feed),
})}
diff --git a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx
index ff766a1e7d70..01933f827362 100644
--- a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx
+++ b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx
@@ -36,6 +36,7 @@ function ConfirmationStep({policyID, backTo}: ConfirmationStepProps) {
const safePaddingBottomStyle = useSafePaddingBottomStyle();
const data = assignCard?.data;
+ const cardholderName = PersonalDetailsUtils.getPersonalDetailByEmail(data?.email ?? '')?.displayName ?? '';
const submit = () => {
CompanyCards.assignWorkspaceCompanyCard(policyID, data);
@@ -58,6 +59,7 @@ function ConfirmationStep({policyID, backTo}: ConfirmationStepProps) {
startStepIndex={3}
stepNames={CONST.COMPANY_CARD.STEP_NAMES}
headerTitle={translate('workspace.companyCards.assignCard')}
+ headerSubtitle={cardholderName}
>
{translate('workspace.companyCards.confirmationDescription')}
editStep(CONST.COMPANY_CARD.STEP.ASSIGNEE)}
/>
@@ -79,7 +81,7 @@ function ConfirmationStep({policyID, backTo}: ConfirmationStepProps) {
/>
editStep(CONST.COMPANY_CARD.STEP.TRANSACTION_START_DATE)}
/>
diff --git a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx
index 9c53889d6519..576a27af18c8 100644
--- a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx
+++ b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx
@@ -1,3 +1,4 @@
+import {subDays} from 'date-fns';
import React, {useMemo, useState} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
@@ -11,6 +12,7 @@ import useLocalize from '@hooks/useLocalize';
import useSafePaddingBottomStyle from '@hooks/useSafePaddingBottomStyle';
import useThemeStyles from '@hooks/useThemeStyles';
import DateUtils from '@libs/DateUtils';
+import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as CompanyCards from '@userActions/CompanyCards';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -24,6 +26,7 @@ function TransactionStartDateStep() {
const [assignCard] = useOnyx(ONYXKEYS.ASSIGN_CARD);
const isEditing = assignCard?.isEditing;
const data = assignCard?.data;
+ const assigneeDisplayName = PersonalDetailsUtils.getPersonalDetailByEmail(data?.email ?? '')?.displayName ?? '';
const [dateOptionSelected, setDateOptionSelected] = useState(data?.dateOption ?? CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING);
const [isModalOpened, setIsModalOpened] = useState(false);
@@ -53,11 +56,13 @@ function TransactionStartDateStep() {
};
const submit = () => {
+ const date90DaysBack = DateUtils.extractDate(subDays(new Date(), 90).toString());
+
CompanyCards.setAssignCardStepAndData({
currentStep: CONST.COMPANY_CARD.STEP.CONFIRMATION,
data: {
dateOption: dateOptionSelected,
- startDate,
+ startDate: dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING ? date90DaysBack : startDate,
},
isEditing: false,
});
@@ -88,6 +93,7 @@ function TransactionStartDateStep() {
startStepIndex={2}
stepNames={CONST.COMPANY_CARD.STEP_NAMES}
headerTitle={translate('workspace.companyCards.assignCard')}
+ headerSubtitle={assigneeDisplayName}
>
{translate('workspace.companyCards.chooseTransactionStartDate')}{translate('workspace.companyCards.startDateDescription')}
diff --git a/src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx b/src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx
index b48456ecce79..1362de468014 100644
--- a/src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx
+++ b/src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx
@@ -43,6 +43,7 @@ function CategorySelectorModal({policyID, isVisible, currentCategory, onCategory
style={[styles.pb0]}
includePaddingTop={false}
includeSafeAreaPaddingBottom={false}
+ shouldEnableKeyboardAvoidingView={false}
testID={CategorySelectorModal.displayName}
>
(canDisableOrDeleteSelectedRates ? disableRates() : setIsWarningModalVisible(true)),
});
}
@@ -227,7 +227,7 @@ function PolicyDistanceRatesPage({
options.push({
text: translate('workspace.distanceRates.enableRates', {count: disabledRates.length}),
value: CONST.POLICY.BULK_ACTION_TYPES.ENABLE,
- icon: Expensicons.Document,
+ icon: Expensicons.Checkmark,
onSelected: enableRates,
});
}
diff --git a/src/pages/workspace/expensifyCard/WorkspaceEditCardNamePage.tsx b/src/pages/workspace/expensifyCard/WorkspaceEditCardNamePage.tsx
index c5eb8a53930a..6a876d7de61a 100644
--- a/src/pages/workspace/expensifyCard/WorkspaceEditCardNamePage.tsx
+++ b/src/pages/workspace/expensifyCard/WorkspaceEditCardNamePage.tsx
@@ -10,6 +10,7 @@ import TextInput from '@components/TextInput';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as ErrorUtils from '@libs/ErrorUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
import Navigation from '@navigation/Navigation';
@@ -40,8 +41,14 @@ function WorkspaceEditCardNamePage({route}: WorkspaceEditCardNamePageProps) {
Navigation.goBack();
};
- const validate = (values: FormOnyxValues): FormInputErrors =>
- ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.NAME]);
+ const validate = (values: FormOnyxValues): FormInputErrors => {
+ const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.NAME]);
+ const length = values.name.length;
+ if (length > CONST.STANDARD_LENGTH_LIMIT) {
+ ErrorUtils.addErrorMessage(errors, INPUT_IDS.NAME, translate('common.error.characterLimitExceedCounter', {length, limit: CONST.STANDARD_LENGTH_LIMIT}));
+ }
+ return errors;
+ };
return (
diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx
index f296b16c13f6..c42fd980470d 100644
--- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx
+++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx
@@ -3,6 +3,7 @@ import React from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import BlockingView from '@components/BlockingViews/BlockingView';
+import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
import Button from '@components/Button';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import getBankIcon from '@components/Icon/BankIcons';
@@ -151,7 +152,10 @@ function WorkspaceExpensifyCardBankAccounts({route}: WorkspaceExpensifyCardBankA
text={translate('workspace.expensifyCard.gotIt')}
style={[styles.m5]}
pressOnEnter
- onPress={() => Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW.getRoute(policyID))}
+ onPress={() => {
+ Navigation.dismissModal();
+ Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW.getRoute(policyID));
+ }}
/>
>
);
@@ -182,6 +186,7 @@ function WorkspaceExpensifyCardBankAccounts({route}: WorkspaceExpensifyCardBankA
testID={WorkspaceExpensifyCardBankAccounts.displayName}
includeSafeAreaPaddingBottom={false}
shouldEnablePickerAvoiding={false}
+ shouldShowOfflineIndicator={false}
>
{isInVerificationState && renderVerificationStateView()}
{!isInVerificationState && (
-
- {translate('workspace.expensifyCard.chooseExistingBank')}
- {renderBankOptions()}
-
-
+
+
+ {translate('workspace.expensifyCard.chooseExistingBank')}
+ {renderBankOptions()}
+
+
+
)}
diff --git a/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx b/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx
index 26ae497406d6..d21a819540fa 100644
--- a/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx
+++ b/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx
@@ -9,6 +9,7 @@ import TextInput from '@components/TextInput';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as ErrorUtils from '@libs/ErrorUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
import * as Card from '@userActions/Card';
@@ -28,16 +29,14 @@ function CardNameStep() {
const userName = PersonalDetailsUtils.getUserNameByEmail(data?.assigneeEmail ?? '', 'firstName');
const defaultCardTitle = data?.cardType !== CONST.EXPENSIFY_CARD.CARD_TYPE.VIRTUAL ? `${userName}'s Card` : '';
- const validate = useCallback(
- (values: FormOnyxValues): FormInputErrors => {
- const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.CARD_TITLE]);
- if (!values.cardTitle) {
- errors.cardTitle = translate('common.error.fieldRequired');
- }
- return errors;
- },
- [translate],
- );
+ const validate = (values: FormOnyxValues): FormInputErrors => {
+ const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.CARD_TITLE]);
+ const length = values.cardTitle.length;
+ if (length > CONST.STANDARD_LENGTH_LIMIT) {
+ ErrorUtils.addErrorMessage(errors, INPUT_IDS.CARD_TITLE, translate('common.error.characterLimitExceedCounter', {length, limit: CONST.STANDARD_LENGTH_LIMIT}));
+ }
+ return errors;
+ };
const submit = useCallback((values: FormOnyxValues) => {
Card.setIssueNewCardStepAndData({
diff --git a/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx b/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx
index cd2f559da3fa..e9c5d8e35187 100644
--- a/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx
+++ b/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx
@@ -1,6 +1,6 @@
import type {StackScreenProps} from '@react-navigation/stack';
import {Str} from 'expensify-common';
-import React from 'react';
+import React, {useMemo} from 'react';
import {useOnyx} from 'react-native-onyx';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
@@ -11,6 +11,7 @@ import TextInput from '@components/TextInput';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import {getDefaultCompanyWebsite} from '@libs/BankAccountUtils';
import * as Url from '@libs/Url';
import * as ValidationUtils from '@libs/ValidationUtils';
import Navigation from '@navigation/Navigation';
@@ -31,10 +32,14 @@ function WorkspaceInvoicingDetailsWebsite({route}: WorkspaceInvoicingDetailsWebs
const {inputCallbackRef} = useAutoFocusInput();
const styles = useThemeStyles();
const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
+ const [session] = useOnyx(ONYXKEYS.SESSION);
+ const [user] = useOnyx(ONYXKEYS.USER);
+ const defaultWebsiteExample = useMemo(() => getDefaultCompanyWebsite(session, user), [session, user]);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const submit = (values: FormOnyxValues) => {
- Policy.updateInvoiceCompanyWebsite(policyID, values[INPUT_IDS.COMPANY_WEBSITE]);
+ const companyWebsite = Str.sanitizeURL(values[INPUT_IDS.COMPANY_WEBSITE], CONST.COMPANY_WEBSITE_DEFAULT_SCHEME);
+ Policy.updateInvoiceCompanyWebsite(policyID, companyWebsite);
Navigation.goBack();
};
@@ -44,7 +49,7 @@ function WorkspaceInvoicingDetailsWebsite({route}: WorkspaceInvoicingDetailsWebs
const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.COMPANY_WEBSITE]);
if (values.companyWebsite) {
- if (!ValidationUtils.isValidWebsite(values.companyWebsite)) {
+ if (!ValidationUtils.isValidWebsite(Str.sanitizeURL(values.companyWebsite, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME))) {
errors.companyWebsite = translate('bankAccount.error.website');
} else {
const domain = Url.extractUrlDomain(values.companyWebsite);
@@ -86,7 +91,7 @@ function WorkspaceInvoicingDetailsWebsite({route}: WorkspaceInvoicingDetailsWebs
label={translate('workspace.invoices.companyWebsite')}
accessibilityLabel={translate('workspace.invoices.companyWebsite')}
role={CONST.ROLE.PRESENTATION}
- defaultValue={policy?.invoice?.companyWebsite}
+ defaultValue={policy?.invoice?.companyWebsite ?? defaultWebsiteExample}
ref={inputCallbackRef}
inputMode={CONST.INPUT_MODE.URL}
/>
diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
index 6d865df7280b..e079fdee90a0 100644
--- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
+++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
@@ -328,8 +328,8 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM
displayInDefaultIconColor
iconStyles={styles.cardIcon}
contentFit="contain"
- iconWidth={variables.cardIconWidth}
- iconHeight={variables.cardIconHeight}
+ iconWidth={variables.iconSizeExtraLarge}
+ iconHeight={variables.iconSizeExtraLarge}
onPress={() => navigateToDetails(memberCard)}
shouldShowRightIcon
/>
diff --git a/src/pages/workspace/reportFields/CreateReportFieldsPage.tsx b/src/pages/workspace/reportFields/CreateReportFieldsPage.tsx
index 8dbd90c9e929..461c5122c5d4 100644
--- a/src/pages/workspace/reportFields/CreateReportFieldsPage.tsx
+++ b/src/pages/workspace/reportFields/CreateReportFieldsPage.tsx
@@ -52,11 +52,11 @@ function CreateReportFieldsPage({
ReportField.createReportField(policyID, {
name: values[INPUT_IDS.NAME],
type: values[INPUT_IDS.TYPE],
- initialValue: values[INPUT_IDS.INITIAL_VALUE],
+ initialValue: !(values[INPUT_IDS.TYPE] === CONST.REPORT_FIELD_TYPES.LIST && availableListValuesLength === 0) ? values[INPUT_IDS.INITIAL_VALUE] : '',
});
Navigation.goBack();
},
- [policyID],
+ [availableListValuesLength, policyID],
);
const validateForm = useCallback(
diff --git a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx
index 9eb2b020f010..a09d1c167760 100644
--- a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx
+++ b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx
@@ -190,7 +190,7 @@ function ReportFieldsListValuesPage({
}, []);
options.push({
- icon: Expensicons.DocumentSlash,
+ icon: Expensicons.Close,
text: translate(enabledValues.length === 1 ? 'workspace.reportFields.disableValue' : 'workspace.reportFields.disableValues'),
value: CONST.POLICY.BULK_ACTION_TYPES.DISABLE,
onSelected: () => {
@@ -222,7 +222,7 @@ function ReportFieldsListValuesPage({
}, []);
options.push({
- icon: Expensicons.Document,
+ icon: Expensicons.Checkmark,
text: translate(disabledValues.length === 1 ? 'workspace.reportFields.enableValue' : 'workspace.reportFields.enableValues'),
value: CONST.POLICY.BULK_ACTION_TYPES.ENABLE,
onSelected: () => {
diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx
index a267d09051be..570dab461c78 100644
--- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx
+++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx
@@ -253,7 +253,7 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) {
if (enabledTagCount > 0) {
options.push({
- icon: Expensicons.DocumentSlash,
+ icon: Expensicons.Close,
text: translate(enabledTagCount === 1 ? 'workspace.tags.disableTag' : 'workspace.tags.disableTags'),
value: CONST.POLICY.BULK_ACTION_TYPES.DISABLE,
onSelected: () => {
@@ -265,7 +265,7 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) {
if (disabledTagCount > 0) {
options.push({
- icon: Expensicons.Document,
+ icon: Expensicons.Checkmark,
text: translate(disabledTagCount === 1 ? 'workspace.tags.enableTag' : 'workspace.tags.enableTags'),
value: CONST.POLICY.BULK_ACTION_TYPES.ENABLE,
onSelected: () => {
diff --git a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx
index 4db8033c1c11..89e68eb7dc3b 100644
--- a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx
+++ b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx
@@ -193,7 +193,7 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) {
if (enabledTagCount > 0) {
options.push({
- icon: Expensicons.DocumentSlash,
+ icon: Expensicons.Close,
text: translate(enabledTagCount === 1 ? 'workspace.tags.disableTag' : 'workspace.tags.disableTags'),
value: CONST.POLICY.BULK_ACTION_TYPES.DISABLE,
onSelected: () => {
@@ -205,7 +205,7 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) {
if (disabledTagCount > 0) {
options.push({
- icon: Expensicons.Document,
+ icon: Expensicons.Checkmark,
text: translate(disabledTagCount === 1 ? 'workspace.tags.enableTag' : 'workspace.tags.enableTags'),
value: CONST.POLICY.BULK_ACTION_TYPES.ENABLE,
onSelected: () => {
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
index ea7513ae7df2..207ecfc30706 100644
--- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
+++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
@@ -207,7 +207,7 @@ function WorkspaceTaxesPage({
// `Disable rates` when at least one enabled rate is selected.
if (selectedTaxesIDs.some((taxID) => !policy?.taxRates?.taxes[taxID]?.isDisabled)) {
options.push({
- icon: Expensicons.DocumentSlash,
+ icon: Expensicons.Close,
text: isMultiple ? translate('workspace.taxes.actions.disableMultiple') : translate('workspace.taxes.actions.disable'),
value: CONST.POLICY.BULK_ACTION_TYPES.DISABLE,
onSelected: () => toggleTaxes(false),
@@ -217,7 +217,7 @@ function WorkspaceTaxesPage({
// `Enable rates` when at least one disabled rate is selected.
if (selectedTaxesIDs.some((taxID) => policy?.taxRates?.taxes[taxID]?.isDisabled)) {
options.push({
- icon: Expensicons.Document,
+ icon: Expensicons.Checkmark,
text: isMultiple ? translate('workspace.taxes.actions.enableMultiple') : translate('workspace.taxes.actions.enable'),
value: CONST.POLICY.BULK_ACTION_TYPES.ENABLE,
onSelected: () => toggleTaxes(true),
diff --git a/src/styles/index.ts b/src/styles/index.ts
index a8e58b047658..b08987459a1a 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -1798,6 +1798,13 @@ const styles = (theme: ThemeColors) =>
alignItems: 'center',
},
+ popoverIconCircle: {
+ backgroundColor: theme.buttonDefaultBG,
+ borderRadius: variables.buttonBorderRadius,
+ height: variables.h40,
+ width: variables.w46,
+ },
+
rightLabelMenuItem: {
fontSize: variables.fontSizeLabel,
color: theme.textSupporting,
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index 65faa941866a..070e78265ff2 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -1119,6 +1119,22 @@ function getAmountWidth(amount: string): number {
return width;
}
+function getItemBackgroundColorStyle(isSelected: boolean, isFocused: boolean, isDisabled: boolean, selectedBG: string, focusedBG: string): ViewStyle {
+ let backgroundColor;
+
+ if (isDisabled) {
+ backgroundColor = undefined;
+ } else if (isSelected) {
+ backgroundColor = selectedBG;
+ } else if (isFocused) {
+ backgroundColor = focusedBG;
+ }
+
+ return {
+ backgroundColor,
+ };
+}
+
const staticStyleUtils = {
positioning,
combineStyles,
@@ -1193,6 +1209,7 @@ const staticStyleUtils = {
getAmountWidth,
getBorderRadiusStyle,
getHighResolutionInfoWrapperStyle,
+ getItemBackgroundColorStyle,
};
const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index d647f3433446..164b161d8824 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -266,6 +266,7 @@ export default {
h20: 20,
h28: 28,
h36: 36,
+ h40: 40,
h112: 112,
h172: 172,
w20: 20,
@@ -273,6 +274,7 @@ export default {
w36: 36,
w40: 40,
w44: 44,
+ w46: 46,
w52: 52,
w80: 80,
w92: 92,
diff --git a/src/types/onyx/Account.ts b/src/types/onyx/Account.ts
index b856ed9010dd..3902d67882c4 100644
--- a/src/types/onyx/Account.ts
+++ b/src/types/onyx/Account.ts
@@ -20,9 +20,6 @@ type Delegate = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** Whether the user validation code was sent */
validateCodeSent?: boolean;
- /** Field-specific server side errors keyed by microtime */
- errorFields?: OnyxCommon.ErrorFields;
-
/** Whether the user is loading */
isLoading?: boolean;
@@ -30,6 +27,24 @@ type Delegate = OnyxCommon.OnyxValueWithOfflineFeedback<{
optimisticAccountID?: number;
}>;
+/** Delegate errors */
+type DelegateErrors = {
+ /** Errors while adding a delegate keyed by email */
+ addDelegate?: Record;
+
+ /** Errors while updating a delegate's role keyed by email */
+ updateDelegateRole?: Record;
+
+ /** Errors while removing a delegate keyed by email */
+ removeDelegate?: Record;
+
+ /** Errors while connecting as a delegate keyed by email */
+ connect?: Record;
+
+ /** Errors while disconnecting as a delegate. No email needed here. */
+ disconnect?: OnyxCommon.Errors;
+};
+
/** Model of delegated access data */
type DelegatedAccess = {
/** The users that can access your account as a delegate */
@@ -41,8 +56,8 @@ type DelegatedAccess = {
/** The email of original user when they are acting as a delegate for another account */
delegate?: string;
- /** Authentication failure errors when disconnecting as a copilot */
- errorFields?: OnyxCommon.ErrorFields;
+ /** Field-specific server side errors keyed by microtime */
+ errorFields?: DelegateErrors;
};
/** Model of user account */
diff --git a/src/types/onyx/Onboarding.ts b/src/types/onyx/Onboarding.ts
index 4b6a52f25cb4..2cf8eccba1c2 100644
--- a/src/types/onyx/Onboarding.ts
+++ b/src/types/onyx/Onboarding.ts
@@ -11,6 +11,9 @@ type Onboarding = {
/** A string that informs which qualifier the user selected during sign up */
signupQualifier: ValueOf;
+
+ /** A Boolean that tells whether the user has seen navattic tour */
+ selfTourViewed?: boolean;
};
export default Onboarding;
diff --git a/src/types/onyx/ReviewDuplicates.ts b/src/types/onyx/ReviewDuplicates.ts
index 0682ed0a7f7c..6c5ccbd93481 100644
--- a/src/types/onyx/ReviewDuplicates.ts
+++ b/src/types/onyx/ReviewDuplicates.ts
@@ -8,6 +8,9 @@ type ReviewDuplicates = {
/** ID of transaction we want to keep */
transactionID: string;
+ /** ID of the transaction report we want to keep */
+ reportID: string;
+
/** Merchant which user want to keep */
merchant: string;
diff --git a/src/types/onyx/User.ts b/src/types/onyx/User.ts
index 56b7a83d1618..eb5f1d888c46 100644
--- a/src/types/onyx/User.ts
+++ b/src/types/onyx/User.ts
@@ -6,9 +6,6 @@ type User = {
/** Whether we should use the staging version of the secure API server */
shouldUseStagingServer?: boolean;
- /** Whether user muted all sounds in application */
- isMutedAllSounds?: boolean;
-
/** Is the user account validated? */
validated: boolean;
diff --git a/tests/perf-test/SearchRouter.perf-test.tsx b/tests/perf-test/SearchRouter.perf-test.tsx
index e5a0b2e30533..4154e80ab6b8 100644
--- a/tests/perf-test/SearchRouter.perf-test.tsx
+++ b/tests/perf-test/SearchRouter.perf-test.tsx
@@ -52,6 +52,8 @@ jest.mock('@react-navigation/native', () => {
useFocusEffect: jest.fn(),
useIsFocused: () => true,
useRoute: () => jest.fn(),
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ UNSTABLE_usePreventRemove: () => jest.fn(),
useNavigation: () => ({
navigate: jest.fn(),
addListener: () => jest.fn(),
diff --git a/tests/perf-test/SidebarUtils.perf-test.ts b/tests/perf-test/SidebarUtils.perf-test.ts
index 4a6b12d726d9..4ea4e1d04b50 100644
--- a/tests/perf-test/SidebarUtils.perf-test.ts
+++ b/tests/perf-test/SidebarUtils.perf-test.ts
@@ -2,7 +2,6 @@ import {rand} from '@ngneat/falso';
import type {OnyxCollection} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import {measureFunction} from 'reassure';
-import {getReportActionMessage} from '@libs/ReportActionsUtils';
import SidebarUtils from '@libs/SidebarUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -53,25 +52,6 @@ const policies = createCollection(
const mockedBetas = Object.values(CONST.BETAS);
-const allReportActions = Object.fromEntries(
- Object.keys(reportActions).map((key) => [
- `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${key}`,
- [
- {
- errors: reportActions[key].errors ?? [],
- message: [
- {
- moderationDecision: {
- decision: getReportActionMessage(reportActions[key])?.moderationDecision?.decision,
- },
- },
- ],
- reportActionID: reportActions[key].reportActionID,
- },
- ],
- ]),
-) as unknown as OnyxCollection;
-
const currentReportId = '1';
const transactionViolations = {} as OnyxCollection;
@@ -114,13 +94,11 @@ describe('SidebarUtils', () => {
test('[SidebarUtils] getOrderedReportIDs on 15k reports for default priorityMode', async () => {
await waitForBatchedUpdates();
- await measureFunction(() =>
- SidebarUtils.getOrderedReportIDs(currentReportId, allReports, mockedBetas, policies, CONST.PRIORITY_MODE.DEFAULT, allReportActions, transactionViolations),
- );
+ await measureFunction(() => SidebarUtils.getOrderedReportIDs(currentReportId, allReports, mockedBetas, policies, CONST.PRIORITY_MODE.DEFAULT, transactionViolations));
});
test('[SidebarUtils] getOrderedReportIDs on 15k reports for GSD priorityMode', async () => {
await waitForBatchedUpdates();
- await measureFunction(() => SidebarUtils.getOrderedReportIDs(currentReportId, allReports, mockedBetas, policies, CONST.PRIORITY_MODE.GSD, allReportActions, transactionViolations));
+ await measureFunction(() => SidebarUtils.getOrderedReportIDs(currentReportId, allReports, mockedBetas, policies, CONST.PRIORITY_MODE.GSD, transactionViolations));
});
});
diff --git a/tests/unit/NetworkTest.ts b/tests/unit/NetworkTest.ts
index aef5204ffc4d..e482cc3261d4 100644
--- a/tests/unit/NetworkTest.ts
+++ b/tests/unit/NetworkTest.ts
@@ -1,5 +1,7 @@
import type {Mock} from 'jest-mock';
+import type {OnyxEntry} from 'react-native-onyx';
import MockedOnyx from 'react-native-onyx';
+import * as App from '@libs/actions/App';
import CONST from '@src/CONST';
import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager';
import * as PersistedRequests from '@src/libs/actions/PersistedRequests';
@@ -12,6 +14,7 @@ import * as MainQueue from '@src/libs/Network/MainQueue';
import * as NetworkStore from '@src/libs/Network/NetworkStore';
import NetworkConnection from '@src/libs/NetworkConnection';
import ONYXKEYS from '@src/ONYXKEYS';
+import type {Session as OnyxSession} from '@src/types/onyx';
import type ReactNativeOnyxMock from '../../__mocks__/react-native-onyx';
import * as TestHelper from '../utils/TestHelper';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
@@ -50,7 +53,7 @@ afterEach(() => {
});
describe('NetworkTests', () => {
- test('failing to reauthenticate while offline should not log out user', () => {
+ test('failing to reauthenticate should not log out user', () => {
// Given a test user login and account ID
const TEST_USER_LOGIN = 'test@testguy.com';
const TEST_USER_ACCOUNT_ID = 1;
@@ -130,6 +133,80 @@ describe('NetworkTests', () => {
});
});
+ test('failing to reauthenticate while offline should not log out user', async () => {
+ const TEST_USER_LOGIN = 'test@testguy.com';
+ const TEST_USER_ACCOUNT_ID = 1;
+
+ let session: OnyxEntry;
+ Onyx.connect({
+ key: ONYXKEYS.SESSION,
+ callback: (val) => (session = val),
+ });
+
+ Onyx.connect({
+ key: ONYXKEYS.NETWORK,
+ });
+
+ await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN);
+ await waitForBatchedUpdates();
+
+ expect(session?.authToken).not.toBeUndefined();
+
+ // Turn off the network
+ await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true});
+
+ const mockedXhr = jest.fn();
+ mockedXhr
+ // Call ReconnectApp with an expired token
+ .mockImplementationOnce(() =>
+ Promise.resolve({
+ jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED,
+ }),
+ )
+ // Call Authenticate
+ .mockImplementationOnce(() =>
+ Promise.resolve({
+ jsonCode: CONST.JSON_CODE.SUCCESS,
+ authToken: 'newAuthToken',
+ }),
+ )
+ // Call ReconnectApp again, it should connect with a new token
+ .mockImplementationOnce(() =>
+ Promise.resolve({
+ jsonCode: CONST.JSON_CODE.SUCCESS,
+ }),
+ );
+
+ HttpUtils.xhr = mockedXhr;
+
+ // Initiate the requests
+ App.confirmReadyToOpenApp();
+ App.reconnectApp();
+ await waitForBatchedUpdates();
+
+ // Turn the network back online
+ await Onyx.set(ONYXKEYS.NETWORK, {isOffline: false});
+
+ // Filter requests results by request name
+ const reconnectResults = (HttpUtils.xhr as Mock).mock.results.filter((_, index) => (HttpUtils.xhr as Mock)?.mock?.calls?.at(index)?.[0] === 'ReconnectApp');
+ const authenticateResults = (HttpUtils.xhr as Mock).mock.results.filter((_, index) => (HttpUtils.xhr as Mock)?.mock?.calls?.at(index)?.[0] === 'Authenticate');
+
+ // Get the response code of Authenticate call
+ const authenticateResponse = await (authenticateResults?.at(0)?.value as Promise<{jsonCode: string}>);
+
+ // Get the response code of the second Reconnect call
+ const reconnectResponse = await (reconnectResults?.at(1)?.value as Promise<{jsonCode: string}>);
+
+ // Authenticate request should return 200
+ expect(authenticateResponse.jsonCode).toBe(CONST.JSON_CODE.SUCCESS);
+
+ // The second ReconnectApp should return 200
+ expect(reconnectResponse.jsonCode).toBe(CONST.JSON_CODE.SUCCESS);
+
+ // check if the user is still logged in
+ expect(session?.authToken).not.toBeUndefined();
+ });
+
test('consecutive API calls eventually succeed when authToken is expired', () => {
// Given a test user login and account ID
const TEST_USER_LOGIN = 'test@testguy.com';
diff --git a/tests/unit/Search/getQueryWithSubstitutionsTest.ts b/tests/unit/Search/getQueryWithSubstitutionsTest.ts
new file mode 100644
index 000000000000..8ca2eec31256
--- /dev/null
+++ b/tests/unit/Search/getQueryWithSubstitutionsTest.ts
@@ -0,0 +1,92 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+// we need "dirty" object key names in these tests
+import {getQueryWithSubstitutions} from '@src/components/Search/SearchRouter/getQueryWithSubstitutions';
+
+describe('getQueryWithSubstitutions should compute and return correct new query', () => {
+ test('when both queries contain no substitutions', () => {
+ // given this previous query: "foo"
+ const userTypedQuery = 'foo bar';
+ const substitutionsMock = {};
+
+ const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock);
+
+ expect(result).toBe('foo bar');
+ });
+
+ test('when query has a substitution and plain text was added after it', () => {
+ // given this previous query: "foo from:@mateusz"
+ const userTypedQuery = 'foo from:Mat test';
+ const substitutionsMock = {
+ 'from:Mat': '@mateusz',
+ };
+
+ const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock);
+
+ expect(result).toBe('foo from:@mateusz test');
+ });
+
+ test('when query has a substitution and plain text was added after before it', () => {
+ // given this previous query: "foo from:@mateusz1"
+ const userTypedQuery = 'foo bar from:Mat1';
+ const substitutionsMock = {
+ 'from:Mat1': '@mateusz1',
+ };
+
+ const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock);
+
+ expect(result).toBe('foo bar from:@mateusz1');
+ });
+
+ test('when query has a substitution and then it was removed', () => {
+ // given this previous query: "foo from:@mateusz"
+ const userTypedQuery = 'foo from:Ma';
+ const substitutionsMock = {
+ 'from:Mat': '@mateusz',
+ };
+
+ const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock);
+
+ expect(result).toBe('foo from:Ma');
+ });
+
+ test('when query has a substitution and then it was changed', () => {
+ // given this previous query: "foo from:@mateusz1"
+ const userTypedQuery = 'foo from:Maat1';
+ const substitutionsMock = {
+ 'from:Mat1': '@mateusz1',
+ };
+
+ const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock);
+
+ expect(result).toBe('foo from:Maat1');
+ });
+
+ test('when query has multiple substitutions and one was changed on the last position', () => {
+ // given this previous query: "foo in:123,456 from:@jakub"
+ // oldHumanReadableQ = 'foo in:admin,admins from:Jakub'
+ const userTypedQuery = 'foo in:admin,admins from:Jakub2';
+ const substitutionsMock = {
+ 'in:admin': '123',
+ 'in:admins': '456',
+ 'from:Jakub': '@jakub',
+ };
+
+ const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock);
+
+ expect(result).toBe('foo in:123,456 from:Jakub2');
+ });
+
+ test('when query has multiple substitutions and one was changed in the middle', () => {
+ // given this previous query: "foo in:aabbccdd123,zxcv123 from:@jakub"
+ const userTypedQuery = 'foo in:wave2,waveControl from:zzzz';
+
+ const substM = {
+ 'in:wave': 'aabbccdd123',
+ 'in:waveControl': 'zxcv123',
+ };
+
+ const result = getQueryWithSubstitutions(userTypedQuery, substM);
+
+ expect(result).toBe('foo in:wave2,zxcv123 from:zzzz');
+ });
+});
diff --git a/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts b/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts
new file mode 100644
index 000000000000..43829af9f873
--- /dev/null
+++ b/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts
@@ -0,0 +1,55 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+// we need "dirty" object key names in these tests
+import {getUpdatedSubstitutionsMap} from '@src/components/Search/SearchRouter/getUpdatedSubstitutionsMap';
+
+describe('getUpdatedSubstitutionsMap should return updated and cleaned substitutions map', () => {
+ test('when there were no substitutions', () => {
+ const userTypedQuery = 'foo bar';
+ const substitutionsMock = {};
+
+ const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock);
+
+ expect(result).toStrictEqual({});
+ });
+
+ test('when query has a substitution and it did not change', () => {
+ const userTypedQuery = 'foo from:Mat';
+ const substitutionsMock = {
+ 'from:Mat': '@mateusz',
+ };
+
+ const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock);
+
+ expect(result).toStrictEqual({
+ 'from:Mat': '@mateusz',
+ });
+ });
+
+ test('when query has a substitution and it changed', () => {
+ const userTypedQuery = 'foo from:Johnny';
+ const substitutionsMock = {
+ 'from:Steven': '@steven',
+ };
+
+ const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock);
+
+ expect(result).toStrictEqual({});
+ });
+
+ test('when query has multiple substitutions and some changed but some stayed', () => {
+ const userTypedQuery = 'from:Johnny to:Steven category:Fruitzzzz';
+ const substitutionsMock = {
+ 'from:Johnny': '@johnny',
+ 'to:Steven': '@steven',
+ 'from:OldName': '@oldName',
+ 'category:Fruit': '123456',
+ };
+
+ const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock);
+
+ expect(result).toStrictEqual({
+ 'from:Johnny': '@johnny',
+ 'to:Steven': '@steven',
+ });
+ });
+});