diff --git a/.github/actions/composite/setupGitForOSBotify/action.yml b/.github/actions/composite/setupGitForOSBotify/action.yml
index 456cef93676a..9495ba07b0ac 100644
--- a/.github/actions/composite/setupGitForOSBotify/action.yml
+++ b/.github/actions/composite/setupGitForOSBotify/action.yml
@@ -2,20 +2,25 @@ name: 'Setup Git for OSBotify'
description: 'Setup Git for OSBotify'
inputs:
- GPG_PASSPHRASE:
- description: 'Passphrase used to decrypt GPG key'
+ OP_SERVICE_ACCOUNT_TOKEN:
+ description: "1Password service account token"
required: true
runs:
using: composite
steps:
- - name: Decrypt OSBotify GPG key
- run: cd .github/workflows && gpg --quiet --batch --yes --decrypt --passphrase=${{ inputs.GPG_PASSPHRASE }} --output OSBotify-private-key.asc OSBotify-private-key.asc.gpg
+ - name: Install 1Password CLI
+ uses: 1password/install-cli-action@v1
+
+ - name: Load files from 1Password
shell: bash
+ env:
+ OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.OP_SERVICE_ACCOUNT_TOKEN }}
+ run: op read "op://Mobile-Deploy-CI/OSBotify-private-key.asc/OSBotify-private-key.asc" --force --out-file ./OSBotify-private-key.asc
- name: Import OSBotify GPG Key
shell: bash
- run: cd .github/workflows && gpg --import OSBotify-private-key.asc
+ run: gpg --import OSBotify-private-key.asc
- name: Set up git for OSBotify
shell: bash
@@ -24,8 +29,3 @@ runs:
git config --global commit.gpgsign true
git config --global user.name OSBotify
git config --global user.email infra+osbotify@expensify.com
-
- - name: Enable debug logs for git
- shell: bash
- if: runner.debug == '1'
- run: echo "GIT_TRACE=true" >> "$GITHUB_ENV"
diff --git a/.github/actions/composite/setupGitForOSBotifyApp/action.yml b/.github/actions/composite/setupGitForOSBotifyApp/action.yml
index 404ddc55e954..128dcf48e934 100644
--- a/.github/actions/composite/setupGitForOSBotifyApp/action.yml
+++ b/.github/actions/composite/setupGitForOSBotifyApp/action.yml
@@ -5,8 +5,8 @@ name: "Setup Git for OSBotify"
description: "Setup Git for OSBotify"
inputs:
- GPG_PASSPHRASE:
- description: "Passphrase used to decrypt GPG key"
+ OP_SERVICE_ACCOUNT_TOKEN:
+ description: "1Password service account token"
required: true
OS_BOTIFY_APP_ID:
description: "Application ID for OS Botify"
@@ -24,28 +24,18 @@ outputs:
runs:
using: composite
steps:
- - name: Check if gpg encrypted private key is present
- id: key_check
- shell: bash
- run: |
- if [[ -f .github/workflows/OSBotify-private-key.asc.gpg ]]; then
- echo "key_exists=true" >> "$GITHUB_OUTPUT"
- fi
-
- - name: Checkout
- uses: actions/checkout@v4
- if: steps.key_check.outputs.key_exists != 'true'
- with:
- sparse-checkout: |
- .github
+ - name: Install 1Password CLI
+ uses: 1password/install-cli-action@v1
- - name: Decrypt OSBotify GPG key
- run: cd .github/workflows && gpg --quiet --batch --yes --decrypt --passphrase=${{ inputs.GPG_PASSPHRASE }} --output OSBotify-private-key.asc OSBotify-private-key.asc.gpg
+ - name: Load files from 1Password
shell: bash
+ env:
+ OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.OP_SERVICE_ACCOUNT_TOKEN }}
+ run: op read "op://Mobile-Deploy-CI/OSBotify-private-key.asc/OSBotify-private-key.asc" --force --out-file ./OSBotify-private-key.asc
- name: Import OSBotify GPG Key
shell: bash
- run: cd .github/workflows && gpg --import OSBotify-private-key.asc
+ run: gpg --import OSBotify-private-key.asc
- name: Set up git for OSBotify
shell: bash
@@ -55,11 +45,6 @@ runs:
git config user.name OSBotify
git config user.email infra+osbotify@expensify.com
- - name: Enable debug logs for git
- shell: bash
- if: runner.debug == '1'
- run: echo "GIT_TRACE=true" >> "$GITHUB_ENV"
-
- name: Sync clock
shell: bash
run: sudo sntp -sS time.windows.com
diff --git a/.github/actions/javascript/getPullRequestDetails/getPullRequestDetails.ts b/.github/actions/javascript/getPullRequestDetails/getPullRequestDetails.ts
index 3ff0de1b3bb2..8e285389e3f7 100644
--- a/.github/actions/javascript/getPullRequestDetails/getPullRequestDetails.ts
+++ b/.github/actions/javascript/getPullRequestDetails/getPullRequestDetails.ts
@@ -54,6 +54,7 @@ GithubUtils.octokit.pulls
.then(({data: PR}) => {
if (!isEmptyObject(PR)) {
console.log(`Found matching pull request: ${PR.html_url}`);
+ console.log(`Pull request details: ${JSON.stringify(PR)}}`);
core.setOutput('MERGE_COMMIT_SHA', PR.merge_commit_sha);
core.setOutput('HEAD_COMMIT_SHA', PR.head?.sha);
core.setOutput('IS_MERGED', PR.merged);
diff --git a/.github/actions/javascript/getPullRequestDetails/index.js b/.github/actions/javascript/getPullRequestDetails/index.js
index fa7e7e00e137..07baf462086c 100644
--- a/.github/actions/javascript/getPullRequestDetails/index.js
+++ b/.github/actions/javascript/getPullRequestDetails/index.js
@@ -11502,6 +11502,7 @@ GithubUtils_1.default.octokit.pulls
.then(({ data: PR }) => {
if (!(0, EmptyObject_1.isEmptyObject)(PR)) {
console.log(`Found matching pull request: ${PR.html_url}`);
+ console.log(`Pull request details: ${JSON.stringify(PR)}}`);
core.setOutput('MERGE_COMMIT_SHA', PR.merge_commit_sha);
core.setOutput('HEAD_COMMIT_SHA', PR.head?.sha);
core.setOutput('IS_MERGED', PR.merged);
diff --git a/.github/workflows/OSBotify-private-key.asc.gpg b/.github/workflows/OSBotify-private-key.asc.gpg
deleted file mode 100644
index 03f06222d0fe..000000000000
Binary files a/.github/workflows/OSBotify-private-key.asc.gpg and /dev/null differ
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index 45dacacd0b16..3f32b2bfa1fe 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -80,14 +80,6 @@ git fetch origin tag 1.0.1-0 --no-tags --shallow-exclude=1.0.0-0 # This will fet
## Secrets
The GitHub workflows require a large list of secrets to deploy, notify and test the code:
-1. `LARGE_SECRET_PASSPHRASE` - decrypts secrets stored in various encrypted files stored in GitHub repository. To create updated versions of these encrypted files, refer to steps 1-4 of [this encrypted secrets help page](https://docs.github.com/en/actions/reference/encrypted-secrets#limits-for-secrets) using the `LARGE_SECRET_PASSPHRASE`.
- 1. `android/app/my-upload-key.keystore.gpg`
- 1. `android/app/android-fastlane-json-key.json.gpg`
- 1. `ios/NewApp_AdHoc.mobileprovision`
- 1. `ios/NewApp_AdHoc_Notification_Service.mobileprovision`
- 1. `ios/NewApp_AppStore.mobileprovision.gpg`
- 1. `ios/NewApp_AppStore_Notification_Service.mobileprovision.gpg`
- 1. `ios/Certificates.p12.gpg`
1. `SLACK_WEBHOOK` - Sends Slack notifications via Slack WebHook https://expensify.slack.com/services/B01AX48D7MM
1. `OS_BOTIFY_TOKEN` - Personal access token for @OSBotify user in GitHub
1. `CLA_BOTIFY_TOKEN` - Personal access token for @CLABotify user in GitHub
@@ -105,10 +97,15 @@ The GitHub workflows require a large list of secrets to deploy, notify and test
1. `APPLE_DEMO_PASSWORD` - Demo account password used for https://appstoreconnect.apple.com/
1. `BROWSERSTACK` - Used to access Browserstack's API
+We use 1Password for many secrets and in general use two different actions from 1Password to fetch secrets:
+
+1. `1password/install-cli-action` - This action is used to install 1Password cli `op` and is used to grab **files** using the `op read` command.
+1. `1password/load-secrets-action` - This action is used to fetch **strings** from 1Password.
+
### Important note about Secrets
Secrets are available by default in most workflows. The exception to the rule is callable workflows. If a workflow is triggered by the `workflow_call` event, it will only have access to repo secrets if the workflow that called it passed in the secrets explicitly (for example, using `secrets: inherit`).
-Furthermore, secrets are not accessible in actions. If you need to access a secret in an action, you must declare it as an input and pass it in. GitHub _should_ still obfuscate the value of the secret in workflow run logs.
+Furthermore, secrets are not accessible in actions. If you need to access a secret in an action, _you must declare it as an input and pass it in_. GitHub _should_ still obfuscate the value of the secret in workflow run logs.
## Actions
diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml
index 5cb0a99730c9..b7dcf95294be 100644
--- a/.github/workflows/cherryPick.yml
+++ b/.github/workflows/cherryPick.yml
@@ -45,7 +45,7 @@ jobs:
id: setupGitForOSBotify
uses: ./.github/actions/composite/setupGitForOSBotifyApp
with:
- GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
+ OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }}
OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }}
@@ -56,14 +56,19 @@ jobs:
SEMVER_LEVEL: "PATCH"
- name: Fetch history of relevant refs
- run: |
- git fetch origin main staging --no-tags --shallow-exclude ${{ steps.getPreviousVersion.outputs.PREVIOUS_VERSION }}
+ run: git fetch origin main staging --no-tags --shallow-exclude ${{ steps.getPreviousVersion.outputs.PREVIOUS_VERSION }}
- name: Get version bump commit
id: getVersionBumpCommit
run: |
git switch main
VERSION_BUMP_COMMIT="$(git log --format='%H' --author='OSBotify' --grep 'Update version to ${{ needs.createNewVersion.outputs.NEW_VERSION }}')"
+ if [ -z "$VERSION_BUMP_COMMIT" ]; then
+ echo "::error::❌ Could not find version bump commit for ${{ needs.createNewVersion.outputs.NEW_VERSION }}"
+ git log --oneline
+ else
+ echo "::notice::👀 Found version bump commit $VERSION_BUMP_COMMIT"
+ fi
echo "VERSION_BUMP_SHA=$VERSION_BUMP_COMMIT" >> "$GITHUB_OUTPUT"
- name: Get merge commit for pull request to CP
diff --git a/.github/workflows/createNewVersion.yml b/.github/workflows/createNewVersion.yml
index 85c928707c6c..a933c2f1686f 100644
--- a/.github/workflows/createNewVersion.yml
+++ b/.github/workflows/createNewVersion.yml
@@ -23,15 +23,15 @@ on:
value: ${{ jobs.createNewVersion.outputs.NEW_VERSION }}
secrets:
- LARGE_SECRET_PASSPHRASE:
- description: Passphrase used to decrypt GPG key
- required: true
SLACK_WEBHOOK:
description: Webhook used to comment in slack
required: true
OS_BOTIFY_COMMIT_TOKEN:
description: OSBotify personal access token, used to workaround committing to protected branch
required: true
+ OP_SERVICE_ACCOUNT_TOKEN:
+ description: 1Password service account token
+ required: true
jobs:
validateActor:
@@ -74,7 +74,7 @@ jobs:
uses: ./.github/actions/composite/setupGitForOSBotify
id: setupGitForOSBotify
with:
- GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
+ OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
- name: Generate new E/App version
id: bumpVersion
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 99c38a2d2e5b..4b4ea2413ae5 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -45,7 +45,7 @@ jobs:
uses: ./.github/actions/composite/setupGitForOSBotifyApp
id: setupGitForOSBotify
with:
- GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
+ OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }}
OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }}
@@ -301,10 +301,15 @@ jobs:
- name: Setup Node
uses: ./.github/actions/composite/setupNode
- - name: Decrypt Developer ID Certificate
- run: cd desktop && gpg --quiet --batch --yes --decrypt --passphrase="$DEVELOPER_ID_SECRET_PASSPHRASE" --output developer_id.p12 developer_id.p12.gpg
+ - name: Load Desktop credentials from 1Password
+ id: load-credentials
+ uses: 1password/load-secrets-action@v2
+ with:
+ export-env: false
env:
- DEVELOPER_ID_SECRET_PASSPHRASE: ${{ secrets.DEVELOPER_ID_SECRET_PASSPHRASE }}
+ OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
+ DESKTOP_CERTIFICATE_BASE64: "op://Mobile-Deploy-CI/Desktop Certificates.p12/CSC_LINK"
+ DESKTOP_CERTIFICATE_PASSWORD: "op://Mobile-Deploy-CI/Desktop Certificates.p12/CSC_KEY_PASSWORD"
- name: Build desktop app
run: |
@@ -314,8 +319,8 @@ jobs:
npm run desktop-build-staging
fi
env:
- CSC_LINK: ${{ secrets.CSC_LINK }}
- CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
+ CSC_LINK: ${{ steps.load-credentials.outputs.DESKTOP_CERTIFICATE_BASE64 }}
+ CSC_KEY_PASSWORD: ${{ steps.load-credentials.outputs.DESKTOP_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml
index 2285eec56065..ca030e95de1d 100644
--- a/.github/workflows/finishReleaseCycle.yml
+++ b/.github/workflows/finishReleaseCycle.yml
@@ -22,7 +22,7 @@ jobs:
uses: ./.github/actions/composite/setupGitForOSBotifyApp
id: setupGitForOSBotify
with:
- GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
+ OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }}
OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }}
@@ -87,7 +87,7 @@ jobs:
id: setupGitForOSBotify
uses: ./.github/actions/composite/setupGitForOSBotifyApp
with:
- GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
+ OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }}
OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }}
@@ -128,7 +128,7 @@ jobs:
- name: Setup git for OSBotify
uses: ./.github/actions/composite/setupGitForOSBotifyApp
with:
- GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
+ OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }}
OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }}
diff --git a/.github/workflows/preDeploy.yml b/.github/workflows/preDeploy.yml
index bfe860e60224..10ca10882464 100644
--- a/.github/workflows/preDeploy.yml
+++ b/.github/workflows/preDeploy.yml
@@ -104,7 +104,7 @@ jobs:
- name: Setup Git for OSBotify
uses: ./.github/actions/composite/setupGitForOSBotifyApp
with:
- GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
+ OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }}
OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }}
diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml
index ea62bca794fe..80918d65462c 100644
--- a/.github/workflows/testBuild.yml
+++ b/.github/workflows/testBuild.yml
@@ -242,10 +242,15 @@ jobs:
- name: Setup Node
uses: ./.github/actions/composite/setupNode
- - name: Decrypt Developer ID Certificate
- run: cd desktop && gpg --quiet --batch --yes --decrypt --passphrase="$DEVELOPER_ID_SECRET_PASSPHRASE" --output developer_id.p12 developer_id.p12.gpg
+ - name: Load Desktop credentials from 1Password
+ id: load-credentials
+ uses: 1password/load-secrets-action@v2
+ with:
+ export-env: false
env:
- DEVELOPER_ID_SECRET_PASSPHRASE: ${{ secrets.DEVELOPER_ID_SECRET_PASSPHRASE }}
+ OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
+ DESKTOP_CERTIFICATE_BASE64: "op://Mobile-Deploy-CI/Desktop Certificates.p12/CSC_LINK"
+ DESKTOP_CERTIFICATE_PASSWORD: "op://Mobile-Deploy-CI/Desktop Certificates.p12/CSC_KEY_PASSWORD"
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
@@ -257,8 +262,8 @@ jobs:
- name: Build desktop app for testing
run: npm run desktop-build-adhoc
env:
- CSC_LINK: ${{ secrets.CSC_LINK }}
- CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
+ CSC_LINK: ${{ steps.load-credentials.outputs.DESKTOP_CERTIFICATE_BASE64 }}
+ CSC_KEY_PASSWORD: ${{ steps.load-credentials.outputs.DESKTOP_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
diff --git a/Mobile-Expensify b/Mobile-Expensify
index 9e5fc5211c4d..daafba5d743d 160000
--- a/Mobile-Expensify
+++ b/Mobile-Expensify
@@ -1 +1 @@
-Subproject commit 9e5fc5211c4dd2ee130aa6bb9d3f09b1728947df
+Subproject commit daafba5d743ddbf9e114e5787aa5fd00b5a1655e
diff --git a/README.md b/README.md
index de5c746a964d..1263b1e66b3a 100644
--- a/README.md
+++ b/README.md
@@ -532,6 +532,15 @@ The primary difference is that the native code, which runs React Native, is loca
The `Mobile-Expensify` directory is a **Git submodule**. This means it points to a specific commit on the `Mobile-Expensify` repository.
+If you'd like to fetch the submodule while executing the `git pull` command in `Expensify/App` instead of updating it manually you can run this command in the root of the project:
+
+```
+git config submodule.recurse true
+```
+
+> [!WARNING]
+> Please, remember that the submodule will get updated automatically only after executing the `git pull` command - if you switch between branches it is still recommended to execute `git submodule update` to make sure you're working on a compatible submodule version!
+
If you'd like to download the most recent changes from the `main` branch, please use the following command:
```bash
git submodule update --remote
diff --git a/android/app/build.gradle b/android/app/build.gradle
index d954287f7a1f..7ba437feeda7 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 1009009408
- versionName "9.0.94-8"
+ versionCode 1009009424
+ versionName "9.0.94-24"
// 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/export.svg b/assets/images/export.svg
new file mode 100644
index 000000000000..ed6ae9897368
--- /dev/null
+++ b/assets/images/export.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/desktop/developer_id.p12.gpg b/desktop/developer_id.p12.gpg
deleted file mode 100644
index ad166e3f8334..000000000000
Binary files a/desktop/developer_id.p12.gpg and /dev/null differ
diff --git a/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md b/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md
index 02baa7f30570..089b06750053 100644
--- a/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md
+++ b/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md
@@ -59,7 +59,9 @@ This dictates when reimbursable expenses will export, according to your preferre
**Journal Entries:** Non-reimbursable expenses will be posted to the Journal Entries posting account selected in your workspace's connection settings. If you centrally manage your company cards through Domains, you can export expenses from each card to a specific account in NetSuite.
-- Expensify Card expenses will always export as Journal Entries, even if you have Expense Reports or Vendor Bills configured for non-reimbursable expenses on the Export tab
+- When [automatic reconciliation](https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Reconciliation) is enabled, Expensify Card expenses will always export as individual, itemized Journal Entries, regardless of Expense Reports or Vendor Bills settings configured for non-reimbursable expenses on the Export tab.
+- Without automatic reconciliation, Expensify Card expenses will export using the export type configured for non-reimbursable expenses on the Export tab.
+- Expensify Card expenses exported as Journal Entries will always export as individual, itemized Journal Entries, regardless of whether the "one journal entry for all items on report" setting is enabled.
- Journal entry forms do not contain a customer column, so it is not possible to export customers or projects with this export option
- The credit line and header level classifications are pulled from the employee record
diff --git a/docs/articles/expensify-classic/expensify-card/Cardholder-Settings-and-Features.md b/docs/articles/expensify-classic/expensify-card/Cardholder-Settings-and-Features.md
index 38686462a1c2..ee03a18033ea 100644
--- a/docs/articles/expensify-classic/expensify-card/Cardholder-Settings-and-Features.md
+++ b/docs/articles/expensify-classic/expensify-card/Cardholder-Settings-and-Features.md
@@ -54,8 +54,6 @@ To add your Expensify Card to a digital wallet, follow the steps below:
- **Restricted Country**: Transactions from restricted countries will be declined.
{% include faq-begin.md %}
-## Can I use Smart Limits with a free Expensify account?
-If you're on the Free plan, you won't have the option to use Smart Limits. Your card limit will simply reset at the end of each calendar month.
## I still haven't received my Expensify Card. What should I do?
For more information on why your card hasn't arrived, you can check out this resource on [Requesting a Card](https://help.expensify.com/articles/expensify-classic/expensify-card/Request-the-Card#what-if-i-havent-received-my-card-after-multiple-weeks).
diff --git a/docs/articles/new-expensify/connect-credit-cards/Personal-Cards.md b/docs/articles/new-expensify/connect-credit-cards/Personal-Cards.md
new file mode 100644
index 000000000000..70fc5cdffb52
--- /dev/null
+++ b/docs/articles/new-expensify/connect-credit-cards/Personal-Cards.md
@@ -0,0 +1,12 @@
+---
+title: Personal Cards
+description: Learn how to track and manage your personal credit card expenses in Expensify through automatic imports or manual uploads.
+---
+
+# Overview
+
+Expensify makes it easy to track expenses and get reimbursed by linking your personal credit card. Once connected, transactions can be imported automatically, or you can upload a CSV file for manual entry. These transactions will be merged with SmartScanned receipts and, if enabled, can generate IRS-compliant eReceipts.
+
+---
+
+We are currently developing the personal card connection feature for New Expensify. Once available, we will update this article with step-by-step instructions on how to connect your card. Stay tuned!
diff --git a/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md b/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md
new file mode 100644
index 000000000000..99a67a577500
--- /dev/null
+++ b/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md
@@ -0,0 +1,162 @@
+---
+title: Connect To NetSuite
+description: Connect NetSuite to New Expensify for streamlined expense reporting and accounting integration.
+order: 1
+---
+
+{% include info.html %}
+To use the NetSuite connection, you must have a NetSuite account and an Expensify Control plan.
+{% include end-info.html %}
+
+Expensify’s integration with NetSuite supports syncing data between the two systems. Before you start connecting Expensify with NetSuite, there are a few things to note:
+
+- You must be able to login to NetSuite as an administrator to initiate the connection.
+- A Control Plan in Expensify is required to integrate with NetSuite.
+- Employees don’t need NetSuite access or a NetSuite license to submit expense reports and sync them to NetSuite.
+- Each NetSuite subsidiary must be connected to a separate Expensify workspace.
+- The workspace currency in Expensify must match the NetSuite subsidiary's default currency.
+
+# Step 1: Install the Expensify Bundle in NetSuite
+
+1. While logged into NetSuite as an administrator, go to _Customization > SuiteBundler > Search & Install Bundles_, then search for “Expensify”.
+2. Click on the Expensify Connect bundle (Bundle ID 283395).
+3. Click **Install**.
+4. If you already have the Expensify Connect bundle installed, head to _Customization > SuiteBundler > Search & Install Bundles > List_, and update it to the latest version.
+5. Select **Show on Existing Custom Forms** for all available fields.
+
+
+# Step 2: Enable Token-Based Authentication
+
+1. In NetSuite, go to _Setup > Company > Enable Features > SuiteCloud > Manage Authentication_.
+2. Make sure “Token Based Authentication” is enabled.
+3. Click **Save**.
+
+
+# Step 3: Add Expensify Integration Role to a User
+
+1. In NetSuite, head to _Lists > Employees_, and find the user to who you would like to add the Expensify Integration role. The user you select must have access to at least the permissions included in the Expensify Integration Role, but they’re not required to be a NetSuite admin.
+2. Click _Edit > Access_, then find the Expensify Integration role in the dropdown and add it to the user.
+3. Click **Save**.
+
+
+{% include info.html %}
+Remember that Tokens are linked to a **User** and a **Role**, not solely to a User. It’s important to note that you cannot establish a connection with tokens using one role and then switch to another role afterward. Once you’ve initiated a connection with tokens, you must continue using the same token/user/role combination for all subsequent sync or export actions.
+{% include end-info.html %}
+
+
+# Step 4: Create Access Tokens
+
+1. In NetSuite, enter “page: tokens” in the Global Search.
+2. Click **New Access Token**.
+3. Select Expensify as the application (this must be the original Expensify integration from the bundle).
+4. Select the role Expensify Integration.
+5. Click **Save**.
+6. Copy and paste the token and token ID to a saved location on your computer (this is the only time you will see these details.)
+
+
+# Step 5: Confirm Expense Reports are enabled in NetSuite
+
+{% include info.html %}
+Expense Reports must be enabled in order to use Expensify’s integration with NetSuite.
+{% include end-info.html %}
+
+
+1. In NetSuite, go to _Setup > Company > Enable Features > Employees_.
+2. Confirm the checkbox next to Expense Reports is checked.
+3. If not, click the checkbox and then click **Save** to enable Expense Reports.
+
+
+# Step 6: Confirm Expense Categories are set up in NetSuite
+
+{% include info.html %}
+Once Expense Reports are enabled, Expense Categories can be set up in NetSuite. Expense Categories are synced to Expensify as Categories. Each Expense Category is an alias mapped to a General Ledger account so that employees can more easily categorize expenses.
+{% include end-info.html %}
+
+1. In NetSuite, go to _Setup > Accounting > Expense Categories_ (a list of Expense Categories should show.)
+2. If no Expense Categories are visible, click **New** to create new ones.
+
+
+# Step 7: Confirm Journal Entry Transaction Forms are Configured Properly
+
+1. In NetSuite, go to _Customization > Forms > Transaction Forms_.
+2. Click **Customize** or **Edit** next to the Standard Journal Entry form.
+3. Click _Screen Fields > Main_. Please verify the “Created From” label has “Show” checked and the "Display Type" is set to "Normal."
+4. Click the sub-header **Lines** and verify that the “Show” column for “Receipt URL” is checked.
+5. Go to _Customization > Forms > Transaction Forms_ and ensure that all other transaction forms with the journal type have this same configuration.
+
+
+# Step 8: Confirm Expense Report Transaction Forms are Configured Properly
+
+1. In NetSuite, go to _Customization > Forms > Transaction Forms_.
+2. Click **Customize** or **Edit** next to the Standard Expense Report form, then click _Screen Fields > Main_.
+3. Verify the “Created From” label has “Show” checked and the "Display Type" is set to "Normal."
+4. Click the second sub-header, **Expenses**, and verify that the "Show" column for "Receipt URL" is checked.
+5. Go to _Customization > Forms > Transaction Forms_ and ensure that all other transaction forms with the expense report type have this same configuration.
+
+
+# Step 9: Confirm Vendor Bill Transactions Forms are Configured Properly
+
+1. In NetSuite, go to _Customization > Forms > Transaction Forms_.
+2. Click **Customize** or **Edit** next to your preferred Vendor Bill form.
+3. Click _Screen Fields > Main_ and verify that the “Created From” label has “Show” checked and that Departments, Classes, and Locations have the “Show” label unchecked.
+4. Under the **Expenses** sub-header (make sure to click the “Expenses” sub-header at the very bottom and not “Expenses & Items”), ensure “Show” is checked for Receipt URL, Department, Location, and Class.
+5. Go to _Customization > Forms > Transaction Forms_ and ensure that all other transaction forms with the vendor bill type have this same configuration.
+
+
+# Step 10: Confirm Vendor Credit Transactions Forms are Configured Properly
+
+1. In NetSuite, go to _Customization > Forms > Transaction Forms_.
+2. Click **Customize** or **Edit** next to your preferred Vendor Credit form, then click _Screen Fields > Main_ and verify that the “Created From” label has “Show” checked and that Departments, Classes, and Locations have the “Show” label unchecked.
+3. Under the Expenses sub-header (make sure to click the “Expenses” sub-header at the very bottom and not “Expenses & Items”), ensure “Show” is checked for Receipt URL, Department, Location, and Class.
+4. Go to _Customization > Forms > Transaction Forms_ and ensure that all other transaction forms with the vendor credit type have this same configuration.
+
+
+# Step 11: Set up Tax Groups (only applicable if tracking taxes)
+
+{% include info.html %}
+**Things to note about tax.**
+Expensify imports NetSuite Tax Groups (not Tax Codes). To ensure Tax Groups can be applied to expenses go to _Setup > Accounting > Set Up Taxes_ and set the _Tax Code Lists Include_ preference to “Tax Groups And Tax Codes” or “Tax Groups Only.” If this field does not display, it’s not needed for that specific country.
+Tax Groups are an alias for Tax Codes in NetSuite and can contain one or more Tax Codes (Please note: for UK and Ireland subsidiaries, please ensure your Tax Groups do not have more than one Tax Code). We recommend naming Tax Groups so your employees can easily understand them, as the name and rate will be displayed in Expensify.
+{% include end-info.html %}
+
+1. Go to _Setup > Accounting > Tax Groups_.
+2. Click **New**.
+3. Select the country for your Tax Group.
+4. Enter the Tax Name (this is what employees will see in Expensify.)
+5. Select the subsidiary for this Tax Group.
+6. Select the Tax Code from the table you wish to include in this Tax Group.
+7. Click **Add**.
+8. Click **Save**.
+9. Create one NetSuite Tax Group for each tax rate you want to show in Expensify.
+
+# Step 12: Connect Expensify to NetSuite
+
+1. Click your profile image or icon in the bottom left menu.
+2. Scroll down and click **Workspaces** in the left menu.
+3. Select the workspace you want to connect to NetSuite.
+4. Click **More features** in the left menu.
+5. Scroll down to the Integrate section and enable the **Accounting** toggle.
+6. Click **Accounting** in the left menu.
+7. Click **Connect** next to NetSuite.
+8. Click **Next** until you reach setup step 5 (If you followed the instructions above, then the first four setup steps will already be complete.)
+9. On setup step 5, enter your NetSuite Account ID, Token ID, and Token Secret (the NetSuite Account ID can be found in NetSuite by going to Setup > Integration > Web Services Preferences_.)
+10. Click **Confirm** to complete the setup.
+
+
+![The New Expensify workspace setting is open and the More Features tab is selected and visible. The toggle to enable Accounting is highlighted with an orange call out and is currently in the grey disabled position.]({{site.url}}/assets/images/ExpensifyHelp-Xero-1.png)
+
+![The New Expensify workspace settings > More features tab is open with the toggle to enable Accounting enabled and green. The Accounting tab is now visible in the left-hand menu and is highlighted with an orange call out.]({{site.url}}/assets/images/ExpensifyHelp-Xero-2.png){:width="100%"}
+
+After completing the setup, the NetSuite connection will sync. It can take 1-2 minutes to sync with NetSuite.
+
+Once connected, all newly approved and paid reports exported from Expensify will be generated in NetSuite using SOAP Web Services (the term NetSuite employs when records are created through the integration). You can then move forward with [configuring the NetSuite settings](https://help.expensify.com/articles/new-expensify/connections/netsuite/Configure-Netsuite) in Expensify.
+
+{% include faq-begin.md %}
+
+## If I have a lot of customer and vendor data in NetSuite, how can I help ensure that importing them all is seamless?
+
+For importing your customers and vendors, make sure your page size is set to 1000 in NetSuite.
+
+Go to **Setup > Integration > Web Services Preferences** and search **Page Size** to determine your page size.
+
+{% include faq-end.md %}
diff --git a/docs/articles/new-expensify/connections/netsuite/Connect-to-NetSuite.md b/docs/articles/new-expensify/connections/netsuite/Connect-to-NetSuite.md
deleted file mode 100644
index 990217523743..000000000000
--- a/docs/articles/new-expensify/connections/netsuite/Connect-to-NetSuite.md
+++ /dev/null
@@ -1,164 +0,0 @@
----
-title: Connect to NetSuite
-description: Integrate NetSuite with Expensify
-order: 1
----
-
-{% include info.html %}
-To use the NetSuite connection, you must have a NetSuite account and an Expensify Control plan.
-{% include end-info.html %}
-
-Expensify’s integration with NetSuite supports syncing data between the two systems. Before you start connecting Expensify with NetSuite, there are a few things to note:
-
-- You must use NetSuite administrator credentials to initiate the connection.
-- A Control Plan in Expensify is required to integrate with NetSuite.
-- Employees don’t need NetSuite access or a NetSuite license to submit expense reports and sync them to NetSuite.
-- Each NetSuite subsidiary must be connected to a separate Expensify workspace.
-- The workspace currency in Expensify must match the NetSuite subsidiary's default currency.
-
-# Step 1: Install the Expensify Bundle in NetSuite
-
-While logged into NetSuite as an administrator, go to **Customization > SuiteBundler > Search & Install Bundles**, then search for “Expensify”.
-Click on the Expensify Connect bundle (Bundle ID 283395).
-Click **Install**.
-If you already have the Expensify Connect bundle installed, head to **Customization > SuiteBundler > Search & Install Bundles > List**, and update it to the latest version.
-Select "Show on Existing Custom Forms" for all available fields.
-
-# Step 2: Enable Token-Based Authentication
-
-In NetSuite, go to **Setup > Company > Enable Features > SuiteCloud > Manage Authentication**.
-Make sure “Token Based Authentication” is enabled.
-Click **Save**.
-
-# Step 3: Add Expensify Integration Role to a User
-
-In NetSuite, head to **Lists > Employees**, and find the user to who you would like to add the Expensify Integration role. The user you select must at least have access to the permissions included in the Expensify Integration Role, and Admin access works too, but Admin access is not required.
-Click **Edit > Access**, then find the Expensify Integration role in the dropdown and add it to the user.
-Click **Save**.
-
-
-{% include info.html %}
-Remember that Tokens are linked to a **User** and a **Role**, not solely to a User. It’s important to note that you cannot establish a connection with tokens using one role and then switch to another role afterward. Once you’ve initiated a connection with tokens, you must continue using the same token/user/role combination for all subsequent sync or export actions.
-{% include end-info.html %}
-
-# Step 4: Create Access Tokens
-
-
-In NetSuite, enter “page: tokens” in the Global Search.
-Click **New Access Token**.
-Select Expensify as the application (this must be the original Expensify integration from the bundle).
-Select the role Expensify Integration.
-Click **Save**.
-Copy and paste the token and token ID to a saved location on your computer (this is the only time you will see these details.)
-
-
-# Step 5: Confirm Expense Reports are enabled in NetSuite
-
-{% include info.html %}
-Expense Reports must be enabled in order to use Expensify’s integration with NetSuite.
-{% include end-info.html %}
-
-
-In NetSuite, go to **Setup > Company > Enable Features > Employees**.
-Confirm the checkbox next to "Expense Reports" is checked.
-If not, click the checkbox and then click **Save** to enable Expense Reports.
-
-
-# Step 6: Confirm Expense Categories are set up in NetSuite
-
-{% include info.html %}
-Once Expense Reports are enabled, Expense Categories can be set up in NetSuite. Expense Categories are synced to Expensify as Categories. Each Expense Category is an alias mapped to a General Ledger account so that employees can more easily categorize expenses.
-{% include end-info.html %}
-
-
-In NetSuite, go to **Setup > Accounting > Expense Categories** (a list of Expense Categories should show.)
-If no Expense Categories are visible, click **New** to create new ones.
-
-# Step 7: Confirm Journal Entry Transaction Forms are Configured Properly
-
-In NetSuite, go to **Customization > Forms > Transaction Forms.**
-Click **Customize** or **Edit** next to the Standard Journal Entry form.
-Click **Screen Fields > Main**. Please verify the “Created From” label has “Show” checked and the "Display Type" is set to "Normal."
-Click the sub-header **Lines** and verify that the “Show” column for “Receipt URL” is checked.
-Go to **Customization > Forms > Transaction Forms** and ensure that all other transaction forms with the journal type have this same configuration.
-
-
-# Step 8: Confirm Expense Report Transaction Forms are Configured Properly
-
-
-In NetSuite, go to **Customization > Forms > Transaction Forms.**
-Click **Customize** or **Edit** next to the Standard Expense Report form, then click **Screen Fields > Main.**
-Verify the “Created From” label has “Show” checked and the "Display Type" is set to "Normal."
-Click the second sub-header, **Expenses**, and verify that the "Show" column for "Receipt URL" is checked.
-Go to **Customization > Forms > Transaction Forms** and ensure that all other transaction forms with the expense report type have this same configuration.
-
-
-# Step 9: Confirm Vendor Bill Transactions Forms are Configured Properly
-
-
-In NetSuite, go to **Customization > Forms > Transaction Forms.**
-Click **Customize** or **Edit** next to your preferred Vendor Bill form.
-Click **Screen Fields > Main** and verify that the “Created From” label has “Show” checked and that Departments, Classes, and Locations have the “Show” label unchecked.
-Under the **Expenses** sub-header (make sure to click the “Expenses” sub-header at the very bottom and not “Expenses & Items”), ensure “Show” is checked for Receipt URL, Department, Location, and Class.
-Go to **Customization > Forms > Transaction Forms** and ensure that all other transaction forms with the vendor bill type have this same configuration.
-
-
-# Step 10: Confirm Vendor Credit Transactions Forms are Configured Properly
-
-
-In NetSuite, go to **Customization > Forms > Transaction Forms**.
-Click **Customize** or **Edit** next to your preferred Vendor Credit form, then click **Screen Fields > Main** and verify that the “Created From” label has “Show” checked and that Departments, Classes, and Locations have the “Show” label unchecked.
-Under the **Expenses** sub-header (make sure to click the “Expenses” sub-header at the very bottom and not “Expenses & Items”), ensure “Show” is checked for Receipt URL, Department, Location, and Class.
-Go to **Customization > Forms > Transaction Forms** and ensure that all other transaction forms with the vendor credit type have this same configuration.
-
-
-# Step 11: Set up Tax Groups (only applicable if tracking taxes)
-
-{% include info.html %}
-**Things to note about tax.**
-Expensify imports NetSuite Tax Groups (not Tax Codes). To ensure Tax Groups can be applied to expenses go to **Setup > Accounting > Set Up Taxes** and set the _Tax Code Lists Include_ preference to “Tax Groups And Tax Codes” or “Tax Groups Only.” If this field does not display, it’s not needed for that specific country.
-Tax Groups are an alias for Tax Codes in NetSuite and can contain one or more Tax Codes (Please note: for UK and Ireland subsidiaries, please ensure your Tax Groups do not have more than one Tax Code). We recommend naming Tax Groups so your employees can easily understand them, as the name and rate will be displayed in Expensify.
-{% include end-info.html %}
-
-Go to **Setup > Accounting > Tax Groups**.
-Click **New**.
-Select the country for your Tax Group.
-Enter the Tax Name (this is what employees will see in Expensify.)
-Select the subsidiary for this Tax Group.
-Select the Tax Code from the table you wish to include in this Tax Group.
-Click **Add**.
-Click **Save**.
-Create one NetSuite Tax Group for each tax rate you want to show in Expensify.
-
-# Step 12: Connect Expensify to NetSuite
-
-Click your profile image or icon in the bottom left menu.
-Scroll down and click **Workspaces** in the left menu.
-Select the workspace you want to connect to NetSuite.
-Click **More features** in the left menu.
-Click **More features** in the left menu.
-Scroll down to the Integrate section and enable the Accounting toggle.
-Click **Accounting** in the left menu.
-Click **Connect** next to NetSuite.
-Click **Next** until you reach setup step 5 (If you followed the instructions above, then the first four setup steps will already be complete.)
-On setup step 5, enter your NetSuite Account ID, Token ID, and Token Secret (the NetSuite Account ID can be found in NetSuite by going to **Setup > Integration > Web Services Preferences**.)
-Click **Confirm** to complete the setup.
-
-
-![The New Expensify workspace setting is open and the More Features tab is selected and visible. The toggle to enable Accounting is highlighted with an orange call out and is currently in the grey disabled position.]({{site.url}}/assets/images/ExpensifyHelp-Xero-1.png)
-
-![The New Expensify workspace settings > More features tab is open with the toggle to enable Accounting enabled and green. The Accounting tab is now visible in the left-hand menu and is highlighted with an orange call out.]({{site.url}}/assets/images/ExpensifyHelp-Xero-2.png){:width="100%"}
-
-After completing the setup, the NetSuite connection will sync. It can take 1-2 minutes to sync with NetSuite.
-
-Once connected, all newly approved and paid reports exported from Expensify will be generated in NetSuite using SOAP Web Services (the term NetSuite employs when records are created through the integration).
-
-{% include faq-begin.md %}
-
-## If I have a lot of customer and vendor data in NetSuite, how can I help ensure that importing them all is seamless?
-
-For importing your customers and vendors, make sure your page size is set to 1000 in NetSuite.
-
-Go to **Setup > Integration > Web Services Preferences** and search **Page Size** to determine your page size.
-
-{% include faq-end.md %}
diff --git a/docs/redirects.csv b/docs/redirects.csv
index 2ddd13d5fc8b..f4970373fb7a 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -620,6 +620,7 @@ https://help.expensify.com/articles/expensify-classic/connect-credit-cards/compa
https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Connect-Company-Cards/
https://help.expensify.com/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa,https://help.expensify.com/new-expensify/hubs/expensify-card/
https://help.expensify.com/articles/new-expensify/expensify-card/Dispute-Expensify-Card-transaction,https://help.expensify.com/articles/new-expensify/expensify-card/Disputing-Expensify-Card-Transactions
+https://help.expensify.com/articles/new-expensify/connections/netsuite/Connect-to-NetSuite,https://help.expensify.com/articles/new-expensify/connections/netsuite/Connect-To-NetSuite
https://help.expensify.com/articles/expensify-classic/expensify-card/Request-the-Card,https://help.expensify.com/articles/expensify-classic/expensify-card/Request-the-Expensify-Card
https://help.expensify.com/articles/expensify-classic/settings/Change-or-add-email-address,https://help.expensify.com/articles/expensify-classic/settings/Managing-Primary-and-Secondary-Logins-in-Expensify
https://help.expensify.com/articles/expensify-classic/domains/SAML-SSO,https://help.expensify.com/articles/expensify-classic/domains/Managing-Single-Sign-On-(SSO)-in-Expensify
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index e6c0f8051831..dec75b7bc653 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -44,7 +44,7 @@
CFBundleVersion
- 9.0.94.8
+ 9.0.94.24
FullStory
OrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 62cb96059e63..e0b61a75885f 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 9.0.94.8
+ 9.0.94.24
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 10caac53aeff..63805152d6ed 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -13,7 +13,7 @@
CFBundleShortVersionString
9.0.94
CFBundleVersion
- 9.0.94.8
+ 9.0.94.24
NSExtension
NSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index b976f36b5134..ab3dc7fe78c0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.0.94-8",
+ "version": "9.0.94-24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.94-8",
+ "version": "9.0.94-24",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index 34e38fe54979..2bf3c28acecb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.94-8",
+ "version": "9.0.94-24",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
diff --git a/src/CONST.ts b/src/CONST.ts
index 6cbd983b9225..b8af68ddb934 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -2830,7 +2830,7 @@ const CONST = {
NAME_PER_DIEM_INTERNATIONAL: 'Per Diem International',
DISTANCE_UNIT_MILES: 'mi',
DISTANCE_UNIT_KILOMETERS: 'km',
- MILEAGE_IRS_RATE: 0.67,
+ MILEAGE_IRS_RATE: 0.7,
DEFAULT_RATE: 'Default Rate',
RATE_DECIMALS: 3,
FAKE_P2P_ID: '_FAKE_P2P_ID_',
@@ -5379,183 +5379,183 @@ const CONST = {
},
CURRENCY_TO_DEFAULT_MILEAGE_RATE: JSON.parse(`{
"AED": {
- "rate": 396,
+ "rate": 414,
"unit": "km"
},
"AFN": {
- "rate": 8369,
+ "rate": 8851,
"unit": "km"
},
"ALL": {
- "rate": 11104,
+ "rate": 10783,
"unit": "km"
},
"AMD": {
- "rate": 56842,
+ "rate": 45116,
"unit": "km"
},
"ANG": {
- "rate": 193,
+ "rate": 203,
"unit": "km"
},
"AOA": {
- "rate": 67518,
+ "rate": 102929,
"unit": "km"
},
"ARS": {
- "rate": 9873,
+ "rate": 118428,
"unit": "km"
},
"AUD": {
- "rate": 85,
+ "rate": 88,
"unit": "km"
},
"AWG": {
- "rate": 195,
+ "rate": 203,
"unit": "km"
},
"AZN": {
- "rate": 183,
+ "rate": 192,
"unit": "km"
},
"BAM": {
- "rate": 177,
+ "rate": 212,
"unit": "km"
},
"BBD": {
- "rate": 216,
+ "rate": 225,
"unit": "km"
},
"BDT": {
- "rate": 9130,
+ "rate": 13697,
"unit": "km"
},
"BGN": {
- "rate": 177,
+ "rate": 211,
"unit": "km"
},
"BHD": {
- "rate": 40,
+ "rate": 42,
"unit": "km"
},
"BIF": {
- "rate": 210824,
+ "rate": 331847,
"unit": "km"
},
"BMD": {
- "rate": 108,
+ "rate": 113,
"unit": "km"
},
"BND": {
- "rate": 145,
+ "rate": 153,
"unit": "km"
},
"BOB": {
- "rate": 745,
+ "rate": 779,
"unit": "km"
},
"BRL": {
- "rate": 594,
+ "rate": 660,
"unit": "km"
},
"BSD": {
- "rate": 108,
+ "rate": 113,
"unit": "km"
},
"BTN": {
- "rate": 7796,
+ "rate": 9761,
"unit": "km"
},
"BWP": {
- "rate": 1180,
+ "rate": 1569,
"unit": "km"
},
"BYN": {
- "rate": 280,
+ "rate": 369,
"unit": "km"
},
"BYR": {
- "rate": 2159418,
+ "rate": 2255979,
"unit": "km"
},
"BZD": {
- "rate": 217,
+ "rate": 227,
"unit": "km"
},
"CAD": {
- "rate": 70,
+ "rate": 72,
"unit": "km"
},
"CDF": {
- "rate": 213674,
+ "rate": 321167,
"unit": "km"
},
"CHF": {
- "rate": 70,
+ "rate": 76,
"unit": "km"
},
"CLP": {
- "rate": 77249,
+ "rate": 111689,
"unit": "km"
},
"CNY": {
- "rate": 702,
+ "rate": 808,
"unit": "km"
},
"COP": {
- "rate": 383668,
+ "rate": 473791,
"unit": "km"
},
"CRC": {
- "rate": 65899,
+ "rate": 57190,
"unit": "km"
},
"CUC": {
- "rate": 108,
+ "rate": 113,
"unit": "km"
},
"CUP": {
- "rate": 2776,
+ "rate": 2902,
"unit": "km"
},
"CVE": {
- "rate": 6112,
+ "rate": 11961,
"unit": "km"
},
"CZK": {
- "rate": 2356,
+ "rate": 2715,
"unit": "km"
},
"DJF": {
- "rate": 19151,
+ "rate": 19956,
"unit": "km"
},
"DKK": {
- "rate": 379,
+ "rate": 381,
"unit": "km"
},
"DOP": {
- "rate": 6144,
+ "rate": 6948,
"unit": "km"
},
"DZD": {
- "rate": 14375,
+ "rate": 15226,
"unit": "km"
},
"EEK": {
- "rate": 1576,
+ "rate": 1646,
"unit": "km"
},
"EGP": {
- "rate": 1696,
+ "rate": 5657,
"unit": "km"
},
"ERN": {
- "rate": 1617,
+ "rate": 1690,
"unit": "km"
},
"ETB": {
- "rate": 4382,
+ "rate": 14326,
"unit": "km"
},
"EUR": {
@@ -5563,11 +5563,11 @@ const CONST = {
"unit": "km"
},
"FJD": {
- "rate": 220,
+ "rate": 264,
"unit": "km"
},
"FKP": {
- "rate": 77,
+ "rate": 90,
"unit": "km"
},
"GBP": {
@@ -5575,55 +5575,55 @@ const CONST = {
"unit": "mi"
},
"GEL": {
- "rate": 359,
+ "rate": 323,
"unit": "km"
},
"GHS": {
- "rate": 620,
+ "rate": 1724,
"unit": "km"
},
"GIP": {
- "rate": 77,
+ "rate": 90,
"unit": "km"
},
"GMD": {
- "rate": 5526,
+ "rate": 8111,
"unit": "km"
},
"GNF": {
- "rate": 1081319,
+ "rate": 974619,
"unit": "km"
},
"GTQ": {
- "rate": 832,
+ "rate": 872,
"unit": "km"
},
"GYD": {
- "rate": 22537,
+ "rate": 23585,
"unit": "km"
},
"HKD": {
- "rate": 837,
+ "rate": 877,
"unit": "km"
},
"HNL": {
- "rate": 2606,
+ "rate": 2881,
"unit": "km"
},
"HRK": {
- "rate": 684,
+ "rate": 814,
"unit": "km"
},
"HTG": {
- "rate": 8563,
+ "rate": 14734,
"unit": "km"
},
"HUF": {
- "rate": 33091,
+ "rate": 44127,
"unit": "km"
},
"IDR": {
- "rate": 1555279,
+ "rate": 1830066,
"unit": "km"
},
"ILS": {
@@ -5631,147 +5631,147 @@ const CONST = {
"unit": "km"
},
"INR": {
- "rate": 7805,
+ "rate": 9761,
"unit": "km"
},
"IQD": {
- "rate": 157394,
+ "rate": 147577,
"unit": "km"
},
"IRR": {
- "rate": 4539961,
+ "rate": 4741290,
"unit": "km"
},
"ISK": {
- "rate": 13518,
+ "rate": 15772,
"unit": "km"
},
"JMD": {
- "rate": 15794,
+ "rate": 17738,
"unit": "km"
},
"JOD": {
- "rate": 77,
+ "rate": 80,
"unit": "km"
},
"JPY": {
- "rate": 11748,
+ "rate": 17542,
"unit": "km"
},
"KES": {
- "rate": 11845,
+ "rate": 14589,
"unit": "km"
},
"KGS": {
- "rate": 9144,
+ "rate": 9852,
"unit": "km"
},
"KHR": {
- "rate": 437658,
+ "rate": 453066,
"unit": "km"
},
"KMF": {
- "rate": 44418,
+ "rate": 53269,
"unit": "km"
},
"KPW": {
- "rate": 97043,
+ "rate": 101389,
"unit": "km"
},
"KRW": {
- "rate": 121345,
+ "rate": 162705,
"unit": "km"
},
"KWD": {
- "rate": 32,
+ "rate": 35,
"unit": "km"
},
"KYD": {
- "rate": 90,
+ "rate": 93,
"unit": "km"
},
"KZT": {
- "rate": 45396,
+ "rate": 58319,
"unit": "km"
},
"LAK": {
- "rate": 1010829,
+ "rate": 2452802,
"unit": "km"
},
"LBP": {
- "rate": 164153,
+ "rate": 10093809,
"unit": "km"
},
"LKR": {
- "rate": 21377,
+ "rate": 33423,
"unit": "km"
},
"LRD": {
- "rate": 18709,
+ "rate": 22185,
"unit": "km"
},
"LSL": {
- "rate": 1587,
+ "rate": 2099,
"unit": "km"
},
"LTL": {
- "rate": 348,
+ "rate": 364,
"unit": "km"
},
"LVL": {
- "rate": 71,
+ "rate": 74,
"unit": "km"
},
"LYD": {
- "rate": 486,
+ "rate": 554,
"unit": "km"
},
"MAD": {
- "rate": 967,
+ "rate": 1127,
"unit": "km"
},
"MDL": {
- "rate": 1910,
+ "rate": 2084,
"unit": "km"
},
"MGA": {
- "rate": 406520,
+ "rate": 529635,
"unit": "km"
},
"MKD": {
- "rate": 5570,
+ "rate": 6650,
"unit": "km"
},
"MMK": {
- "rate": 152083,
+ "rate": 236413,
"unit": "km"
},
"MNT": {
- "rate": 306788,
+ "rate": 382799,
"unit": "km"
},
"MOP": {
- "rate": 863,
+ "rate": 904,
"unit": "km"
},
"MRO": {
- "rate": 38463,
+ "rate": 40234,
"unit": "km"
},
"MRU": {
- "rate": 3862,
+ "rate": 4506,
"unit": "km"
},
"MUR": {
- "rate": 4340,
+ "rate": 5226,
"unit": "km"
},
"MVR": {
- "rate": 1667,
+ "rate": 1735,
"unit": "km"
},
"MWK": {
- "rate": 84643,
+ "rate": 195485,
"unit": "km"
},
"MXN": {
@@ -5779,23 +5779,23 @@ const CONST = {
"unit": "km"
},
"MYR": {
- "rate": 444,
+ "rate": 494,
"unit": "km"
},
"MZN": {
- "rate": 7772,
+ "rate": 7199,
"unit": "km"
},
"NAD": {
- "rate": 1587,
+ "rate": 2099,
"unit": "km"
},
"NGN": {
- "rate": 42688,
+ "rate": 174979,
"unit": "km"
},
"NIO": {
- "rate": 3772,
+ "rate": 4147,
"unit": "km"
},
"NOK": {
@@ -5803,35 +5803,35 @@ const CONST = {
"unit": "km"
},
"NPR": {
- "rate": 12474,
+ "rate": 15617,
"unit": "km"
},
"NZD": {
- "rate": 95,
+ "rate": 104,
"unit": "km"
},
"OMR": {
- "rate": 42,
+ "rate": 43,
"unit": "km"
},
"PAB": {
- "rate": 108,
+ "rate": 113,
"unit": "km"
},
"PEN": {
- "rate": 401,
+ "rate": 420,
"unit": "km"
},
"PGK": {
- "rate": 380,
+ "rate": 455,
"unit": "km"
},
"PHP": {
- "rate": 5234,
+ "rate": 6582,
"unit": "km"
},
"PKR": {
- "rate": 16785,
+ "rate": 31411,
"unit": "km"
},
"PLN": {
@@ -5839,43 +5839,43 @@ const CONST = {
"unit": "km"
},
"PYG": {
- "rate": 704732,
+ "rate": 890772,
"unit": "km"
},
"QAR": {
- "rate": 393,
+ "rate": 410,
"unit": "km"
},
"RON": {
- "rate": 443,
+ "rate": 538,
"unit": "km"
},
"RSD": {
- "rate": 10630,
+ "rate": 12656,
"unit": "km"
},
"RUB": {
- "rate": 8074,
+ "rate": 11182,
"unit": "km"
},
"RWF": {
- "rate": 107182,
+ "rate": 156589,
"unit": "km"
},
"SAR": {
- "rate": 404,
+ "rate": 423,
"unit": "km"
},
"SBD": {
- "rate": 859,
+ "rate": 951,
"unit": "km"
},
"SCR": {
- "rate": 2287,
+ "rate": 1611,
"unit": "km"
},
"SDG": {
- "rate": 41029,
+ "rate": 67705,
"unit": "km"
},
"SEK": {
@@ -5883,155 +5883,159 @@ const CONST = {
"unit": "km"
},
"SGD": {
- "rate": 145,
+ "rate": 151,
"unit": "km"
},
"SHP": {
- "rate": 77,
+ "rate": 90,
"unit": "km"
},
"SLL": {
- "rate": 1102723,
+ "rate": 2362357,
+ "unit": "km"
+ },
+ "SLE": {
+ "rate": 2363,
"unit": "km"
},
"SOS": {
- "rate": 62604,
+ "rate": 64374,
"unit": "km"
},
"SRD": {
- "rate": 1526,
+ "rate": 3954,
"unit": "km"
},
"STD": {
- "rate": 2223309,
+ "rate": 2510095,
"unit": "km"
},
"STN": {
- "rate": 2232,
+ "rate": 2683,
"unit": "km"
},
"SVC": {
- "rate": 943,
+ "rate": 987,
"unit": "km"
},
"SYP": {
- "rate": 82077,
+ "rate": 1464664,
"unit": "km"
},
"SZL": {
- "rate": 1585,
+ "rate": 2099,
"unit": "km"
},
"THB": {
- "rate": 3328,
+ "rate": 3801,
"unit": "km"
},
"TJS": {
- "rate": 1230,
+ "rate": 1228,
"unit": "km"
},
"TMT": {
- "rate": 378,
+ "rate": 394,
"unit": "km"
},
"TND": {
- "rate": 295,
+ "rate": 360,
"unit": "km"
},
"TOP": {
- "rate": 245,
+ "rate": 274,
"unit": "km"
},
"TRY": {
- "rate": 845,
+ "rate": 4035,
"unit": "km"
},
"TTD": {
- "rate": 732,
+ "rate": 763,
"unit": "km"
},
"TWD": {
- "rate": 3055,
+ "rate": 3703,
"unit": "km"
},
"TZS": {
- "rate": 250116,
+ "rate": 286235,
"unit": "km"
},
"UAH": {
- "rate": 2985,
+ "rate": 4725,
"unit": "km"
},
"UGX": {
- "rate": 395255,
+ "rate": 416016,
"unit": "km"
},
"USD": {
- "rate": 67,
+ "rate": 70,
"unit": "mi"
},
"UYU": {
- "rate": 4777,
+ "rate": 4888,
"unit": "km"
},
"UZS": {
- "rate": 1131331,
+ "rate": 1462038,
"unit": "km"
},
"VEB": {
- "rate": 679346,
+ "rate": 709737,
"unit": "km"
},
"VEF": {
- "rate": 26793449,
+ "rate": 27993155,
"unit": "km"
},
"VES": {
- "rate": 194381905,
+ "rate": 6457,
"unit": "km"
},
"VND": {
- "rate": 2487242,
+ "rate": 2825526,
"unit": "km"
},
"VUV": {
- "rate": 11748,
+ "rate": 13358,
"unit": "km"
},
"WST": {
- "rate": 272,
+ "rate": 315,
"unit": "km"
},
"XAF": {
- "rate": 59224,
+ "rate": 70811,
"unit": "km"
},
"XCD": {
- "rate": 291,
+ "rate": 304,
"unit": "km"
},
"XOF": {
- "rate": 59224,
+ "rate": 70811,
"unit": "km"
},
"XPF": {
- "rate": 10783,
+ "rate": 12875,
"unit": "km"
},
"YER": {
- "rate": 27037,
+ "rate": 28003,
"unit": "km"
},
"ZAR": {
- "rate": 464,
+ "rate": 484,
"unit": "km"
},
"ZMK": {
- "rate": 566489,
+ "rate": 591756,
"unit": "km"
},
"ZMW": {
- "rate": 2377,
+ "rate": 3148,
"unit": "km"
}
}`) as Record,
@@ -6189,6 +6193,7 @@ const CONST = {
LOWER_THAN: 'lt',
LOWER_THAN_OR_EQUAL_TO: 'lte',
},
+ SYNTAX_RANGE_NAME: 'syntax',
SYNTAX_ROOT_KEYS: {
TYPE: 'type',
STATUS: 'status',
diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx
index fec3fa6fa6fd..e9b3b555385d 100644
--- a/src/components/AvatarWithDisplayName.tsx
+++ b/src/components/AvatarWithDisplayName.tsx
@@ -1,18 +1,33 @@
import React, {useCallback, useEffect, useRef} from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
-import {useOnyx, withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
-import * as OptionsListUtils from '@libs/OptionsListUtils';
-import * as ReportUtils from '@libs/ReportUtils';
+import {getPersonalDetailsForAccountIDs} from '@libs/OptionsListUtils';
+import {
+ getChatRoomSubtitle,
+ getDisplayNamesWithTooltips,
+ getIcons,
+ getParentNavigationSubtitle,
+ getReportName,
+ isChatThread,
+ isExpenseReport,
+ isInvoiceReport,
+ isIOUReport,
+ isMoneyRequest,
+ isMoneyRequestReport,
+ isTrackExpenseReport,
+ navigateToDetailsPage,
+ shouldReportShowSubscript,
+} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {PersonalDetailsList, Policy, Report, ReportActions} from '@src/types/onyx';
+import type {Policy, Report} from '@src/types/onyx';
import type {Icon} from '@src/types/onyx/OnyxCommon';
import CaretWrapper from './CaretWrapper';
import DisplayNames from './DisplayNames';
@@ -23,15 +38,7 @@ import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
import SubscriptAvatar from './SubscriptAvatar';
import Text from './Text';
-type AvatarWithDisplayNamePropsWithOnyx = {
- /** All of the actions of the report */
- parentReportActions: OnyxEntry;
-
- /** Personal details of all users */
- personalDetails: OnyxEntry;
-};
-
-type AvatarWithDisplayNameProps = AvatarWithDisplayNamePropsWithOnyx & {
+type AvatarWithDisplayNameProps = {
/** The report currently being looked at */
report: OnyxEntry;
@@ -55,41 +62,38 @@ const fallbackIcon: Icon = {
id: -1,
};
-function AvatarWithDisplayName({
- policy,
- report,
- parentReportActions,
- isAnonymous = false,
- size = CONST.AVATAR_SIZE.DEFAULT,
- shouldEnableDetailPageNavigation = false,
- personalDetails = CONST.EMPTY_OBJECT,
-}: AvatarWithDisplayNameProps) {
+function AvatarWithDisplayName({policy, report, isAnonymous = false, size = CONST.AVATAR_SIZE.DEFAULT, shouldEnableDetailPageNavigation = false}: AvatarWithDisplayNameProps) {
+ const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.parentReportID}`, {canEvict: false});
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST) ?? CONST.EMPTY_OBJECT;
+
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`);
const [invoiceReceiverPolicy] = useOnyx(
- `${ONYXKEYS.COLLECTION.POLICY}${parentReport?.invoiceReceiver && 'policyID' in parentReport.invoiceReceiver ? parentReport.invoiceReceiver.policyID : -1}`,
+ `${ONYXKEYS.COLLECTION.POLICY}${parentReport?.invoiceReceiver && 'policyID' in parentReport.invoiceReceiver ? parentReport.invoiceReceiver.policyID : CONST.DEFAULT_NUMBER_ID}`,
);
- const title = ReportUtils.getReportName(report, invoiceReceiverPolicy);
- const subtitle = ReportUtils.getChatRoomSubtitle(report);
- const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(report);
- const isMoneyRequestOrReport =
- ReportUtils.isMoneyRequestReport(report) || ReportUtils.isMoneyRequest(report) || ReportUtils.isTrackExpenseReport(report) || ReportUtils.isInvoiceReport(report);
- const icons = ReportUtils.getIcons(report, personalDetails, null, '', -1, policy, invoiceReceiverPolicy);
- const ownerPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(report?.ownerAccountID ? [report.ownerAccountID] : [], personalDetails);
- const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails), false);
- const shouldShowSubscriptAvatar = ReportUtils.shouldReportShowSubscript(report);
+ const title = getReportName(report, undefined, undefined, undefined, invoiceReceiverPolicy);
+ const subtitle = getChatRoomSubtitle(report, {isCreateExpenseFlow: true});
+ const parentNavigationSubtitleData = getParentNavigationSubtitle(report);
+ const isMoneyRequestOrReport = isMoneyRequestReport(report) || isMoneyRequest(report) || isTrackExpenseReport(report) || isInvoiceReport(report);
+ const icons = getIcons(report, personalDetails, null, '', -1, policy, invoiceReceiverPolicy);
+ const ownerPersonalDetails = getPersonalDetailsForAccountIDs(report?.ownerAccountID ? [report.ownerAccountID] : [], personalDetails);
+ const displayNamesWithTooltips = getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails), false);
+ const shouldShowSubscriptAvatar = shouldReportShowSubscript(report);
const avatarBorderColor = isAnonymous ? theme.highlightBG : theme.componentBG;
const actorAccountID = useRef(null);
useEffect(() => {
- const parentReportAction = parentReportActions?.[report?.parentReportActionID ?? '-1'];
- actorAccountID.current = parentReportAction?.actorAccountID ?? -1;
+ if (!report?.parentReportActionID) {
+ return;
+ }
+ const parentReportAction = parentReportActions?.[report?.parentReportActionID];
+ actorAccountID.current = parentReportAction?.actorAccountID ?? CONST.DEFAULT_NUMBER_ID;
}, [parentReportActions, report]);
const goToDetailsPage = useCallback(() => {
- ReportUtils.navigateToDetailsPage(report, Navigation.getReportRHPActiveRoute());
+ navigateToDetailsPage(report, Navigation.getReportRHPActiveRoute());
}, [report]);
const showActorDetails = useCallback(() => {
@@ -99,17 +103,17 @@ function AvatarWithDisplayName({
return;
}
- if (ReportUtils.isExpenseReport(report) && report?.ownerAccountID) {
+ if (isExpenseReport(report) && report?.ownerAccountID) {
Navigation.navigate(ROUTES.PROFILE.getRoute(report.ownerAccountID));
return;
}
- if (ReportUtils.isIOUReport(report) && report?.reportID) {
+ if (isIOUReport(report) && report?.reportID) {
Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(report.reportID));
return;
}
- if (ReportUtils.isChatThread(report)) {
+ if (isChatThread(report)) {
// In an ideal situation account ID won't be 0
if (actorAccountID.current && actorAccountID.current > 0) {
Navigation.navigate(ROUTES.PROFILE.getRoute(actorAccountID.current));
@@ -198,12 +202,4 @@ function AvatarWithDisplayName({
AvatarWithDisplayName.displayName = 'AvatarWithDisplayName';
-export default withOnyx({
- parentReportActions: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '-1'}`,
- canEvict: false,
- },
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
-})(AvatarWithDisplayName);
+export default AvatarWithDisplayName;
diff --git a/src/components/BookTravelButton.tsx b/src/components/BookTravelButton.tsx
new file mode 100644
index 000000000000..1953acde8ad4
--- /dev/null
+++ b/src/components/BookTravelButton.tsx
@@ -0,0 +1,120 @@
+import {Str} from 'expensify-common';
+import React, {useCallback, useContext, useState} from 'react';
+import {NativeModules} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
+import useLocalize from '@hooks/useLocalize';
+import usePolicy from '@hooks/usePolicy';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {openTravelDotLink} from '@libs/actions/Link';
+import {cleanupTravelProvisioningSession} from '@libs/actions/Travel';
+import Log from '@libs/Log';
+import Navigation from '@libs/Navigation/Navigation';
+import {getAdminsPrivateEmailDomains} from '@libs/PolicyUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import Button from './Button';
+import CustomStatusBarAndBackgroundContext from './CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContext';
+import DotIndicatorMessage from './DotIndicatorMessage';
+
+type BookTravelButtonProps = {
+ text: string;
+};
+
+const navigateToAcceptTerms = (domain: string) => {
+ // Remove the previous provision session infromation if any is cached.
+ cleanupTravelProvisioningSession();
+ Navigation.navigate(ROUTES.TRAVEL_TCS.getRoute(domain));
+};
+
+function BookTravelButton({text}: BookTravelButtonProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
+ const policy = usePolicy(activePolicyID);
+ const [errorMessage, setErrorMessage] = useState('');
+ const [travelSettings] = useOnyx(ONYXKEYS.NVP_TRAVEL_SETTINGS);
+ const [account] = useOnyx(ONYXKEYS.ACCOUNT);
+ const primaryLogin = account?.primaryLogin;
+ const {setRootStatusBarEnabled} = useContext(CustomStatusBarAndBackgroundContext);
+
+ // Flag indicating whether NewDot was launched exclusively for Travel,
+ // e.g., when the user selects "Trips" from the Expensify Classic menu in HybridApp.
+ const [wasNewDotLaunchedJustForTravel] = useOnyx(ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY);
+
+ const bookATrip = useCallback(() => {
+ setErrorMessage('');
+
+ // The primary login of the user is where Spotnana sends the emails with booking confirmations, itinerary etc. It can't be a phone number.
+ if (!primaryLogin || Str.isSMSLogin(primaryLogin)) {
+ setErrorMessage(translate('travel.phoneError'));
+ return;
+ }
+
+ // Spotnana requires an address anytime an entity is created for a policy
+ if (isEmptyObject(policy?.address)) {
+ Navigation.navigate(ROUTES.WORKSPACE_PROFILE_ADDRESS.getRoute(policy?.id, Navigation.getActiveRoute()));
+ return;
+ }
+
+ const isPolicyProvisioned = policy?.travelSettings?.spotnanaCompanyID ?? policy?.travelSettings?.associatedTravelDomainAccountID;
+ if (policy?.travelSettings?.hasAcceptedTerms ?? (travelSettings?.hasAcceptedTerms && isPolicyProvisioned)) {
+ openTravelDotLink(policy?.id)
+ ?.then(() => {
+ // When a user selects "Trips" in the Expensify Classic menu, the HybridApp opens the ManageTrips page in NewDot.
+ // The wasNewDotLaunchedJustForTravel flag indicates if NewDot was launched solely for this purpose.
+ if (!NativeModules.HybridAppModule || !wasNewDotLaunchedJustForTravel) {
+ return;
+ }
+
+ // Close NewDot if it was opened only for Travel, as its purpose is now fulfilled.
+ Log.info('[HybridApp] Returning to OldDot after opening TravelDot');
+ NativeModules.HybridAppModule.closeReactNativeApp(false, false);
+ setRootStatusBarEnabled(false);
+ })
+ ?.catch(() => {
+ setErrorMessage(translate('travel.errorMessage'));
+ });
+ } else if (isPolicyProvisioned) {
+ navigateToAcceptTerms(CONST.TRAVEL.DEFAULT_DOMAIN);
+ } else {
+ // Determine the domain to associate with the workspace during provisioning in Spotnana.
+ // - If all admins share the same private domain, the workspace is tied to it automatically.
+ // - If admins have multiple private domains, the user must select one.
+ // - Public domains are not allowed; an error page is shown in that case.
+ const adminDomains = getAdminsPrivateEmailDomains(policy);
+ if (adminDomains.length === 0) {
+ Navigation.navigate(ROUTES.TRAVEL_PUBLIC_DOMAIN_ERROR);
+ } else if (adminDomains.length === 1) {
+ navigateToAcceptTerms(adminDomains.at(0) ?? CONST.TRAVEL.DEFAULT_DOMAIN);
+ } else {
+ Navigation.navigate(ROUTES.TRAVEL_DOMAIN_SELECTOR);
+ }
+ }
+ }, [policy, wasNewDotLaunchedJustForTravel, travelSettings, translate, primaryLogin, setRootStatusBarEnabled]);
+
+ return (
+ <>
+ {!!errorMessage && (
+
+ )}
+
+ >
+ );
+}
+
+BookTravelButton.displayName = 'BookTravelButton';
+
+export default BookTravelButton;
diff --git a/src/components/DistanceRequest/DistanceRequestFooter.tsx b/src/components/DistanceRequest/DistanceRequestFooter.tsx
index 8a4455e02bd6..a811face3340 100644
--- a/src/components/DistanceRequest/DistanceRequestFooter.tsx
+++ b/src/components/DistanceRequest/DistanceRequestFooter.tsx
@@ -1,7 +1,7 @@
import React, {useCallback, useMemo} from 'react';
import type {ReactNode} from 'react';
import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import Button from '@components/Button';
import DistanceMapView from '@components/DistanceMapView';
@@ -9,24 +9,22 @@ import * as Expensicons from '@components/Icon/Expensicons';
import ImageSVG from '@components/ImageSVG';
import type {WayPoint} from '@components/MapView/MapViewTypes';
import useLocalize from '@hooks/useLocalize';
+import usePolicy from '@hooks/usePolicy';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
-import * as TransactionUtils from '@libs/TransactionUtils';
+import DistanceRequestUtils from '@libs/DistanceRequestUtils';
+import {getPersonalPolicy} from '@libs/PolicyUtils';
+import {getDistanceInMeters, getWaypointIndex, isCustomUnitRateIDForP2P} from '@libs/TransactionUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {MapboxAccessToken} from '@src/types/onyx';
+import type {Policy} from '@src/types/onyx';
import type {WaypointCollection} from '@src/types/onyx/Transaction';
import type Transaction from '@src/types/onyx/Transaction';
import type IconAsset from '@src/types/utils/IconAsset';
const MAX_WAYPOINTS = 25;
-type DistanceRequestFooterOnyxProps = {
- /** Data about Mapbox token for calling Mapbox API */
- mapboxAccessToken: OnyxEntry;
-};
-
-type DistanceRequestFooterProps = DistanceRequestFooterOnyxProps & {
+type DistanceRequestFooterProps = {
/** The waypoints for the distance expense */
waypoints?: WaypointCollection;
@@ -35,16 +33,26 @@ type DistanceRequestFooterProps = DistanceRequestFooterOnyxProps & {
/** The transaction being interacted with */
transaction: OnyxEntry;
+
+ /** The policy */
+ policy: OnyxEntry;
};
-function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navigateToWaypointEditPage}: DistanceRequestFooterProps) {
+function DistanceRequestFooter({waypoints, transaction, navigateToWaypointEditPage, policy}: DistanceRequestFooterProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
+ const activePolicy = usePolicy(activePolicyID);
+ const [mapboxAccessToken] = useOnyx(ONYXKEYS.MAPBOX_ACCESS_TOKEN);
const numberOfWaypoints = Object.keys(waypoints ?? {}).length;
const numberOfFilledWaypoints = Object.values(waypoints ?? {}).filter((waypoint) => waypoint?.address).length;
const lastWaypointIndex = numberOfWaypoints - 1;
+ const defaultMileageRate = DistanceRequestUtils.getDefaultMileageRate(policy ?? activePolicy);
+ const policyCurrency = (policy ?? activePolicy)?.outputCurrency ?? getPersonalPolicy()?.outputCurrency ?? CONST.CURRENCY.USD;
+ const mileageRate = isCustomUnitRateIDForP2P(transaction) ? DistanceRequestUtils.getRateForP2P(policyCurrency, transaction) : defaultMileageRate;
+ const {unit} = mileageRate ?? {};
const getMarkerComponent = useCallback(
(icon: IconAsset): ReactNode => (
@@ -66,7 +74,7 @@ function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navig
return;
}
- const index = TransactionUtils.getWaypointIndex(key);
+ const index = getWaypointIndex(key);
let MarkerComponent: IconAsset;
if (index === 0) {
MarkerComponent = Expensicons.DotIndicatorUnfilled;
@@ -114,6 +122,8 @@ function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navig
waypoints={waypointMarkers}
styleURL={CONST.MAPBOX.STYLE_URL}
overlayStyle={styles.mapEditView}
+ distanceInMeters={getDistanceInMeters(transaction, undefined)}
+ unit={unit}
/>
>
@@ -122,8 +132,4 @@ function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navig
DistanceRequestFooter.displayName = 'DistanceRequestFooter';
-export default withOnyx({
- mapboxAccessToken: {
- key: ONYXKEYS.MAPBOX_ACCESS_TOKEN,
- },
-})(DistanceRequestFooter);
+export default DistanceRequestFooter;
diff --git a/src/components/EmptyStateComponent/index.tsx b/src/components/EmptyStateComponent/index.tsx
index ae7ea7f09503..69e542e44798 100644
--- a/src/components/EmptyStateComponent/index.tsx
+++ b/src/components/EmptyStateComponent/index.tsx
@@ -23,6 +23,7 @@ function EmptyStateComponent({
title,
titleStyles,
subtitle,
+ children,
headerStyles,
headerContentStyles,
lottieWebViewStyles,
@@ -99,7 +100,8 @@ function EmptyStateComponent({
{HeaderComponent}
{title}
- {typeof subtitle === 'string' ? {subtitle} : subtitle}
+ {subtitle}
+ {children}
{buttons?.map(({buttonText, buttonAction, success, icon, isDisabled}, index) => (
= {
SkeletonComponent: ValidSkeletons;
title: string;
titleStyles?: StyleProp;
- subtitle: string | React.ReactNode;
+ subtitle?: string;
+ children?: React.ReactNode;
buttons?: Button[];
containerStyles?: StyleProp;
headerStyles?: StyleProp;
diff --git a/src/components/FeatureList.tsx b/src/components/FeatureList.tsx
index 70c56ad5d963..179eee2d5810 100644
--- a/src/components/FeatureList.tsx
+++ b/src/components/FeatureList.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import type {ReactNode} from 'react';
import {View} from 'react-native';
import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
import useLocalize from '@hooks/useLocalize';
@@ -7,7 +8,6 @@ import variables from '@styles/variables';
import type {TranslationPaths} from '@src/languages/types';
import type IconAsset from '@src/types/utils/IconAsset';
import Button from './Button';
-import DotIndicatorMessage from './DotIndicatorMessage';
import type DotLottieAnimation from './LottieAnimations/types';
import MenuItem from './MenuItem';
import Section from './Section';
@@ -33,15 +33,6 @@ type FeatureListProps = {
/** Action to call on cta button press */
onCtaPress?: () => void;
- /** Text of the secondary button button */
- secondaryButtonText?: string;
-
- /** Accessibility label for the secondary button */
- secondaryButtonAccessibilityLabel?: string;
-
- /** Action to call on secondary button press */
- onSecondaryButtonPress?: () => void;
-
/** A list of menuItems representing the feature list. */
menuItems: FeatureListItem[];
@@ -60,23 +51,19 @@ type FeatureListProps = {
/** The style used for the title */
titleStyles?: StyleProp;
- /** The error message to display for the CTA button */
- ctaErrorMessage?: string;
-
/** Padding for content on large screens */
contentPaddingOnLargeScreens?: {padding: number};
+
+ /** Custom content to display in the footer */
+ footer?: ReactNode;
};
function FeatureList({
title,
subtitle = '',
- ctaText = '',
- ctaAccessibilityLabel = '',
- onCtaPress = () => {},
- secondaryButtonText = '',
- secondaryButtonAccessibilityLabel = '',
- onSecondaryButtonPress = () => {},
- ctaErrorMessage,
+ ctaText,
+ ctaAccessibilityLabel,
+ onCtaPress,
menuItems,
illustration,
illustrationStyle,
@@ -84,6 +71,7 @@ function FeatureList({
illustrationContainerStyle,
titleStyles,
contentPaddingOnLargeScreens,
+ footer,
}: FeatureListProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
@@ -122,30 +110,17 @@ function FeatureList({
))}
- {!!secondaryButtonText && (
+ {!!ctaText && (
)}
- {!!ctaErrorMessage && (
-
- )}
-
+ {!!footer && footer}
);
diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts
index da402f612a2c..b71c9e2402c5 100644
--- a/src/components/Icon/Expensicons.ts
+++ b/src/components/Icon/Expensicons.ts
@@ -88,6 +88,7 @@ import ExpensifyFooterLogoVertical from '@assets/images/expensify-footer-logo-ve
import ExpensifyFooterLogo from '@assets/images/expensify-footer-logo.svg';
import ExpensifyLogoNew from '@assets/images/expensify-logo-new.svg';
import ExpensifyWordmark from '@assets/images/expensify-wordmark.svg';
+import Export from '@assets/images/export.svg';
import EyeDisabled from '@assets/images/eye-disabled.svg';
import Eye from '@assets/images/eye.svg';
import Feed from '@assets/images/feed.svg';
@@ -285,6 +286,7 @@ export {
ExpensifyFooterLogo,
ExpensifyFooterLogoVertical,
Expand,
+ Export,
Eye,
EyeDisabled,
FallbackAvatar,
diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx
index 6f1c7aaee458..ac202c1dc6a0 100644
--- a/src/components/MapView/MapView.tsx
+++ b/src/components/MapView/MapView.tsx
@@ -6,9 +6,12 @@ import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
import * as Expensicons from '@components/Icon/Expensicons';
+import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
+import Text from '@components/Text';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
-import * as UserLocation from '@libs/actions/UserLocation';
+import {clearUserLocation, setUserLocation} from '@libs/actions/UserLocation';
+import DistanceRequestUtils from '@libs/DistanceRequestUtils';
import getCurrentPosition from '@libs/getCurrentPosition';
import type {GeolocationErrorCallback} from '@libs/getCurrentPosition/getCurrentPosition.types';
import {GeolocationErrorCode} from '@libs/getCurrentPosition/getCurrentPosition.types';
@@ -24,7 +27,7 @@ import responder from './responder';
import utils from './utils';
const MapView = forwardRef(
- ({accessToken, style, mapPadding, styleURL, pitchEnabled, initialState, waypoints, directionCoordinates, onMapReady, interactive = true}, ref) => {
+ ({accessToken, style, mapPadding, styleURL, pitchEnabled, initialState, waypoints, directionCoordinates, onMapReady, interactive = true, distanceInMeters, unit}, ref) => {
const [userLocation] = useOnyx(ONYXKEYS.USER_LOCATION);
const navigation = useNavigation();
const {isOffline} = useNetwork();
@@ -39,6 +42,25 @@ const MapView = forwardRef(
const shouldInitializeCurrentPosition = useRef(true);
const [isAccessTokenSet, setIsAccessTokenSet] = useState(false);
+ const [distanceUnit, setDistanceUnit] = useState(unit);
+ useEffect(() => {
+ if (!unit || distanceUnit) {
+ return;
+ }
+ setDistanceUnit(unit);
+ }, [unit, distanceUnit]);
+
+ const toggleDistanceUnit = useCallback(() => {
+ setDistanceUnit((currentUnit) =>
+ currentUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS ? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES : CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS,
+ );
+ }, []);
+
+ const distanceLabelText = useMemo(
+ () => DistanceRequestUtils.getDistanceForDisplayLabel(distanceInMeters ?? 0, distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS),
+ [distanceInMeters, distanceUnit],
+ );
+
// Determines if map can be panned to user's detected
// location without bothering the user. It will return
// false if user has already started dragging the map or
@@ -50,7 +72,7 @@ const MapView = forwardRef(
if (error?.code !== GeolocationErrorCode.PERMISSION_DENIED || !initialLocation) {
return;
}
- UserLocation.clearUserLocation();
+ clearUserLocation();
},
[initialLocation],
);
@@ -74,7 +96,7 @@ const MapView = forwardRef(
getCurrentPosition((params) => {
const currentCoords = {longitude: params.coords.longitude, latitude: params.coords.latitude};
- UserLocation.setUserLocation(currentCoords);
+ setUserLocation(currentCoords);
}, setCurrentPositionToInitialState);
}, [isOffline, shouldPanMapToCurrentPosition, setCurrentPositionToInitialState]),
);
@@ -205,6 +227,20 @@ const MapView = forwardRef(
const initCenterCoordinate = useMemo(() => (interactive ? centerCoordinate : undefined), [interactive, centerCoordinate]);
const initBounds = useMemo(() => (interactive ? undefined : waypointsBounds), [interactive, waypointsBounds]);
+ const distanceSymbolCoorinate = useMemo(() => {
+ const length = directionCoordinates?.length;
+ // If the array is empty, return undefined
+ if (!length) {
+ return undefined;
+ }
+
+ // Find the index of the middle element
+ const middleIndex = Math.floor(length / 2);
+
+ // Return the middle element
+ return directionCoordinates.at(middleIndex);
+ }, [directionCoordinates]);
+
return !isOffline && isAccessTokenSet && !!defaultSettings ? (
(
/>
)}
-
{waypoints?.map(({coordinate, markerComponent, id}) => {
const MarkerComponent = markerComponent;
if (utils.areSameCoordinate([coordinate[0], coordinate[1]], [currentPosition?.longitude ?? 0, currentPosition?.latitude ?? 0]) && interactive) {
@@ -276,6 +311,25 @@ const MapView = forwardRef(
})}
{!!directionCoordinates && }
+ {!!distanceSymbolCoorinate && !!distanceInMeters && !!distanceUnit && (
+
+
+
+
+ {distanceLabelText}
+
+
+
+
+ )}
{interactive && (
diff --git a/src/components/MapView/MapViewImpl.website.tsx b/src/components/MapView/MapViewImpl.website.tsx
index c71ce6fb237b..4fdf71252895 100644
--- a/src/components/MapView/MapViewImpl.website.tsx
+++ b/src/components/MapView/MapViewImpl.website.tsx
@@ -12,10 +12,12 @@ import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
import * as Expensicons from '@components/Icon/Expensicons';
+import {PressableWithoutFeedback} from '@components/Pressable';
import usePrevious from '@hooks/usePrevious';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import DistanceRequestUtils from '@libs/DistanceRequestUtils';
import type {GeolocationErrorCallback} from '@libs/getCurrentPosition/getCurrentPosition.types';
import {GeolocationErrorCode} from '@libs/getCurrentPosition/getCurrentPosition.types';
import {clearUserLocation, setUserLocation} from '@userActions/UserLocation';
@@ -42,6 +44,8 @@ const MapViewImpl = forwardRef(
directionCoordinates,
initialState = {location: CONST.MAPBOX.DEFAULT_COORDINATE, zoom: CONST.MAPBOX.DEFAULT_ZOOM},
interactive = true,
+ distanceInMeters,
+ unit,
},
ref,
) => {
@@ -49,6 +53,19 @@ const MapViewImpl = forwardRef(
const {isOffline} = useNetwork();
const {translate} = useLocalize();
+ const [distanceUnit, setDistanceUnit] = useState(unit);
+ useEffect(() => {
+ if (!unit || distanceUnit) {
+ return;
+ }
+ setDistanceUnit(unit);
+ }, [unit, distanceUnit]);
+
+ const toggleDistanceUnit = useCallback(() => {
+ setDistanceUnit((currentUnit) =>
+ currentUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS ? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES : CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS,
+ );
+ }, []);
const theme = useTheme();
const styles = useThemeStyles();
@@ -232,6 +249,20 @@ const MapViewImpl = forwardRef(
};
}, [waypoints, directionCoordinates, interactive, currentPosition, initialState.zoom]);
+ const distanceSymbolCoorinate = useMemo(() => {
+ const length = directionCoordinates?.length;
+ // If the array is empty, return undefined
+ if (!length) {
+ return undefined;
+ }
+
+ // Find the index of the middle element
+ const middleIndex = Math.floor(length / 2);
+
+ // Return the middle element
+ return directionCoordinates.at(middleIndex);
+ }, [directionCoordinates]);
+
return !isOffline && !!accessToken && !!initialViewState ? (
(
)}
+ {!!distanceSymbolCoorinate && !!distanceInMeters && !!distanceUnit && (
+
+
+
+ {DistanceRequestUtils.getDistanceForDisplayLabel(distanceInMeters, distanceUnit)}
+
+
+
+ )}
{waypoints?.map(({coordinate, markerComponent, id}) => {
const MarkerComponent = markerComponent;
if (utils.areSameCoordinate([coordinate[0], coordinate[1]], [currentPosition?.longitude ?? 0, currentPosition?.latitude ?? 0]) && interactive) {
diff --git a/src/components/MapView/MapViewTypes.ts b/src/components/MapView/MapViewTypes.ts
index 41170694c9d2..11e90a78fffc 100644
--- a/src/components/MapView/MapViewTypes.ts
+++ b/src/components/MapView/MapViewTypes.ts
@@ -1,5 +1,6 @@
import type {ReactNode} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
+import type {Unit} from '@src/types/onyx/Policy';
type MapViewProps = {
// Public access token to be used to fetch map data from Mapbox.
@@ -22,6 +23,12 @@ type MapViewProps = {
onMapReady?: () => void;
// Whether the map is interactable or not
interactive?: boolean;
+
+ // Distance displayed on the map in meters.
+ distanceInMeters?: number;
+
+ // Unit of measurement for distance
+ unit?: Unit;
};
type DirectionProps = {
diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx
index f8169503f932..158970d1e9f3 100644
--- a/src/components/MultipleAvatars.tsx
+++ b/src/components/MultipleAvatars.tsx
@@ -5,7 +5,7 @@ import type {ValueOf} from 'type-fest';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
-import * as ReportUtils from '@libs/ReportUtils';
+import {getUserDetailTooltipText} from '@libs/ReportUtils';
import type {AvatarSource} from '@libs/UserUtils';
import variables from '@styles/variables';
import CONST from '@src/CONST';
@@ -37,6 +37,9 @@ type MultipleAvatarsProps = {
/** Whether the avatars are hovered */
isHovered?: boolean;
+ /** Whether the avatars are actived */
+ isActive?: boolean;
+
/** Whether the avatars are in an element being pressed */
isPressed?: boolean;
@@ -76,6 +79,7 @@ function MultipleAvatars({
shouldStackHorizontally = false,
shouldDisplayAvatarsInRows = false,
isHovered = false,
+ isActive = false,
isPressed = false,
isFocusMode = false,
isInReportAction = false,
@@ -110,7 +114,7 @@ function MultipleAvatars({
let avatarContainerStyles = StyleUtils.getContainerStyles(size, isInReportAction);
const {singleAvatarStyle, secondAvatarStyles} = useMemo(() => avatarSizeToStylesMap[size as AvatarSizeToStyles] ?? avatarSizeToStylesMap.default, [size, avatarSizeToStylesMap]);
- const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => ReportUtils.getUserDetailTooltipText(Number(icon.id), icon.name)) : ['']), [shouldShowTooltip, icons]);
+ const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => getUserDetailTooltipText(Number(icon.id), icon.name)) : ['']), [shouldShowTooltip, icons]);
const avatarSize = useMemo(() => {
if (isFocusMode) {
@@ -173,7 +177,6 @@ function MultipleAvatars({
const oneAvatarSize = StyleUtils.getAvatarStyle(size);
const oneAvatarBorderWidth = StyleUtils.getAvatarBorderWidth(size).borderWidth ?? 0;
const overlapSize = oneAvatarSize.width / overlapDivider;
-
if (shouldStackHorizontally) {
// Height of one avatar + border space
const height = oneAvatarSize.height + 2 * oneAvatarBorderWidth;
@@ -206,6 +209,7 @@ function MultipleAvatars({
isPressed,
isInReportAction,
shouldUseCardBackground,
+ isActive,
}),
StyleUtils.getAvatarBorderWidth(size),
]}
diff --git a/src/components/Search/SearchAutocompleteInput.tsx b/src/components/Search/SearchAutocompleteInput.tsx
index 40157e929b33..a89c68364ad9 100644
--- a/src/components/Search/SearchAutocompleteInput.tsx
+++ b/src/components/Search/SearchAutocompleteInput.tsx
@@ -1,23 +1,27 @@
import type {ForwardedRef, ReactNode, RefObject} from 'react';
-import React, {forwardRef, useLayoutEffect, useState} from 'react';
+import React, {forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useState} from 'react';
import {View} from 'react-native';
import type {StyleProp, TextInputProps, ViewStyle} from 'react-native';
import {useOnyx} from 'react-native-onyx';
+import {useSharedValue} from 'react-native-reanimated';
import FormHelpMessage from '@components/FormHelpMessage';
import type {SelectionListHandle} from '@components/SelectionList/types';
import TextInput from '@components/TextInput';
import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
+import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import {parseFSAttributes} from '@libs/Fullstory';
-import {parseForLiveMarkdown} from '@libs/SearchAutocompleteUtils';
+import runOnLiveMarkdownRuntime from '@libs/runOnLiveMarkdownRuntime';
+import {getAutocompleteCategories, getAutocompleteTags, parseForLiveMarkdown} from '@libs/SearchAutocompleteUtils';
import handleKeyPress from '@libs/SearchInputOnKeyPress';
import shouldDelayFocus from '@libs/shouldDelayFocus';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import type {SubstitutionMap} from './SearchRouter/getQueryWithSubstitutions';
type SearchAutocompleteInputProps = {
/** Value of TextInput */
@@ -61,6 +65,9 @@ type SearchAutocompleteInputProps = {
/** Whether the search reports API call is running */
isSearchingForReports?: boolean;
+
+ /** Map of autocomplete suggestions. Required for highlighting to work properly */
+ substitutionMap: SubstitutionMap;
} & Pick;
function SearchAutocompleteInput(
@@ -82,6 +89,7 @@ function SearchAutocompleteInput(
rightComponent,
isSearchingForReports,
selection,
+ substitutionMap,
}: SearchAutocompleteInputProps,
ref: ForwardedRef,
) {
@@ -89,13 +97,72 @@ function SearchAutocompleteInput(
const {translate} = useLocalize();
const [isFocused, setIsFocused] = useState(false);
const {isOffline} = useNetwork();
+ const {activeWorkspaceID} = useActiveWorkspace();
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
+ const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST);
+ const currencyAutocompleteList = Object.keys(currencyList ?? {});
+ const currencySharedValue = useSharedValue(currencyAutocompleteList);
+
+ const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
+ const categoryAutocompleteList = useMemo(() => {
+ return getAutocompleteCategories(allPolicyCategories, activeWorkspaceID);
+ }, [activeWorkspaceID, allPolicyCategories]);
+ const categorySharedValue = useSharedValue(categoryAutocompleteList);
+
+ const [allPoliciesTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
+ const tagAutocompleteList = useMemo(() => {
+ return getAutocompleteTags(allPoliciesTags, activeWorkspaceID);
+ }, [activeWorkspaceID, allPoliciesTags]);
+ const tagSharedValue = useSharedValue(tagAutocompleteList);
+
const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST);
const emailList = Object.keys(loginList ?? {});
+ const emailListSharedValue = useSharedValue(emailList);
const offlineMessage: string = isOffline && shouldShowOfflineMessage ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : '';
+ useEffect(() => {
+ runOnLiveMarkdownRuntime(() => {
+ 'worklet';
+
+ emailListSharedValue.set(emailList);
+ })();
+ }, [emailList, emailListSharedValue]);
+
+ useEffect(() => {
+ runOnLiveMarkdownRuntime(() => {
+ 'worklet';
+
+ currencySharedValue.set(currencyAutocompleteList);
+ })();
+ }, [currencyAutocompleteList, currencySharedValue]);
+
+ useEffect(() => {
+ runOnLiveMarkdownRuntime(() => {
+ 'worklet';
+
+ categorySharedValue.set(categoryAutocompleteList);
+ })();
+ }, [categorySharedValue, categoryAutocompleteList]);
+
+ useEffect(() => {
+ runOnLiveMarkdownRuntime(() => {
+ 'worklet';
+
+ tagSharedValue.set(tagAutocompleteList);
+ });
+ }, [tagSharedValue, tagAutocompleteList]);
+
+ const parser = useCallback(
+ (input: string) => {
+ 'worklet';
+
+ return parseForLiveMarkdown(input, currentUserPersonalDetails.displayName ?? '', substitutionMap, emailListSharedValue, currencySharedValue, categorySharedValue, tagSharedValue);
+ },
+ [currentUserPersonalDetails.displayName, substitutionMap, currencySharedValue, categorySharedValue, tagSharedValue, emailListSharedValue],
+ );
+
const inputWidth = isFullWidth ? styles.w100 : {width: variables.popoverWidth};
// Parse Fullstory attributes on initial render
@@ -145,11 +212,7 @@ function SearchAutocompleteInput(
onKeyPress={handleKeyPress(onSubmit)}
type="markdown"
multiline={false}
- parser={(input: string) => {
- 'worklet';
-
- return parseForLiveMarkdown(input, emailList, currentUserPersonalDetails.displayName ?? '');
- }}
+ parser={parser}
selection={selection}
/>
diff --git a/src/components/Search/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeaderInput.tsx
index a3e6e0a034fd..d39c58f9edea 100644
--- a/src/components/Search/SearchPageHeaderInput.tsx
+++ b/src/components/Search/SearchPageHeaderInput.tsx
@@ -1,4 +1,5 @@
import {useIsFocused} from '@react-navigation/native';
+import isEqual from 'lodash/isEqual';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
@@ -123,7 +124,9 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps
setAutocompleteQueryValue(updatedUserQuery);
const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions);
- setAutocompleteSubstitutions(updatedSubstitutionsMap);
+ if (!isEqual(autocompleteSubstitutions, updatedSubstitutionsMap)) {
+ setAutocompleteSubstitutions(updatedSubstitutionsMap);
+ }
if (updatedUserQuery) {
listRef.current?.updateAndScrollToFocusedIndex(0);
@@ -290,6 +293,7 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps
autocompleteListRef={listRef}
ref={textInputRef}
selection={selection}
+ substitutionMap={autocompleteSubstitutions}
/>
0) {
listRef.current?.updateAndScrollToFocusedIndex(0);
@@ -323,6 +326,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps)
wrapperFocusedStyle={[styles.borderColorFocus]}
isSearchingForReports={isSearchingForReports}
selection={selection}
+ substitutionMap={autocompleteSubstitutions}
ref={textInputRef}
/>
range.key !== CONST.SEARCH.SYNTAX_RANGE_NAME);
if (searchAutocompleteQueryRanges.length === 0) {
return {};
diff --git a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts
index 84895efb33a5..e20577b7df45 100644
--- a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts
+++ b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts
@@ -1,10 +1,11 @@
-import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types';
+import type {SearchAutocompleteQueryRange, SearchAutocompleteQueryRangeKey} from '@components/Search/types';
import {parse} from '@libs/SearchParser/autocompleteParser';
import {sanitizeSearchValue} from '@libs/SearchQueryUtils';
+import CONST from '@src/CONST';
type SubstitutionMap = Record;
-const getSubstitutionMapKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`;
+const getSubstitutionMapKey = (filterKey: SearchAutocompleteQueryRangeKey, value: string) => `${filterKey}:${value}`;
/**
* Given a plaintext query and a SubstitutionMap object, this function will return a transformed query where:
@@ -21,7 +22,7 @@ const getSubstitutionMapKey = (filterKey: SearchFilterKey, value: string) => `${
function getQueryWithSubstitutions(changedQuery: string, substitutions: SubstitutionMap) {
const parsed = parse(changedQuery) as {ranges: SearchAutocompleteQueryRange[]};
- const searchAutocompleteQueryRanges = parsed.ranges;
+ const searchAutocompleteQueryRanges = parsed.ranges.filter((range) => range.key !== CONST.SEARCH.SYNTAX_RANGE_NAME);
if (searchAutocompleteQueryRanges.length === 0) {
return changedQuery;
diff --git a/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts
index ee7bf3850259..aa37f84acc68 100644
--- a/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts
+++ b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts
@@ -1,8 +1,9 @@
-import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types';
-import * as parser from '@libs/SearchParser/autocompleteParser';
+import type {SearchAutocompleteQueryRange, SearchAutocompleteQueryRangeKey} from '@components/Search/types';
+import {parse} from '@libs/SearchParser/autocompleteParser';
+import CONST from '@src/CONST';
import type {SubstitutionMap} from './getQueryWithSubstitutions';
-const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`;
+const getSubstitutionsKey = (filterKey: SearchAutocompleteQueryRangeKey, value: string) => `${filterKey}:${value}`;
/**
* Given a plaintext query and a SubstitutionMap object,
@@ -16,9 +17,9 @@ const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${fi
* return: {}
*/
function getUpdatedSubstitutionsMap(query: string, substitutions: SubstitutionMap): SubstitutionMap {
- const parsedQuery = parser.parse(query) as {ranges: SearchAutocompleteQueryRange[]};
+ const parsedQuery = parse(query) as {ranges: SearchAutocompleteQueryRange[]};
- const searchAutocompleteQueryRanges = parsedQuery.ranges;
+ const searchAutocompleteQueryRanges = parsedQuery.ranges.filter((range) => range.key !== CONST.SEARCH.SYNTAX_RANGE_NAME);
if (searchAutocompleteQueryRanges.length === 0) {
return {};
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index 1f5443b11a58..153e635da7b6 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -239,7 +239,8 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo
transactions,
previousTransactions,
queryJSON,
- offset,
+ // Set offset to 0 to retrieve the most recent chat messages.
+ offset: 0,
reportActions,
previousReportActions,
});
diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts
index 3dc408cc27c3..887c70944660 100644
--- a/src/components/Search/types.ts
+++ b/src/components/Search/types.ts
@@ -98,6 +98,8 @@ type SearchFilterKey =
| typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS
| typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID;
+type SearchAutocompleteQueryRangeKey = SearchFilterKey | typeof CONST.SEARCH.SYNTAX_RANGE_NAME;
+
type UserFriendlyKey = ValueOf;
type QueryFilters = Array<{
@@ -130,7 +132,7 @@ type SearchAutocompleteResult = {
};
type SearchAutocompleteQueryRange = {
- key: SearchFilterKey;
+ key: SearchAutocompleteQueryRangeKey;
length: number;
start: number;
value: string;
@@ -159,4 +161,5 @@ export type {
SearchAutocompleteResult,
PaymentData,
SearchAutocompleteQueryRange,
+ SearchAutocompleteQueryRangeKey,
};
diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx
index b0d9bcc44485..bad9a1a169c9 100644
--- a/src/components/VideoPlayer/BaseVideoPlayer.tsx
+++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx
@@ -20,7 +20,7 @@ import VideoPopoverMenu from '@components/VideoPopoverMenu';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL';
-import * as DeviceCapabilities from '@libs/DeviceCapabilities';
+import {canUseTouchScreen as canUseTouchScreenLib} from '@libs/DeviceCapabilities';
import CONST from '@src/CONST';
import shouldReplayVideo from './shouldReplayVideo';
import type {VideoPlayerProps, VideoWithOnFullScreenUpdate} from './types';
@@ -86,12 +86,12 @@ function BaseVideoPlayer({
const videoPlayerElementParentRef = useRef(null);
const videoPlayerElementRef = useRef(null);
const sharedVideoPlayerParentRef = useRef(null);
- const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen();
+ const canUseTouchScreen = canUseTouchScreenLib();
const isCurrentlyURLSet = currentlyPlayingURL === url;
const isUploading = CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => url.startsWith(prefix));
const videoStateRef = useRef(null);
const {updateVolume, lastNonZeroVolume} = useVolumeContext();
- const {videoPopoverMenuPlayerRef, currentPlaybackSpeed, setCurrentPlaybackSpeed} = useVideoPopoverMenuContext();
+ const {videoPopoverMenuPlayerRef, currentPlaybackSpeed, setCurrentPlaybackSpeed, setSource: setPopoverMenuSource} = useVideoPopoverMenuContext();
const {source} = videoPopoverMenuPlayerRef.current?.props ?? {};
const shouldUseNewRate = typeof source === 'number' || !source || source.uri !== sourceURL;
@@ -163,6 +163,7 @@ function BaseVideoPlayer({
}
setIsPopoverVisible(true);
});
+ setPopoverMenuSource(url);
if (!event || !('nativeEvent' in event)) {
return;
}
diff --git a/src/components/VideoPlayerContexts/VideoPopoverMenuContext.tsx b/src/components/VideoPlayerContexts/VideoPopoverMenuContext.tsx
index 42373da91789..310563114849 100644
--- a/src/components/VideoPlayerContexts/VideoPopoverMenuContext.tsx
+++ b/src/components/VideoPlayerContexts/VideoPopoverMenuContext.tsx
@@ -4,20 +4,20 @@ import type {PopoverMenuItem} from '@components/PopoverMenu';
import type {VideoWithOnFullScreenUpdate} from '@components/VideoPlayer/types';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
+import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL';
import fileDownload from '@libs/fileDownload';
import CONST from '@src/CONST';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
-import {usePlaybackContext} from './PlaybackContext';
import type {PlaybackSpeed, VideoPopoverMenuContext} from './types';
const Context = React.createContext(null);
function VideoPopoverMenuContextProvider({children}: ChildrenProps) {
- const {currentlyPlayingURL} = usePlaybackContext();
const {translate} = useLocalize();
+ const [source, setSource] = useState('');
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(CONST.VIDEO_PLAYER.PLAYBACK_SPEEDS[3]);
const {isOffline} = useNetwork();
- const isLocalFile = currentlyPlayingURL && CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => currentlyPlayingURL.startsWith(prefix));
+ const isLocalFile = source && CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => source.startsWith(prefix));
const videoPopoverMenuPlayerRef = useRef(null);
const updatePlaybackSpeed = useCallback(
@@ -29,15 +29,11 @@ function VideoPopoverMenuContextProvider({children}: ChildrenProps) {
);
const downloadAttachment = useCallback(() => {
- if (videoPopoverMenuPlayerRef.current === null) {
- return;
- }
- const {source} = videoPopoverMenuPlayerRef.current?.props ?? {};
if (typeof source === 'number' || !source) {
return;
}
- fileDownload(source.uri);
- }, [videoPopoverMenuPlayerRef]);
+ fileDownload(addEncryptedAuthTokenToURL(source));
+ }, [source]);
const menuItems = useMemo(() => {
const items: PopoverMenuItem[] = [];
@@ -70,8 +66,8 @@ function VideoPopoverMenuContextProvider({children}: ChildrenProps) {
}, [currentPlaybackSpeed, downloadAttachment, translate, updatePlaybackSpeed, isOffline, isLocalFile]);
const contextValue = useMemo(
- () => ({menuItems, videoPopoverMenuPlayerRef, currentPlaybackSpeed, updatePlaybackSpeed, setCurrentPlaybackSpeed}),
- [menuItems, videoPopoverMenuPlayerRef, currentPlaybackSpeed, updatePlaybackSpeed, setCurrentPlaybackSpeed],
+ () => ({menuItems, videoPopoverMenuPlayerRef, currentPlaybackSpeed, updatePlaybackSpeed, setCurrentPlaybackSpeed, setSource}),
+ [menuItems, videoPopoverMenuPlayerRef, currentPlaybackSpeed, updatePlaybackSpeed, setCurrentPlaybackSpeed, setSource],
);
return {children};
}
diff --git a/src/components/VideoPlayerContexts/types.ts b/src/components/VideoPlayerContexts/types.ts
index b376e9dd5f14..532d3a0131d3 100644
--- a/src/components/VideoPlayerContexts/types.ts
+++ b/src/components/VideoPlayerContexts/types.ts
@@ -35,6 +35,7 @@ type VideoPopoverMenuContext = {
currentPlaybackSpeed: PlaybackSpeed;
updatePlaybackSpeed: (speed: PlaybackSpeed) => void;
setCurrentPlaybackSpeed: (speed: PlaybackSpeed) => void;
+ setSource: (source: string) => void;
};
type FullScreenContext = {
diff --git a/src/hooks/useHtmlPaste/index.ts b/src/hooks/useHtmlPaste/index.ts
index 1a7e62f3141e..1a5606ef48ad 100644
--- a/src/hooks/useHtmlPaste/index.ts
+++ b/src/hooks/useHtmlPaste/index.ts
@@ -1,4 +1,5 @@
import {useCallback, useEffect} from 'react';
+import {isMobile} from '@libs/Browser';
import Parser from '@libs/Parser';
import CONST from '@src/CONST';
import type UseHtmlPaste from './types';
@@ -89,9 +90,14 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, isActive
*/
const handlePastePlainText = useCallback(
(event: ClipboardEvent) => {
- const plainText = event.clipboardData?.getData('text/plain');
- if (plainText) {
- paste(plainText);
+ const markdownText = event.clipboardData?.getData('text/plain');
+ // Updated paste logic to address issue #53718
+ // When copying from a chat conversation, the clipboard contains markdown-formatted text.
+ // On desktop web, users have the option to paste as plain text, but this feature is unavailable on mobile web.
+ // A conditional check is added to determine whether to retain markdown or convert it to plain text based on the platform.
+ if (markdownText) {
+ const parsedText = isMobile() ? markdownText : Parser.htmlToText(Parser.replace(markdownText));
+ paste(parsedText);
}
},
[paste],
diff --git a/src/hooks/useSearchBackPress/index.android.ts b/src/hooks/useSearchBackPress/index.android.ts
new file mode 100644
index 000000000000..32aa7bb5b87a
--- /dev/null
+++ b/src/hooks/useSearchBackPress/index.android.ts
@@ -0,0 +1,28 @@
+import {useFocusEffect} from '@react-navigation/native';
+import {useCallback} from 'react';
+import {BackHandler} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
+import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type UseSearchBackPress from './types';
+
+const useSearchBackPress: UseSearchBackPress = ({onClearSelection, onNavigationCallBack}) => {
+ const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE);
+ useFocusEffect(
+ useCallback(() => {
+ const onBackPress = () => {
+ if (selectionMode?.isEnabled) {
+ onClearSelection();
+ turnOffMobileSelectionMode();
+ return true;
+ }
+ onNavigationCallBack();
+ return false;
+ };
+ const backHandler = BackHandler.addEventListener('hardwareBackPress', onBackPress);
+ return () => backHandler.remove();
+ }, [selectionMode?.isEnabled, onClearSelection, onNavigationCallBack]),
+ );
+};
+
+export default useSearchBackPress;
diff --git a/src/hooks/useSearchBackPress/index.ts b/src/hooks/useSearchBackPress/index.ts
new file mode 100644
index 000000000000..7cc904a7f31b
--- /dev/null
+++ b/src/hooks/useSearchBackPress/index.ts
@@ -0,0 +1,6 @@
+import type UseSearchBackPress from './types';
+
+// the back press event is only supported on Android native
+const useSearchBackPress: UseSearchBackPress = () => {};
+
+export default useSearchBackPress;
diff --git a/src/hooks/useSearchBackPress/types.ts b/src/hooks/useSearchBackPress/types.ts
new file mode 100644
index 000000000000..64b1a440b1cf
--- /dev/null
+++ b/src/hooks/useSearchBackPress/types.ts
@@ -0,0 +1,8 @@
+type UseSearchBackPressParams = {
+ onClearSelection: () => void;
+ onNavigationCallBack: () => void;
+};
+
+type UseSearchBackPress = (params: UseSearchBackPressParams) => void;
+
+export default UseSearchBackPress;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 6551eb5e8e9c..9e7140f7950c 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -156,6 +156,7 @@ import type {
StatementTitleParams,
StepCounterParams,
StripePaidParams,
+ SubmitsToParams,
SubscriptionCommitmentParams,
SubscriptionSettingsRenewsOnParams,
SubscriptionSettingsSaveUpToParams,
@@ -1109,6 +1110,7 @@ const translations = {
}),
dates: 'Dates',
rates: 'Rates',
+ submitsTo: ({name}: SubmitsToParams) => `Submits to ${name}`,
},
notificationPreferencesPage: {
header: 'Notification preferences',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index f2db2c5b49b8..35873c3d1959 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -155,6 +155,7 @@ import type {
StatementTitleParams,
StepCounterParams,
StripePaidParams,
+ SubmitsToParams,
SubscriptionCommitmentParams,
SubscriptionSettingsRenewsOnParams,
SubscriptionSettingsSaveUpToParams,
@@ -1107,6 +1108,7 @@ const translations = {
}),
dates: 'Fechas',
rates: 'Tasas',
+ submitsTo: ({name}: SubmitsToParams) => `Se envía a ${name}`,
},
notificationPreferencesPage: {
header: 'Preferencias de avisos',
diff --git a/src/languages/params.ts b/src/languages/params.ts
index f3d48acc63b1..5012d351abfe 100644
--- a/src/languages/params.ts
+++ b/src/languages/params.ts
@@ -603,6 +603,10 @@ type FlightLayoverParams = {
layover: string;
};
+type SubmitsToParams = {
+ name: string;
+};
+
type SettlementDateParams = {
settlementDate: string;
};
@@ -820,5 +824,6 @@ export type {
ChatWithAccountManagerParams,
EditDestinationSubtitleParams,
FlightLayoverParams,
+ SubmitsToParams,
SettlementDateParams,
};
diff --git a/src/libs/API/parameters/CreatePerDiemRequestParams.ts b/src/libs/API/parameters/CreatePerDiemRequestParams.ts
index 61740fbd0a37..b2fe0b541cc5 100644
--- a/src/libs/API/parameters/CreatePerDiemRequestParams.ts
+++ b/src/libs/API/parameters/CreatePerDiemRequestParams.ts
@@ -19,6 +19,7 @@ type CreatePerDiemRequestParams = {
reportPreviewReportActionID: string;
transactionThreadReportID: string;
createdReportActionIDForThread: string | undefined;
+ billable?: boolean;
};
export default CreatePerDiemRequestParams;
diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts
index 6952324a33d3..d36c49d753fb 100644
--- a/src/libs/DateUtils.ts
+++ b/src/libs/DateUtils.ts
@@ -819,9 +819,9 @@ function getFormattedReservationRangeDate(date1: Date, date2: Date): string {
*/
function getFormattedTransportDate(date: Date): string {
if (isThisYear(date)) {
- return `${translateLocal('travel.departs')} ${format(date, 'EEEE, MMM d')} ${translateLocal('common.conjunctionAt')} ${format(date, 'HH:MM')}`;
+ return `${translateLocal('travel.departs')} ${format(date, 'EEEE, MMM d')} ${translateLocal('common.conjunctionAt')} ${format(date, 'hh:mm a')}`;
}
- return `${translateLocal('travel.departs')} ${format(date, 'EEEE, MMM d, yyyy')} ${translateLocal('common.conjunctionAt')} ${format(date, 'HH:MM')}`;
+ return `${translateLocal('travel.departs')} ${format(date, 'EEEE, MMM d, yyyy')} ${translateLocal('common.conjunctionAt')} ${format(date, 'hh:mm a')}`;
}
/**
diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts
index fd57144c98ca..11d6b0bf9121 100644
--- a/src/libs/DistanceRequestUtils.ts
+++ b/src/libs/DistanceRequestUtils.ts
@@ -202,6 +202,11 @@ function getDistanceForDisplay(
return `${distanceInUnits} ${unitString}`;
}
+function getDistanceForDisplayLabel(distanceInMeters: number, unit: Unit): string {
+ const distanceInUnits = getRoundedDistanceInUnits(distanceInMeters, unit);
+ return `${distanceInUnits} ${unit}`;
+}
+
/**
* @param hasRoute Whether the route exists for the distance expense
* @param distanceInMeters Distance traveled
@@ -407,6 +412,7 @@ export default {
getUpdatedDistanceUnit,
getRate,
getRateByCustomUnitRateID,
+ getDistanceForDisplayLabel,
};
export type {MileageRate};
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 7ba5ffd0c99b..129c44767b18 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -40,7 +40,7 @@ import Parser from './Parser';
import Performance from './Performance';
import {getDisplayNameOrDefault} from './PersonalDetailsUtils';
import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from './PhoneNumber';
-import {canSendInvoiceFromWorkspace} from './PolicyUtils';
+import {canSendInvoiceFromWorkspace, getSubmitToAccountID} from './PolicyUtils';
import {
getCombinedReportActions,
getExportIntegrationLastMessageText,
@@ -195,15 +195,21 @@ type GetValidReportsConfig = {
includeInvoiceRooms?: boolean;
includeDomainEmail?: boolean;
loginsToExclude?: Record;
+ shouldSeparateWorkspaceChat?: boolean;
+ shouldSeparateSelfDMChat?: boolean;
} & GetValidOptionsSharedConfig;
+type GetValidReportsReturnTypeCombined = {
+ selfDMOption: OptionData | undefined;
+ workspaceOptions: OptionData[];
+ recentReports: OptionData[];
+};
+
type GetOptionsConfig = {
excludeLogins?: Record;
includeRecentReports?: boolean;
includeSelectedOptions?: boolean;
recentAttendees?: Attendee[];
- shouldSeparateWorkspaceChat?: boolean;
- shouldSeparateSelfDMChat?: boolean;
excludeHiddenReports?: boolean;
} & GetValidReportsConfig;
@@ -805,7 +811,7 @@ function createOption(
result.tooltipText = getReportParticipantsTitle(visibleParticipantAccountIDs);
hasMultipleParticipants = personalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat || reportUtilsIsGroupChat(report);
- subtitle = getChatRoomSubtitle(report);
+ subtitle = getChatRoomSubtitle(report, {isCreateExpenseFlow: true});
const lastActorDetails = report.lastActorAccountID ? personalDetailMap[report.lastActorAccountID] : null;
const lastActorDisplayName = getLastActorDisplayName(lastActorDetails, hasMultipleParticipants);
@@ -876,6 +882,17 @@ function getReportOption(participant: Participant): OptionData {
} else {
option.text = getPolicyName({report});
option.alternateText = translateLocal('workspace.common.workspace');
+
+ if (report?.policyID) {
+ const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`];
+ const submitToAccountID = getSubmitToAccountID(policy, report);
+ const submitsToAccountDetails = allPersonalDetails?.[submitToAccountID];
+ const subtitle = submitsToAccountDetails?.displayName ?? submitsToAccountDetails?.login;
+
+ if (subtitle) {
+ option.alternateText = translateLocal('iou.submitsTo', {name: subtitle ?? ''});
+ }
+ }
}
option.isDisabled = isDraftReport(participant.reportID);
option.selected = participant.selected;
@@ -1246,9 +1263,8 @@ function getUserToInviteOption({
return userToInvite;
}
-function getValidReports(
- reports: OptionList['reports'],
- {
+function getValidReports(reports: OptionList['reports'], config: GetValidReportsConfig): GetValidReportsReturnTypeCombined {
+ const {
betas = [],
includeMultipleParticipantReports = false,
showChatPreviewLine = false,
@@ -1266,11 +1282,14 @@ function getValidReports(
includeDomainEmail = false,
shouldBoldTitleByDefault = true,
loginsToExclude = {},
- }: GetValidReportsConfig,
-) {
+ shouldSeparateSelfDMChat,
+ shouldSeparateWorkspaceChat,
+ } = config;
const topmostReportId = Navigation.getTopmostReportId();
const validReportOptions: OptionData[] = [];
+ const workspaceChats: OptionData[] = [];
+ let selfDMChat: OptionData | undefined;
const preferRecentExpenseReports = action === CONST.IOU.ACTION.CREATE;
for (let i = 0; i < reports.length; i++) {
@@ -1402,10 +1421,20 @@ function getValidReports(
lastIOUCreationDate,
};
- validReportOptions.push(newReportOption);
+ if (shouldSeparateWorkspaceChat && newReportOption.isOwnPolicyExpenseChat && !newReportOption.private_isArchived) {
+ workspaceChats.push(newReportOption);
+ } else if (shouldSeparateSelfDMChat && newReportOption.isSelfDM) {
+ selfDMChat = newReportOption;
+ } else {
+ validReportOptions.push(newReportOption);
+ }
}
- return validReportOptions;
+ return {
+ recentReports: validReportOptions,
+ workspaceOptions: workspaceChats,
+ selfDMOption: selfDMChat,
+ };
}
/**
@@ -1445,15 +1474,22 @@ function getValidOptions(
// Get valid recent reports:
let recentReportOptions: OptionData[] = [];
+ let workspaceChats: OptionData[] = [];
+ let selfDMChat: OptionData | undefined;
if (includeRecentReports) {
- recentReportOptions = getValidReports(options.reports, {
+ const {recentReports, workspaceOptions, selfDMOption} = getValidReports(options.reports, {
...getValidReportsConfig,
includeP2P,
includeDomainEmail,
selectedOptions,
loginsToExclude,
shouldBoldTitleByDefault,
+ shouldSeparateSelfDMChat,
+ shouldSeparateWorkspaceChat,
});
+ recentReportOptions = recentReports;
+ workspaceChats = workspaceOptions;
+ selfDMChat = selfDMOption;
} else if (recentAttendees && recentAttendees?.length > 0) {
recentAttendees.filter((attendee) => {
const login = attendee.login ?? attendee.displayName;
@@ -1506,22 +1542,6 @@ function getValidOptions(
}
}
- let workspaceChats: OptionData[] = [];
-
- if (shouldSeparateWorkspaceChat) {
- workspaceChats = recentReportOptions.filter((option) => option.isOwnPolicyExpenseChat && !option.private_isArchived);
- }
-
- let selfDMChat: OptionData | undefined;
-
- if (shouldSeparateWorkspaceChat) {
- recentReportOptions = recentReportOptions.filter((option) => !option.isPolicyExpenseChat);
- }
- if (shouldSeparateSelfDMChat) {
- selfDMChat = recentReportOptions.find((option) => option.isSelfDM);
- recentReportOptions = recentReportOptions.filter((option) => !option.isSelfDM);
- }
-
if (excludeHiddenReports) {
recentReportOptions = recentReportOptions.filter((option) => option.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
}
@@ -2064,7 +2084,15 @@ function filterAndOrderOptions(options: Options, searchInputValue: string, confi
const orderedOptions = combineOrderingOfReportsAndPersonalDetails(filterResult, searchInputValue, config);
// on staging server, in specific cases (see issue) BE returns duplicated personalDetails entries
- orderedOptions.personalDetails = orderedOptions.personalDetails.filter((detail, index, array) => array.findIndex((i) => i.login === detail.login) === index);
+ const uniqueLogins = new Set();
+ orderedOptions.personalDetails = orderedOptions.personalDetails.filter((detail) => {
+ const login = detail.login ?? '';
+ if (uniqueLogins.has(login)) {
+ return false;
+ }
+ uniqueLogins.add(login);
+ return true;
+ });
return {
...filterResult,
diff --git a/src/libs/Pusher/pusher.ts b/src/libs/Pusher/pusher.ts
index e4b0cee4622b..cb18bc7f63db 100644
--- a/src/libs/Pusher/pusher.ts
+++ b/src/libs/Pusher/pusher.ts
@@ -33,7 +33,7 @@ type UserIsLeavingRoomEvent = Record & {
type PingPongEvent = Record & {
pingID: string;
- timestamp: number;
+ pingTimestamp: number;
};
type PusherEventMap = {
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index dbf2bd7d3a35..7fefadc693d5 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -692,6 +692,10 @@ type Thread = {
parentReportActionID: string;
} & Report;
+type GetChatRoomSubtitleConfig = {
+ isCreateExpenseFlow?: boolean;
+};
+
type GetPolicyNameParams = {
report: OnyxInputOrEntry;
returnEmptyIfNotFound?: boolean;
@@ -1090,6 +1094,14 @@ function isInvoiceReport(report: OnyxInputOrEntry | SearchReport): boole
return report?.type === CONST.REPORT.TYPE.INVOICE;
}
+function isNewDotInvoice(invoiceRoomID: string | undefined): boolean {
+ if (!invoiceRoomID) {
+ return false;
+ }
+
+ return isInvoiceRoom(getReport(invoiceRoomID, allReports));
+}
+
/**
* Checks if a report is an Expense report.
*/
@@ -4487,7 +4499,9 @@ function getReportNameInternal({
}
if (isInvoiceReport(report)) {
- formattedName = report?.reportName ?? getMoneyRequestReportName({report, policy, invoiceReceiverPolicy});
+ const moneyRequestReportName = getMoneyRequestReportName({report, policy, invoiceReceiverPolicy});
+ const oldDotInvoiceName = report?.reportName ?? moneyRequestReportName;
+ formattedName = isNewDotInvoice(report?.chatReportID) ? moneyRequestReportName : oldDotInvoiceName;
}
if (isInvoiceRoom(report)) {
@@ -4541,7 +4555,7 @@ function getPayeeName(report: OnyxEntry): string | undefined {
/**
* Get either the policyName or domainName the chat is tied to
*/
-function getChatRoomSubtitle(report: OnyxEntry): string | undefined {
+function getChatRoomSubtitle(report: OnyxEntry, config: GetChatRoomSubtitleConfig = {isCreateExpenseFlow: false}): string | undefined {
if (isChatThread(report)) {
return '';
}
@@ -4562,7 +4576,15 @@ function getChatRoomSubtitle(report: OnyxEntry): string | undefined {
return report?.reportName?.substring(1) ?? '';
}
if ((isPolicyExpenseChat(report) && !!report?.isOwnPolicyExpenseChat) || isExpenseReport(report)) {
- return translateLocal('workspace.common.workspace');
+ const submitToAccountID = getSubmitToAccountID(getPolicy(report?.policyID), report);
+ const submitsToAccountDetails = allPersonalDetails?.[submitToAccountID];
+ const subtitle = submitsToAccountDetails?.displayName ?? submitsToAccountDetails?.login;
+
+ if (!subtitle || !config.isCreateExpenseFlow) {
+ return translateLocal('workspace.common.workspace');
+ }
+
+ return translateLocal('iou.submitsTo', {name: subtitle ?? ''});
}
if (isArchivedReport(getReportNameValuePairs(report?.reportID))) {
return report?.oldPolicyName ?? '';
@@ -4626,7 +4648,7 @@ function navigateToDetailsPage(report: OnyxEntry, backTo?: string) {
/**
* Go back to the details page of a given report
*/
-function goBackToDetailsPage(report: OnyxEntry, backTo?: string) {
+function goBackToDetailsPage(report: OnyxEntry, backTo?: string, shouldGoBackToDetailsPage = false) {
const isOneOnOneChatReport = isOneOnOneChat(report);
const participantAccountID = getParticipantsAccountIDsForDisplay(report);
@@ -4636,7 +4658,11 @@ function goBackToDetailsPage(report: OnyxEntry, backTo?: string) {
}
if (report?.reportID) {
- Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(report.reportID, backTo));
+ if (shouldGoBackToDetailsPage) {
+ Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID, backTo));
+ } else {
+ Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(report.reportID, backTo));
+ }
} else {
Log.warn('Missing reportID during navigation back to the details page');
}
@@ -9376,6 +9402,7 @@ export {
isInvoiceRoom,
isInvoiceRoomWithID,
isInvoiceReport,
+ isNewDotInvoice,
isOpenInvoiceReport,
getDefaultNotificationPreferenceForReport,
canWriteInReport,
diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts
index 2aece1753758..365134546c2b 100644
--- a/src/libs/SearchAutocompleteUtils.ts
+++ b/src/libs/SearchAutocompleteUtils.ts
@@ -1,6 +1,8 @@
import type {MarkdownRange} from '@expensify/react-native-live-markdown';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
-import type {SearchAutocompleteResult} from '@components/Search/types';
+import type {SharedValue} from 'react-native-reanimated/lib/typescript/commonTypes';
+import type {SubstitutionMap} from '@components/Search/SearchRouter/getQueryWithSubstitutions';
+import type {SearchAutocompleteQueryRange, SearchAutocompleteResult} from '@components/Search/types';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, PolicyCategories, PolicyTagLists, RecentlyUsedCategories, RecentlyUsedTags} from '@src/types/onyx';
@@ -133,26 +135,81 @@ function getAutocompleteQueryWithComma(prevQuery: string, newQuery: string) {
return newQuery;
}
+function filterOutRangesWithCorrectValue(
+ range: SearchAutocompleteQueryRange,
+ userDisplayName: string,
+ substitutionMap: SubstitutionMap,
+ userLogins: SharedValue,
+ currencyList: SharedValue,
+ categoryList: SharedValue,
+ tagList: SharedValue,
+) {
+ 'worklet';
+
+ const typeList = Object.values(CONST.SEARCH.DATA_TYPES) as string[];
+ const expenseTypeList = Object.values(CONST.SEARCH.TRANSACTION_TYPE) as string[];
+ const statusList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP}) as string[];
+
+ switch (range.key) {
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN:
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE:
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID:
+ return substitutionMap[`${range.key}:${range.value}`] !== undefined;
+
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO:
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM:
+ return substitutionMap[`${range.key}:${range.value}`] !== undefined || userLogins.get().includes(range.value);
+
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY:
+ return currencyList.get().includes(range.value);
+ case CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE:
+ return typeList.includes(range.value);
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE:
+ return expenseTypeList.includes(range.value);
+ case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS:
+ return statusList.includes(range.value);
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY:
+ return categoryList.get().includes(range.value);
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG:
+ return tagList.get().includes(range.value);
+ default:
+ return true;
+ }
+}
+
/**
* Parses input string using the autocomplete parser and returns array of
* markdown ranges that can be used by RNMarkdownTextInput.
* It is simpler version of search parser that can be run on UI.
*/
-function parseForLiveMarkdown(input: string, userLogins: string[], userDisplayName: string) {
+function parseForLiveMarkdown(
+ input: string,
+ userDisplayName: string,
+ map: SubstitutionMap,
+ userLogins: SharedValue,
+ currencyList: SharedValue,
+ categoryList: SharedValue,
+ tagList: SharedValue,
+) {
'worklet';
const parsedAutocomplete = parse(input) as SearchAutocompleteResult;
const ranges = parsedAutocomplete.ranges;
+ return ranges
+ .filter((range) => filterOutRangesWithCorrectValue(range, userDisplayName, map, userLogins, currencyList, categoryList, tagList))
+ .map((range) => {
+ let type = 'mention-user';
- return ranges.map((range) => {
- let type = 'mention-user';
+ if (range.key === CONST.SEARCH.SYNTAX_RANGE_NAME) {
+ type = CONST.SEARCH.SYNTAX_RANGE_NAME;
+ }
- if ((range.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO || CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM) && (userLogins.includes(range.value) || range.value === userDisplayName)) {
- type = 'mention-here';
- }
+ if ((range.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO || CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM) && (userLogins.get().includes(range.value) || range.value === userDisplayName)) {
+ type = 'mention-here';
+ }
- return {...range, type};
- }) as MarkdownRange[];
+ return {...range, type};
+ }) as MarkdownRange[];
}
export {
diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js
index f4ebc66f828e..5ef8d782c839 100644
--- a/src/libs/SearchParser/autocompleteParser.js
+++ b/src/libs/SearchParser/autocompleteParser.js
@@ -283,24 +283,38 @@ function peg$parse(input, options) {
if (!value) {
autocomplete = {
key,
- value: '',
+ value: "",
start: location().end.offset,
length: 0,
};
- return;
+ return {
+ key: "syntax",
+ value: key,
+ start: location().start.offset,
+ length: location().end.offset - location().start.offset,
+ };
}
autocomplete = {
key,
...value[value.length - 1],
};
-
- return value
+ const result = value
.filter((filter) => filter.length > 0)
.map((filter) => ({
key,
...filter,
}));
+
+ return [
+ {
+ key: "syntax",
+ value: key,
+ start: location().start.offset,
+ length: result[0].start - location().start.offset,
+ },
+ ...result,
+ ];
};
var peg$f3 = function() { autocomplete = null; };
var peg$f4 = function(parts, empty) {
diff --git a/src/libs/SearchParser/autocompleteParser.peggy b/src/libs/SearchParser/autocompleteParser.peggy
index 54af9dcf9e81..96bbe4dcc0ff 100644
--- a/src/libs/SearchParser/autocompleteParser.peggy
+++ b/src/libs/SearchParser/autocompleteParser.peggy
@@ -32,27 +32,41 @@ defaultFilter
if (!value) {
autocomplete = {
key,
- value: '',
+ value: "",
start: location().end.offset,
length: 0,
};
- return;
+ return {
+ key: "syntax",
+ value: key,
+ start: location().start.offset,
+ length: location().end.offset - location().start.offset,
+ };
}
autocomplete = {
key,
...value[value.length - 1],
};
-
- return value
+ const result = value
.filter((filter) => filter.length > 0)
.map((filter) => ({
key,
...filter,
}));
+
+ return [
+ {
+ key: "syntax",
+ value: key,
+ start: location().start.offset,
+ length: result[0].start - location().start.offset,
+ },
+ ...result,
+ ];
}
-freeTextFilter = _ (identifier/ ",") _ { autocomplete = null; }
+freeTextFilter = _ (identifier / ",") _ { autocomplete = null; }
autocompleteKey "key"
= @(
diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js
index 463fe85566a6..66846116ca35 100644
--- a/src/libs/SearchParser/searchParser.js
+++ b/src/libs/SearchParser/searchParser.js
@@ -222,7 +222,7 @@ function peg$parse(input, options) {
var peg$c35 = "<=";
var peg$c36 = "<";
- var peg$r0 = /^[^ \t\r\n]/;
+ var peg$r0 = /^[^ \t\r\n\xA0]/;
var peg$r1 = /^[:=]/;
var peg$r2 = /^[^ ,"\u201D\u201C\t\n\r\xA0]/;
var peg$r3 = /^["\u201C-\u201D]/;
@@ -230,7 +230,7 @@ function peg$parse(input, options) {
var peg$r5 = /^[^ ,\t\n\r\xA0]/;
var peg$r6 = /^[ \t\r\n\xA0]/;
- var peg$e0 = peg$classExpectation([" ", "\t", "\r", "\n"], true, false);
+ var peg$e0 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], true, false);
var peg$e1 = peg$otherExpectation("key");
var peg$e2 = peg$otherExpectation("default key");
var peg$e3 = peg$literalExpectation(",", false);
@@ -302,7 +302,9 @@ function peg$parse(input, options) {
const keywordFilter = buildFilter(
"eq",
"keyword",
- keywords.map((filter) => filter.right.replace(/^(['"])(.*)\1$/, '$2')).flat()
+ keywords
+ .map((filter) => filter.right.replace(/^(['"])(.*)\1$/, "$2"))
+ .flat()
);
if (keywordFilter.right.length > 0) {
nonKeywords.push(keywordFilter);
@@ -315,10 +317,14 @@ function peg$parse(input, options) {
updateDefaultValues(key, value);
};
var peg$f3 = function(value) {
+ //handle no-breaking space
+ let word;
if (Array.isArray(value)) {
- return buildFilter("eq", "keyword", value.join(""));
+ word = value.join("");
+ } else {
+ word = value;
}
- return buildFilter("eq", "keyword", value);
+ return buildFilter("eq", "keyword", word);
};
var peg$f4 = function(field, op, values) {
return buildFilter(op, field, values);
diff --git a/src/libs/SearchParser/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy
index b379f22b59e1..cfbf076e381c 100644
--- a/src/libs/SearchParser/searchParser.peggy
+++ b/src/libs/SearchParser/searchParser.peggy
@@ -70,7 +70,9 @@ filterList
const keywordFilter = buildFilter(
"eq",
"keyword",
- keywords.map((filter) => filter.right.replace(/^(['"])(.*)\1$/, '$2')).flat()
+ keywords
+ .map((filter) => filter.right.replace(/^(['"])(.*)\1$/, "$2"))
+ .flat()
);
if (keywordFilter.right.length > 0) {
nonKeywords.push(keywordFilter);
@@ -88,11 +90,15 @@ defaultFilter
}
freeTextFilter
- = _ value:(quotedString / [^ \t\r\n]+) _ {
+ = _ value:(quotedString / [^ \t\r\n\xA0]+) _ {
+ //handle no-breaking space
+ let word;
if (Array.isArray(value)) {
- return buildFilter("eq", "keyword", value.join(""));
+ word = value.join("");
+ } else {
+ word = value;
}
- return buildFilter("eq", "keyword", value);
+ return buildFilter("eq", "keyword", word);
}
standardFilter
@@ -124,8 +130,7 @@ key "key"
/ posted
)
-defaultKey "default key"
- = @(type / status / sortBy / sortOrder / policyID)
+defaultKey "default key" = @(type / status / sortBy / sortOrder / policyID)
identifier
= (","+)? parts:(quotedString / alphanumeric)|1.., ","+| empty:(","+)? {
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 6199f0dc7b8e..941d2ceb18d3 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -203,6 +203,7 @@ type MoneyRequestInformation = {
transactionThreadReportID: string;
createdReportActionIDForThread: string | undefined;
onyxData: OnyxData;
+ billable?: boolean;
};
type TrackExpenseInformation = {
@@ -338,6 +339,7 @@ type PerDiemExpenseTransactionParams = {
tag?: string;
created: string;
customUnit: TransactionCustomUnit;
+ billable?: boolean;
};
type RequestMoneyPolicyParams = {
@@ -448,6 +450,35 @@ type CreateDistanceRequestInformation = {
policyParams?: RequestMoneyPolicyParams;
};
+type TrackExpenseTransactionParams = {
+ amount: number;
+ currency: string;
+ created: string | undefined;
+ merchant?: string;
+ comment?: string;
+ receipt?: Receipt;
+ category?: string;
+ tag?: string;
+ taxCode?: string;
+ taxAmount?: number;
+ billable?: boolean;
+ validWaypoints?: WaypointCollection;
+ gpsPoints?: GPSPoint;
+ actionableWhisperReportActionID?: string;
+ linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction;
+ linkedTrackedExpenseReportID?: string;
+ customUnitRateID?: string;
+};
+
+type CreateTrackExpenseParams = {
+ report: OnyxTypes.Report;
+ isDraftPolicy: boolean;
+ action?: IOUAction;
+ participantParams: RequestMoneyParticipantParams;
+ policyParams?: RequestMoneyPolicyParams;
+ transactionParams: TrackExpenseTransactionParams;
+};
+
let allPersonalDetails: OnyxTypes.PersonalDetailsList = {};
Onyx.connect({
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
@@ -2793,7 +2824,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI
const {parentChatReport, transactionParams, participantParams, policyParams = {}, moneyRequestReportID = ''} = perDiemExpenseInformation;
const {payeeAccountID = userAccountID, payeeEmail = currentUserEmail, participant} = participantParams;
const {policy, policyCategories, policyTagList} = policyParams;
- const {comment = '', currency, created, category, tag, customUnit} = transactionParams;
+ const {comment = '', currency, created, category, tag, customUnit, billable} = transactionParams;
const amount = computePerDiemExpenseAmount(customUnit);
const merchant = computePerDiemExpenseMerchant(customUnit, policy);
@@ -2868,6 +2899,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI
merchant,
tag,
customUnit,
+ billable,
pendingFields: {subRates: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD},
},
});
@@ -2987,6 +3019,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI
successData,
failureData,
},
+ billable,
};
}
@@ -4546,6 +4579,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf
transactionThreadReportID,
createdReportActionIDForThread,
onyxData,
+ billable,
} = getPerDiemExpenseInformation({
parentChatReport: currentChatReport,
participantParams,
@@ -4576,6 +4610,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf
tag,
transactionThreadReportID,
createdReportActionIDForThread,
+ billable,
};
API.write(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST, parameters, onyxData);
@@ -4651,34 +4686,30 @@ function sendInvoice(
/**
* Track an expense
*/
-function trackExpense(
- report: OnyxTypes.Report,
- amount: number,
- currency: string,
- created: string,
- merchant: string,
- payeeEmail: string | undefined,
- payeeAccountID: number,
- participant: Participant,
- comment: string,
- isDraftPolicy: boolean,
- receipt?: Receipt,
- category?: string,
- tag?: string,
- taxCode = '',
- taxAmount = 0,
- billable?: boolean,
- policy?: OnyxEntry,
- policyTagList?: OnyxEntry,
- policyCategories?: OnyxEntry,
- gpsPoints?: GPSPoint,
- validWaypoints?: WaypointCollection,
- action?: IOUAction,
- actionableWhisperReportActionID?: string,
- linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction,
- linkedTrackedExpenseReportID?: string,
- customUnitRateID?: string,
-) {
+function trackExpense(params: CreateTrackExpenseParams) {
+ const {report, action, isDraftPolicy, participantParams, policyParams: policyData = {}, transactionParams: transactionData} = params;
+ const {participant, payeeAccountID, payeeEmail} = participantParams;
+ const {policy, policyCategories, policyTagList} = policyData;
+ const {
+ amount,
+ currency,
+ created = '',
+ merchant = '',
+ comment = '',
+ receipt,
+ category,
+ tag,
+ taxCode = '',
+ taxAmount = 0,
+ billable,
+ gpsPoints,
+ validWaypoints,
+ actionableWhisperReportActionID,
+ linkedTrackedExpenseReportAction,
+ linkedTrackedExpenseReportID,
+ customUnitRateID,
+ } = transactionData;
+
const isMoneyRequestReport = isMoneyRequestReportReportUtils(report);
const currentChatReport = isMoneyRequestReport ? getReportOrDraftReport(report.chatReportID) : report;
const moneyRequestReportID = isMoneyRequestReport ? report.reportID : '';
diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts
index 9d3536e57d65..663a01cb0221 100644
--- a/src/libs/actions/Policy/Policy.ts
+++ b/src/libs/actions/Policy/Policy.ts
@@ -1312,6 +1312,7 @@ function updateGeneralSettings(policyID: string | undefined, name: string, curre
outputCurrency: currency,
...(customUnitID && {
customUnits: {
+ ...policy.customUnits,
[customUnitID]: {
...distanceUnit,
rates: optimisticRates,
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index b9fc2cd45d64..bc051f1472bb 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -68,6 +68,7 @@ import * as Localize from '@libs/Localize';
import Log from '@libs/Log';
import {registerPaginationConfig} from '@libs/Middleware/Pagination';
import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
+import shouldOpenOnAdminRoom from '@libs/Navigation/shouldOpenOnAdminRoom';
import {isOnboardingFlowName} from '@libs/NavigationUtils';
import enhanceParameters from '@libs/Network/enhanceParameters';
import type {NetworkStatus} from '@libs/NetworkConnection';
@@ -2945,6 +2946,20 @@ function openReportFromDeepLink(url: string) {
return;
}
+ // Check if the report exists in the collection
+ const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
+ // If the report does not exist, navigate to the last accessed report or Concierge chat
+ if (!report) {
+ const lastAccessedReportID = findLastAccessedReport(false, shouldOpenOnAdminRoom(), undefined, reportID)?.reportID;
+ if (lastAccessedReportID) {
+ const lastAccessedReportRoute = ROUTES.REPORT_WITH_ID.getRoute(lastAccessedReportID);
+ Navigation.navigate(lastAccessedReportRoute, CONST.NAVIGATION.ACTION_TYPE.PUSH);
+ return;
+ }
+ navigateToConciergeChat(false, () => true, CONST.NAVIGATION.ACTION_TYPE.PUSH);
+ return;
+ }
+
Navigation.navigate(route as Route, CONST.NAVIGATION.ACTION_TYPE.PUSH);
};
diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts
index 2864b989ed3d..7132e870ed70 100644
--- a/src/libs/actions/Task.ts
+++ b/src/libs/actions/Task.ts
@@ -352,7 +352,7 @@ function getOutstandingChildTask(taskReport: OnyxEntry) {
reportAction.childType === CONST.REPORT.TYPE.TASK &&
reportAction?.childStateNum === CONST.REPORT.STATE_NUM.OPEN &&
reportAction?.childStatusNum === CONST.REPORT.STATUS_NUM.OPEN &&
- ReportActionsUtils.getReportActionMessage(reportAction)?.isDeletedParentAction
+ !ReportActionsUtils.getReportActionMessage(reportAction)?.isDeletedParentAction
) {
return true;
}
diff --git a/src/libs/actions/Travel.ts b/src/libs/actions/Travel.ts
index 2aeb04b60f1b..ff613bb2d48d 100644
--- a/src/libs/actions/Travel.ts
+++ b/src/libs/actions/Travel.ts
@@ -1,54 +1,10 @@
-import {Str} from 'expensify-common';
-import type {Dispatch, SetStateAction} from 'react';
-import {Linking, NativeModules} from 'react-native';
-import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx';
+import type {OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
-import type {LocaleContextProps} from '@components/LocaleContextProvider';
import * as API from '@libs/API';
import type {AcceptSpotnanaTermsParams} from '@libs/API/parameters';
import {WRITE_COMMANDS} from '@libs/API/types';
import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils';
-import Log from '@libs/Log';
-import Navigation from '@libs/Navigation/Navigation';
-import {getAdminsPrivateEmailDomains, getPolicy} from '@libs/PolicyUtils';
-import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
-import type {TravelSettings} from '@src/types/onyx';
-import {isEmptyObject} from '@src/types/utils/EmptyObject';
-import {buildTravelDotURL, openTravelDotLink} from './Link';
-
-let travelSettings: OnyxEntry;
-Onyx.connect({
- key: ONYXKEYS.NVP_TRAVEL_SETTINGS,
- callback: (val) => {
- travelSettings = val;
- },
-});
-
-let activePolicyID: OnyxEntry;
-Onyx.connect({
- key: ONYXKEYS.NVP_ACTIVE_POLICY_ID,
- callback: (val) => {
- activePolicyID = val;
- },
-});
-
-let primaryLogin: string;
-Onyx.connect({
- key: ONYXKEYS.ACCOUNT,
- callback: (val) => {
- primaryLogin = val?.primaryLogin ?? '';
- },
-});
-
-let isSingleNewDotEntry: boolean | undefined;
-Onyx.connect({
- key: ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY,
- callback: (val) => {
- isSingleNewDotEntry = val;
- },
-});
/**
* Accept Spotnana terms and conditions to receive a proper token used for authenticating further actions
@@ -98,76 +54,9 @@ function acceptSpotnanaTerms(domain?: string) {
API.write(WRITE_COMMANDS.ACCEPT_SPOTNANA_TERMS, params, {optimisticData, successData, failureData});
}
-function handleProvisioningPermissionDeniedError(domain: string) {
- Navigation.navigate(ROUTES.TRAVEL_DOMAIN_PERMISSION_INFO.getRoute(domain));
- Onyx.merge(ONYXKEYS.TRAVEL_PROVISIONING, null);
-}
-
-function openTravelDotAfterProvisioning(spotnanaToken: string) {
- Navigation.closeRHPFlow();
+function cleanupTravelProvisioningSession() {
Onyx.merge(ONYXKEYS.TRAVEL_PROVISIONING, null);
- Linking.openURL(buildTravelDotURL(spotnanaToken));
-}
-
-function provisionDomain(domain: string) {
- Onyx.merge(ONYXKEYS.TRAVEL_PROVISIONING, null);
- Navigation.navigate(ROUTES.TRAVEL_TCS.getRoute(domain));
-}
-
-function bookATrip(
- translate: LocaleContextProps['translate'],
- setCtaErrorMessage: Dispatch>,
- setRootStatusBarEnabled: (isEnabled: boolean) => void,
- ctaErrorMessage = '',
-): void {
- if (!activePolicyID) {
- return;
- }
- if (Str.isSMSLogin(primaryLogin)) {
- setCtaErrorMessage(translate('travel.phoneError'));
- return;
- }
- const policy = getPolicy(activePolicyID);
- if (isEmptyObject(policy?.address)) {
- Navigation.navigate(ROUTES.WORKSPACE_PROFILE_ADDRESS.getRoute(activePolicyID, Navigation.getActiveRoute()));
- return;
- }
-
- const isPolicyProvisioned = policy?.travelSettings?.spotnanaCompanyID ?? policy?.travelSettings?.associatedTravelDomainAccountID;
- if (policy?.travelSettings?.hasAcceptedTerms ?? (travelSettings?.hasAcceptedTerms && isPolicyProvisioned)) {
- openTravelDotLink(activePolicyID)
- ?.then(() => {
- if (!NativeModules.HybridAppModule || !isSingleNewDotEntry) {
- return;
- }
-
- Log.info('[HybridApp] Returning to OldDot after opening TravelDot');
- NativeModules.HybridAppModule.closeReactNativeApp(false, false);
- setRootStatusBarEnabled(false);
- })
- ?.catch(() => {
- setCtaErrorMessage(translate('travel.errorMessage'));
- });
- if (ctaErrorMessage) {
- setCtaErrorMessage('');
- }
- } else if (isPolicyProvisioned) {
- Onyx.merge(ONYXKEYS.TRAVEL_PROVISIONING, null);
- Navigation.navigate(ROUTES.TRAVEL_TCS.getRoute(CONST.TRAVEL.DEFAULT_DOMAIN));
- } else {
- const adminDomains = getAdminsPrivateEmailDomains(policy);
- let routeToNavigateTo;
- if (adminDomains.length === 0) {
- routeToNavigateTo = ROUTES.TRAVEL_PUBLIC_DOMAIN_ERROR;
- } else if (adminDomains.length === 1) {
- Onyx.merge(ONYXKEYS.TRAVEL_PROVISIONING, null);
- routeToNavigateTo = ROUTES.TRAVEL_TCS.getRoute(adminDomains.at(0) ?? CONST.TRAVEL.DEFAULT_DOMAIN);
- } else {
- routeToNavigateTo = ROUTES.TRAVEL_DOMAIN_SELECTOR;
- }
- Navigation.navigate(routeToNavigateTo);
- }
}
// eslint-disable-next-line import/prefer-default-export
-export {acceptSpotnanaTerms, handleProvisioningPermissionDeniedError, openTravelDotAfterProvisioning, provisionDomain, bookATrip};
+export {acceptSpotnanaTerms, cleanupTravelProvisioningSession};
diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts
index c8c9ae12773d..ffcbc813477b 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -892,11 +892,8 @@ function playSoundForMessageType(pushJSON: OnyxServerUpdate[]) {
});
}
-// Holds a map of all the PINGs that have been sent to the server and when they were sent
-// Once a PONG is received, the event data will be removed from this map.
-type PingPongTimestampMap = Record;
-let pingIDsAndTimestamps: PingPongTimestampMap = {};
-
+let lastPingSentTimestamp = Date.now();
+let lastPongReceivedTimestamp = Date.now();
function subscribeToPusherPong() {
// If there is no user accountID yet (because the app isn't fully setup yet), the channel can't be subscribed to so return early
if (currentUserAccountID === -1) {
@@ -905,21 +902,13 @@ function subscribeToPusherPong() {
PusherUtils.subscribeToPrivateUserChannelEvent(Pusher.TYPE.PONG, currentUserAccountID.toString(), (pushJSON) => {
Log.info(`[Pusher PINGPONG] Received a PONG event from the server`, false, pushJSON);
- const pongEvent = pushJSON as Pusher.PingPongEvent;
- // First, check to see if the PONG event is in the pingIDsAndTimestamps map
- // It's OK if it doesn't exist. The client was maybe refreshed while still waiting for a PONG event, in which case it might
- // receive the PONG event but has already lost it's memory of the PING.
- const pingEventTimestamp = pingIDsAndTimestamps[pongEvent.pingID];
- if (!pingEventTimestamp) {
- return;
- }
+ lastPongReceivedTimestamp = Date.now();
// Calculate the latency between the client and the server
- const latency = Date.now() - Number(pingEventTimestamp);
+ const pongEvent = pushJSON as Pusher.PingPongEvent;
+ const latency = Date.now() - Number(pongEvent.pingTimestamp);
Log.info(`[Pusher PINGPONG] The event took ${latency} ms`);
- // Remove the event from the map
- delete pingIDsAndTimestamps[pongEvent.pingID];
Timing.end(CONST.TIMING.PUSHER_PING_PONG);
});
}
@@ -928,15 +917,14 @@ function subscribeToPusherPong() {
const PING_INTERVAL_LENGTH_IN_SECONDS = 30;
// Specify how long between each check for missing PONG events
-const CHECK_MISSING_PONG_INTERVAL_LENGTH_IN_SECONDS = 60;
+const CHECK_LATE_PONG_INTERVAL_LENGTH_IN_SECONDS = 60;
// Specify how long before a PING event is considered to be missing a PONG event in order to put the application in offline mode
const NO_EVENT_RECEIVED_TO_BE_OFFLINE_THRESHOLD_IN_SECONDS = 2 * PING_INTERVAL_LENGTH_IN_SECONDS;
-let lastTimestamp = Date.now();
function pingPusher() {
if (isOffline()) {
- Log.info('[Pusher PINGPONG] Skipping ping because the client is offline');
+ Log.info('[Pusher PINGPONG] Skipping PING because the client is offline');
return;
}
// Send a PING event to the server with a specific ID and timestamp
@@ -947,34 +935,29 @@ function pingPusher() {
// In local development, there can end up being multiple intervals running because when JS code is replaced with hot module replacement, the old interval is not cleared
// and keeps running. This little bit of logic will attempt to keep multiple pings from happening.
- if (pingTimestamp - lastTimestamp < PING_INTERVAL_LENGTH_IN_SECONDS * 1000) {
+ if (pingTimestamp - lastPingSentTimestamp < PING_INTERVAL_LENGTH_IN_SECONDS * 1000) {
return;
}
- lastTimestamp = pingTimestamp;
+ lastPingSentTimestamp = pingTimestamp;
- pingIDsAndTimestamps[pingID] = pingTimestamp;
const parameters: PusherPingParams = {pingID, pingTimestamp};
API.write(WRITE_COMMANDS.PUSHER_PING, parameters);
Log.info(`[Pusher PINGPONG] Sending a PING to the server: ${pingID} timestamp: ${pingTimestamp}`);
Timing.start(CONST.TIMING.PUSHER_PING_PONG);
}
-function checkforMissingPongEvents() {
- Log.info(`[Pusher PINGPONG] Checking for missing PONG events`);
- // Get the oldest PING timestamp that is left in the event map
- const oldestPingTimestamp = Math.min(...Object.values(pingIDsAndTimestamps));
- const ageOfEventInMS = Date.now() - oldestPingTimestamp;
-
- // Get the eventID of that timestamp
- const eventID = Object.keys(pingIDsAndTimestamps).find((key) => pingIDsAndTimestamps[key] === oldestPingTimestamp);
+function checkforLatePongReplies() {
+ Log.info(`[Pusher PINGPONG] Checking for late PONG events`);
+ const timeSinceLastPongReceived = Date.now() - lastPongReceivedTimestamp;
- // If the oldest timestamp is older than 2 * PING_INTERVAL_LENGTH_IN_SECONDS, then set the network status to offline
- if (ageOfEventInMS > NO_EVENT_RECEIVED_TO_BE_OFFLINE_THRESHOLD_IN_SECONDS * 1000) {
- Log.info(`[Pusher PINGPONG] The server has not replied to the PING event ${eventID} in ${ageOfEventInMS} ms so going offline`);
+ // If the time since the last pong was received is more than 2 * PING_INTERVAL_LENGTH_IN_SECONDS, then record it in the logs
+ if (timeSinceLastPongReceived > NO_EVENT_RECEIVED_TO_BE_OFFLINE_THRESHOLD_IN_SECONDS * 1000) {
+ Log.info(`[Pusher PINGPONG] The server has not replied to the PING event in ${timeSinceLastPongReceived} ms so going offline`);
// When going offline, reset the pingpong state so that when the network reconnects, the client will start fresh
- lastTimestamp = Date.now();
- pingIDsAndTimestamps = {};
+ lastPingSentTimestamp = Date.now();
+ } else {
+ Log.info(`[Pusher PINGPONG] Last PONG event was ${timeSinceLastPongReceived} ms ago so not going offline`);
}
}
@@ -992,7 +975,7 @@ function initializePusherPingPong() {
}
pingPongStarted = true;
- Log.info(`[Pusher PINGPONG] Starting Pusher Ping Pong and pinging every ${PING_INTERVAL_LENGTH_IN_SECONDS} seconds`);
+ Log.info(`[Pusher PINGPONG] Starting Pusher PING PONG and pinging every ${PING_INTERVAL_LENGTH_IN_SECONDS} seconds`);
// Subscribe to the pong event from Pusher. Unfortunately, there is no way of knowing when the client is actually subscribed
// so there could be a little delay before the client is actually listening to this event.
@@ -1005,7 +988,7 @@ function initializePusherPingPong() {
// events to be sent and received
setTimeout(() => {
// Check for any missing pong events on a regular interval
- setInterval(checkforMissingPongEvents, CHECK_MISSING_PONG_INTERVAL_LENGTH_IN_SECONDS * 1000);
+ setInterval(checkforLatePongReplies, CHECK_LATE_PONG_INTERVAL_LENGTH_IN_SECONDS * 1000);
}, PING_INTERVAL_LENGTH_IN_SECONDS * 2);
}
diff --git a/src/libs/focusComposerWithDelay/index.ts b/src/libs/focusComposerWithDelay/index.ts
index eef9d21d113d..ab251bf8403a 100644
--- a/src/libs/focusComposerWithDelay/index.ts
+++ b/src/libs/focusComposerWithDelay/index.ts
@@ -32,7 +32,8 @@ function focusComposerWithDelay(textInput: InputType | null): FocusComposerWithD
return;
}
// When the closing modal has a focused text input focus() needs a delay to properly work.
- setTimeout(() => textInput.focus(), 0);
+ // Setting 150ms here is a temporary workaround for the Android HybridApp. It should be reverted once we identify the real root cause of this issue: https://github.com/Expensify/App/issues/56311.
+ setTimeout(() => textInput.focus(), 150);
if (forcedSelectionRange) {
setTextInputSelection(textInput, forcedSelectionRange);
}
diff --git a/src/libs/runOnLiveMarkdownRuntime/index.native.tsx b/src/libs/runOnLiveMarkdownRuntime/index.native.tsx
new file mode 100644
index 000000000000..fe7bd2441cf3
--- /dev/null
+++ b/src/libs/runOnLiveMarkdownRuntime/index.native.tsx
@@ -0,0 +1,8 @@
+import {getWorkletRuntime} from '@expensify/react-native-live-markdown';
+import {runOnRuntime} from 'react-native-reanimated';
+
+function runOnLiveMarkdownRuntime(worklet: (...args: Args) => ReturnType) {
+ return runOnRuntime(getWorkletRuntime(), worklet);
+}
+
+export default runOnLiveMarkdownRuntime;
diff --git a/src/libs/runOnLiveMarkdownRuntime/index.tsx b/src/libs/runOnLiveMarkdownRuntime/index.tsx
new file mode 100644
index 000000000000..928e7a34ea3f
--- /dev/null
+++ b/src/libs/runOnLiveMarkdownRuntime/index.tsx
@@ -0,0 +1,6 @@
+// Reanimated does not support runOnRuntime() on web
+function runOnLiveMarkdownRuntime(worklet: (...args: Args) => ReturnType) {
+ return worklet;
+}
+
+export default runOnLiveMarkdownRuntime;
diff --git a/src/libs/shouldFetchReport.ts b/src/libs/shouldFetchReport.ts
index ae0384bf728d..07d416e98ffc 100644
--- a/src/libs/shouldFetchReport.ts
+++ b/src/libs/shouldFetchReport.ts
@@ -1,9 +1,8 @@
import type {OnyxEntry} from 'react-native-onyx';
import type Report from '@src/types/onyx/Report';
-import type ReportMetadata from '@src/types/onyx/ReportMetadata';
-export default function shouldFetchReport(report: OnyxEntry, reportMetadata: OnyxEntry) {
+export default function shouldFetchReport(report: OnyxEntry, isOptimisticReport?: boolean) {
// If the report is optimistic, there's no need to fetch it. The original action should create it.
// If there is an error for creating the chat, there's no need to fetch it since it doesn't exist
- return !reportMetadata?.isOptimisticReport && !report?.errorFields?.createChat;
+ return !isOptimisticReport && !report?.errorFields?.createChat;
}
diff --git a/src/pages/PrivateNotes/PrivateNotesListPage.tsx b/src/pages/PrivateNotes/PrivateNotesListPage.tsx
index d2cdda140bb8..2ac33c89d174 100644
--- a/src/pages/PrivateNotes/PrivateNotesListPage.tsx
+++ b/src/pages/PrivateNotes/PrivateNotesListPage.tsx
@@ -13,6 +13,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types';
import type {PrivateNotesNavigatorParamList} from '@libs/Navigation/types';
+import {goBackToDetailsPage} from '@libs/ReportUtils';
import type {WithReportAndPrivateNotesOrNotFoundProps} from '@pages/home/report/withReportAndPrivateNotesOrNotFound';
import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound';
import CONST from '@src/CONST';
@@ -94,7 +95,7 @@ function PrivateNotesListPage({report, accountID: sessionAccountID}: PrivateNote
Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID, backTo))}
+ onBackButtonPress={() => goBackToDetailsPage(report, route.params.backTo, true)}
onCloseButtonPress={() => Navigation.dismissModal()}
/>
{
Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS_EXPORT.getRoute(report?.reportID, connectedIntegration, backTo));
diff --git a/src/pages/ReportParticipantsPage.tsx b/src/pages/ReportParticipantsPage.tsx
index 32bec2cd751f..bda35fc70b1f 100755
--- a/src/pages/ReportParticipantsPage.tsx
+++ b/src/pages/ReportParticipantsPage.tsx
@@ -11,7 +11,7 @@ import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
import type {DropdownOption, WorkspaceMemberBulkActionType} from '@components/ButtonWithDropdownMenu/types';
import ConfirmModal from '@components/ConfirmModal';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import * as Expensicons from '@components/Icon/Expensicons';
+import {FallbackAvatar, MakeAdmin, Plus, RemoveMembers, User} from '@components/Icon/Expensicons';
import ScreenWrapper from '@components/ScreenWrapper';
import TableListItem from '@components/SelectionList/TableListItem';
import type {ListItem, SelectionListHandle} from '@components/SelectionList/types';
@@ -21,17 +21,30 @@ import useLocalize from '@hooks/useLocalize';
import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
import useNetwork from '@hooks/useNetwork';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSearchBackPress from '@hooks/useSearchBackPress';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
-import * as Report from '@libs/actions/Report';
-import * as UserSearchPhraseActions from '@libs/actions/RoomMembersUserSearchPhrase';
+import {removeFromGroupChat, updateGroupChatMemberRoles} from '@libs/actions/Report';
+import {clearUserSearchPhrase} from '@libs/actions/RoomMembersUserSearchPhrase';
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {ParticipantsNavigatorParamList} from '@libs/Navigation/types';
-import * as OptionsListUtils from '@libs/OptionsListUtils';
-import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
-import * as ReportUtils from '@libs/ReportUtils';
+import {isSearchStringMatchUserDetails} from '@libs/OptionsListUtils';
+import {getDisplayNameOrDefault, getPersonalDetailsByIDs} from '@libs/PersonalDetailsUtils';
+import {
+ getParticipantsList,
+ getReportName,
+ isArchivedNonExpenseReport,
+ isChatRoom,
+ isChatThread,
+ isGroupChatAdmin,
+ isGroupChat as isGroupChatUtils,
+ isMoneyRequestReport,
+ isPolicyExpenseChat,
+ isSelfDM,
+ isTaskReport,
+} from '@libs/ReportUtils';
import StringUtils from '@libs/StringUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -63,8 +76,8 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
const [session] = useOnyx(ONYXKEYS.SESSION);
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
const currentUserAccountID = Number(session?.accountID);
- const isCurrentUserAdmin = ReportUtils.isGroupChatAdmin(report, currentUserAccountID);
- const isGroupChat = useMemo(() => ReportUtils.isGroupChat(report), [report]);
+ const isCurrentUserAdmin = isGroupChatAdmin(report, currentUserAccountID);
+ const isGroupChat = useMemo(() => isGroupChatUtils(report), [report]);
const isFocused = useIsFocused();
const {isOffline} = useNetwork();
const canSelectMultiple = isGroupChat && isCurrentUserAdmin && (isSmallScreenWidth ? selectionMode?.isEnabled : true);
@@ -77,7 +90,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
setSelectedMembers([]);
}, [isFocused]);
- const chatParticipants = ReportUtils.getParticipantsList(report, personalDetails);
+ const chatParticipants = getParticipantsList(report, personalDetails);
const pendingChatMembers = reportMetadata?.pendingChatMembers;
const reportParticipants = report?.participants;
@@ -102,11 +115,23 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
if (shouldShowTextInput) {
setSearchValue(userSearchPhrase ?? '');
} else {
- UserSearchPhraseActions.clearUserSearchPhrase();
+ clearUserSearchPhrase();
setSearchValue('');
}
}, [isFocused, setSearchValue, shouldShowTextInput, userSearchPhrase]);
+ useSearchBackPress({
+ onClearSelection: () => setSelectedMembers([]),
+ onNavigationCallBack: () => {
+ if (!report) {
+ return;
+ }
+
+ setSearchValue('');
+ Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID, backTo));
+ },
+ });
+
const getParticipants = () => {
let result: MemberOption[] = [];
@@ -115,7 +140,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
const details = personalDetails?.[accountID];
// If search value is provided, filter out members that don't match the search value
- if (!details || (searchValue.trim() && !OptionsListUtils.isSearchStringMatchUserDetails(details, searchValue))) {
+ if (!details || (searchValue.trim() && !isSearchStringMatchUserDetails(details, searchValue))) {
return;
}
@@ -135,13 +160,13 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
isSelected,
isDisabledCheckbox: accountID === currentUserAccountID,
isDisabled: pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || details?.isOptimisticPersonalDetail,
- text: formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(details)),
+ text: formatPhoneNumber(getDisplayNameOrDefault(details)),
alternateText: formatPhoneNumber(details?.login ?? ''),
rightElement: roleBadge,
pendingAction,
icons: [
{
- source: details?.avatar ?? Expensicons.FallbackAvatar,
+ source: details?.avatar ?? FallbackAvatar,
name: formatPhoneNumber(details?.login ?? ''),
type: CONST.ICON_TYPE_AVATAR,
id: accountID,
@@ -200,19 +225,19 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
const removeUsers = () => {
// Remove the admin from the list
const accountIDsToRemove = selectedMembers.filter((id) => id !== currentUserAccountID);
- Report.removeFromGroupChat(report.reportID, accountIDsToRemove);
+ removeFromGroupChat(report.reportID, accountIDsToRemove);
setSearchValue('');
setSelectedMembers([]);
setRemoveMembersConfirmModalVisible(false);
InteractionManager.runAfterInteractions(() => {
- UserSearchPhraseActions.clearUserSearchPhrase();
+ clearUserSearchPhrase();
});
};
const changeUserRole = useCallback(
(role: ValueOf) => {
const accountIDsToUpdate = selectedMembers.filter((id) => report.participants?.[id].role !== role);
- Report.updateGroupChatMemberRoles(report.reportID, accountIDsToUpdate, role);
+ updateGroupChatMemberRoles(report.reportID, accountIDsToUpdate, role);
setSelectedMembers([]);
},
[report, selectedMembers],
@@ -263,7 +288,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
{
text: translate('workspace.people.removeMembersTitle', {count: selectedMembers.length}),
value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.REMOVE,
- icon: Expensicons.RemoveMembers,
+ icon: RemoveMembers,
onSelected: () => setRemoveMembersConfirmModalVisible(true),
},
];
@@ -274,7 +299,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
options.push({
text: translate('workspace.people.makeMember'),
value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_MEMBER,
- icon: Expensicons.User,
+ icon: User,
onSelected: () => changeUserRole(CONST.REPORT.ROLE.MEMBER),
});
}
@@ -285,7 +310,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
options.push({
text: translate('workspace.people.makeAdmin'),
value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_ADMIN,
- icon: Expensicons.MakeAdmin,
+ icon: MakeAdmin,
onSelected: () => changeUserRole(CONST.REPORT.ROLE.ADMIN),
});
}
@@ -317,7 +342,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
success
onPress={inviteUser}
text={translate('workspace.invite.member')}
- icon={Expensicons.Plus}
+ icon={Plus}
innerStyles={[shouldUseNarrowLayout && styles.alignItemsCenter]}
style={[shouldUseNarrowLayout && styles.flexGrow1]}
/>
@@ -338,14 +363,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
[report, isCurrentUserAdmin, isGroupChat, backTo],
);
const headerTitle = useMemo(() => {
- if (
- ReportUtils.isChatRoom(report) ||
- ReportUtils.isPolicyExpenseChat(report) ||
- ReportUtils.isChatThread(report) ||
- ReportUtils.isTaskReport(report) ||
- ReportUtils.isMoneyRequestReport(report) ||
- isGroupChat
- ) {
+ if (isChatRoom(report) || isPolicyExpenseChat(report) || isChatThread(report) || isTaskReport(report) || isMoneyRequestReport(report) || isGroupChat) {
return translate('common.members');
}
return translate('common.details');
@@ -365,7 +383,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
style={[styles.defaultModalContainer]}
testID={ReportParticipantsPage.displayName}
>
-
+
{
@@ -381,7 +399,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
}
}}
guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS}
- subtitle={StringUtils.lineBreaksToSpaces(ReportUtils.getReportName(report))}
+ subtitle={StringUtils.lineBreaksToSpaces(getReportName(report))}
/>
{headerButtons}
setRemoveMembersConfirmModalVisible(false)}
prompt={translate('workspace.people.removeMembersPrompt', {
count: selectedMembers.length,
- memberName: formatPhoneNumber(PersonalDetailsUtils.getPersonalDetailsByIDs({accountIDs: selectedMembers, currentUserAccountID}).at(0)?.displayName ?? ''),
+ memberName: formatPhoneNumber(getPersonalDetailsByIDs({accountIDs: selectedMembers, currentUserAccountID}).at(0)?.displayName ?? ''),
})}
confirmText={translate('common.remove')}
cancelText={translate('common.cancel')}
diff --git a/src/pages/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx
index 447876334b5c..15544388aa09 100644
--- a/src/pages/RoomMembersPage.tsx
+++ b/src/pages/RoomMembersPage.tsx
@@ -8,7 +8,7 @@ import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
import type {DropdownOption, RoomMemberBulkActionType} from '@components/ButtonWithDropdownMenu/types';
import ConfirmModal from '@components/ConfirmModal';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import * as Expensicons from '@components/Icon/Expensicons';
+import {FallbackAvatar, Plus, RemoveMembers} from '@components/Icon/Expensicons';
import {usePersonalDetails} from '@components/OnyxProvider';
import ScreenWrapper from '@components/ScreenWrapper';
import TableListItem from '@components/SelectionList/TableListItem';
@@ -20,20 +20,21 @@ import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalD
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSearchBackPress from '@hooks/useSearchBackPress';
import useThemeStyles from '@hooks/useThemeStyles';
import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
-import * as UserSearchPhraseActions from '@libs/actions/RoomMembersUserSearchPhrase';
-import * as DeviceCapabilities from '@libs/DeviceCapabilities';
+import {clearUserSearchPhrase, updateUserSearchPhrase} from '@libs/actions/RoomMembersUserSearchPhrase';
+import {canUseTouchScreen} from '@libs/DeviceCapabilities';
import localeCompare from '@libs/LocaleCompare';
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackRouteProp, PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {RoomMembersNavigatorParamList} from '@libs/Navigation/types';
-import * as OptionsListUtils from '@libs/OptionsListUtils';
-import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
-import * as PolicyUtils from '@libs/PolicyUtils';
-import * as ReportUtils from '@libs/ReportUtils';
+import {isPersonalDetailsReady, isSearchStringMatchUserDetails} from '@libs/OptionsListUtils';
+import {getDisplayNameOrDefault, getPersonalDetailsByIDs} from '@libs/PersonalDetailsUtils';
+import {isPolicyEmployee as isPolicyEmployeeUtils, isUserPolicyAdmin} from '@libs/PolicyUtils';
+import {getParticipantsList, getReportName, isChatThread, isDefaultRoom, isPolicyExpenseChat as isPolicyExpenseChatUtils, isUserCreatedPolicyRoom} from '@libs/ReportUtils';
import StringUtils from '@libs/StringUtils';
-import * as Report from '@userActions/Report';
+import {clearAddRoomMemberError, openRoomMembersPage, removeFromRoom} from '@userActions/Report';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
@@ -58,7 +59,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
const [didLoadRoomMembers, setDidLoadRoomMembers] = useState(false);
const personalDetails = usePersonalDetails();
const policy = useMemo(() => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`], [policies, report?.policyID]);
- const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(report), [report]);
+ const isPolicyExpenseChat = useMemo(() => isPolicyExpenseChatUtils(report), [report]);
const backTo = route.params.backTo;
const isFocusedScreen = useIsFocused();
@@ -84,12 +85,12 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
if (!report) {
return;
}
- Report.openRoomMembersPage(report.reportID);
+ openRoomMembersPage(report.reportID);
setDidLoadRoomMembers(true);
}, [report]);
useEffect(() => {
- UserSearchPhraseActions.clearUserSearchPhrase();
+ clearUserSearchPhrase();
getRoomMembers();
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, []);
@@ -111,13 +112,13 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
*/
const removeUsers = () => {
if (report) {
- Report.removeFromRoom(report.reportID, selectedMembers);
+ removeFromRoom(report.reportID, selectedMembers);
}
setSearchValue('');
setSelectedMembers([]);
setRemoveMembersConfirmModalVisible(false);
InteractionManager.runAfterInteractions(() => {
- UserSearchPhraseActions.clearUserSearchPhrase();
+ clearUserSearchPhrase();
});
};
@@ -170,7 +171,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
}
};
- const participants = useMemo(() => ReportUtils.getParticipantsList(report, personalDetails, true), [report, personalDetails]);
+ const participants = useMemo(() => getParticipantsList(report, personalDetails, true), [report, personalDetails]);
/** Include the search bar when there are 8 or more active members in the selection list */
const shouldShowTextInput = useMemo(() => {
@@ -194,7 +195,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
}, [isFocusedScreen, shouldShowTextInput, userSearchPhrase]);
useEffect(() => {
- UserSearchPhraseActions.updateUserSearchPhrase(searchValue);
+ updateUserSearchPhrase(searchValue);
}, [searchValue]);
useEffect(() => {
@@ -204,11 +205,19 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
if (shouldShowTextInput) {
setSearchValue(userSearchPhrase ?? '');
} else {
- UserSearchPhraseActions.clearUserSearchPhrase();
+ clearUserSearchPhrase();
setSearchValue('');
}
}, [isFocusedScreen, setSearchValue, shouldShowTextInput, userSearchPhrase]);
+ useSearchBackPress({
+ onClearSelection: () => setSelectedMembers([]),
+ onNavigationCallBack: () => {
+ setSearchValue('');
+ Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID, backTo));
+ },
+ });
+
const data = useMemo((): ListItem[] => {
let result: ListItem[] = [];
@@ -216,11 +225,11 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
const details = personalDetails?.[accountID];
// If search value is provided, filter out members that don't match the search value
- if (!details || (searchValue.trim() && !OptionsListUtils.isSearchStringMatchUserDetails(details, searchValue))) {
+ if (!details || (searchValue.trim() && !isSearchStringMatchUserDetails(details, searchValue))) {
return;
}
const pendingChatMember = reportMetadata?.pendingChatMembers?.findLast((member) => member.accountID === accountID.toString());
- const isAdmin = PolicyUtils.isUserPolicyAdmin(policy, details.login);
+ const isAdmin = isUserPolicyAdmin(policy, details.login);
const isDisabled = pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || details.isOptimisticPersonalDetail;
const isDisabledCheckbox =
(isPolicyExpenseChat && isAdmin) ||
@@ -234,11 +243,11 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
isSelected: selectedMembers.includes(accountID),
isDisabled,
isDisabledCheckbox,
- text: formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(details)),
+ text: formatPhoneNumber(getDisplayNameOrDefault(details)),
alternateText: details?.login ? formatPhoneNumber(details.login) : '',
icons: [
{
- source: details.avatar ?? Expensicons.FallbackAvatar,
+ source: details.avatar ?? FallbackAvatar,
name: details.login ?? '',
type: CONST.ICON_TYPE_AVATAR,
id: accountID,
@@ -267,7 +276,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
const dismissError = useCallback(
(item: ListItem) => {
- Report.clearAddRoomMemberError(report.reportID, String(item.accountID));
+ clearAddRoomMemberError(report.reportID, String(item.accountID));
},
[report.reportID],
);
@@ -276,7 +285,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
if (!report?.policyID || policies === null) {
return false;
}
- return PolicyUtils.isPolicyEmployee(report.policyID, policies);
+ return isPolicyEmployeeUtils(report.policyID, policies);
}, [report?.policyID, policies]);
const headerMessage = searchValue.trim() && !data.length ? `${translate('roomMembersPage.memberNotFound')} ${translate('roomMembersPage.useInviteButton')}` : '';
@@ -286,7 +295,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
{
text: translate('workspace.people.removeMembersTitle', {count: selectedMembers.length}),
value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.REMOVE,
- icon: Expensicons.RemoveMembers,
+ icon: RemoveMembers,
onSelected: () => setRemoveMembersConfirmModalVisible(true),
},
];
@@ -313,7 +322,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
success
onPress={inviteUser}
text={translate('workspace.invite.member')}
- icon={Expensicons.Plus}
+ icon={Plus}
innerStyles={[shouldUseNarrowLayout && styles.alignItemsCenter]}
style={[shouldUseNarrowLayout && styles.flexGrow1]}
/>
@@ -358,9 +367,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
testID={RoomMembersPage.displayName}
>
{
Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID, backTo));
@@ -368,7 +375,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
>
{
if (selectionMode?.isEnabled) {
setSelectedMembers([]);
@@ -389,7 +396,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
onCancel={() => setRemoveMembersConfirmModalVisible(false)}
prompt={translate('roomMembersPage.removeMembersPrompt', {
count: selectedMembers.length,
- memberName: formatPhoneNumber(PersonalDetailsUtils.getPersonalDetailsByIDs({accountIDs: selectedMembers, currentUserAccountID}).at(0)?.displayName ?? ''),
+ memberName: formatPhoneNumber(getPersonalDetailsByIDs({accountIDs: selectedMembers, currentUserAccountID}).at(0)?.displayName ?? ''),
})}
confirmText={translate('common.remove')}
cancelText={translate('common.cancel')}
@@ -409,9 +416,9 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
onCheckboxPress={(item) => toggleUser(item)}
onSelectRow={openRoomMemberDetails}
onSelectAll={() => toggleAllUsers(data)}
- showLoadingPlaceholder={!OptionsListUtils.isPersonalDetailsReady(personalDetails) || !didLoadRoomMembers}
+ showLoadingPlaceholder={!isPersonalDetailsReady(personalDetails) || !didLoadRoomMembers}
showScrollIndicator
- shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
+ shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()}
listHeaderWrapperStyle={[styles.ph9, styles.mt3]}
customListHeader={customListHeader}
ListItem={TableListItem}
diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx
index 7ab8268494f7..e56cb1abe330 100644
--- a/src/pages/Search/EmptySearchView.tsx
+++ b/src/pages/Search/EmptySearchView.tsx
@@ -1,10 +1,9 @@
-import React, {useContext, useMemo, useState} from 'react';
+import React, {useMemo, useState} from 'react';
import {Linking, View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import type {OnyxCollection} from 'react-native-onyx';
+import BookTravelButton from '@components/BookTravelButton';
import ConfirmModal from '@components/ConfirmModal';
-import CustomStatusBarAndBackgroundContext from '@components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContext';
-import DotIndicatorMessage from '@components/DotIndicatorMessage';
import EmptyStateComponent from '@components/EmptyStateComponent';
import type {FeatureListItem} from '@components/FeatureList';
import {Alert, PiggyBank} from '@components/Icon/Illustrations';
@@ -22,7 +21,6 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {startMoneyRequest} from '@libs/actions/IOU';
import {openExternalLink, openOldDotLink} from '@libs/actions/Link';
import {canActionTask, canModifyTask, completeTask} from '@libs/actions/Task';
-import {bookATrip} from '@libs/actions/Travel';
import {setSelfTourViewed} from '@libs/actions/Welcome';
import interceptAnonymousUser from '@libs/interceptAnonymousUser';
import {hasSeenTourSelector} from '@libs/onboardingSelectors';
@@ -61,11 +59,8 @@ function EmptySearchView({type, hasResults}: EmptySearchViewProps) {
const shouldRedirectToExpensifyClassic = useMemo(() => {
return areAllGroupPoliciesExpenseChatDisabled((allPolicies as OnyxCollection) ?? {});
}, [allPolicies]);
- const {setRootStatusBarEnabled} = useContext(CustomStatusBarAndBackgroundContext);
- const [ctaErrorMessage, setCtaErrorMessage] = useState('');
-
- const subtitleComponent = useMemo(() => {
+ const tripViewChildren = useMemo(() => {
return (
<>
@@ -79,7 +74,7 @@ function EmptySearchView({type, hasResults}: EmptySearchViewProps) {
{translate('travel.toLearnMore')}
-
+
{tripsFeatures.map((tripsFeature) => (
))}
- {!!ctaErrorMessage && (
-
- )}
+
>
);
- }, [styles, translate, ctaErrorMessage]);
+ }, [styles, translate]);
const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
const onboardingPurpose = introSelected?.choice;
@@ -131,14 +120,7 @@ function EmptySearchView({type, hasResults}: EmptySearchViewProps) {
headerContentStyles: [StyleUtils.getWidthAndHeightStyle(375, 240), StyleUtils.getBackgroundColorStyle(theme.travelBG)],
title: translate('travel.title'),
titleStyles: {...styles.textAlignLeft},
- subtitle: subtitleComponent,
- buttons: [
- {
- buttonText: translate('search.searchResults.emptyTripResults.buttonText'),
- buttonAction: () => bookATrip(translate, setCtaErrorMessage, setRootStatusBarEnabled, ctaErrorMessage),
- success: true,
- },
- ],
+ children: tripViewChildren,
lottieWebViewStyles: {backgroundColor: theme.travelBG, ...styles.emptyStateFolderWebStyles},
};
case CONST.SEARCH.DATA_TYPES.EXPENSE:
@@ -238,10 +220,8 @@ function EmptySearchView({type, hasResults}: EmptySearchViewProps) {
translate,
styles.textAlignLeft,
styles.emptyStateFolderWebStyles,
- subtitleComponent,
+ tripViewChildren,
hasSeenTour,
- setRootStatusBarEnabled,
- ctaErrorMessage,
navatticURL,
shouldRedirectToExpensifyClassic,
hasResults,
@@ -264,7 +244,9 @@ function EmptySearchView({type, hasResults}: EmptySearchViewProps) {
buttons={content.buttons}
headerContentStyles={[styles.h100, styles.w100, ...content.headerContentStyles]}
lottieWebViewStyles={content.lottieWebViewStyles}
- />
+ >
+ {content.children}
+
{
+ cleanupTravelProvisioningSession();
+ Navigation.navigate(ROUTES.TRAVEL_TCS.getRoute(selectedDomain ?? CONST.TRAVEL.DEFAULT_DOMAIN));
+ };
+
return (
provisionDomain(selectedDomain ?? CONST.TRAVEL.DEFAULT_DOMAIN)}
+ onPress={provisionTravelForDomain}
text={translate('common.continue')}
/>
}
diff --git a/src/pages/Travel/ManageTrips.tsx b/src/pages/Travel/ManageTrips.tsx
index 9a9b59b002c1..85a41cdb9eee 100644
--- a/src/pages/Travel/ManageTrips.tsx
+++ b/src/pages/Travel/ManageTrips.tsx
@@ -1,22 +1,17 @@
-import React, {useContext, useState} from 'react';
+import React from 'react';
import {Linking, View} from 'react-native';
-import {useOnyx} from 'react-native-onyx';
-import CustomStatusBarAndBackgroundContext from '@components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContext';
+import BookTravelButton from '@components/BookTravelButton';
+import Button from '@components/Button';
import type {FeatureListItem} from '@components/FeatureList';
import FeatureList from '@components/FeatureList';
-import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import * as Illustrations from '@components/Icon/Illustrations';
import LottieAnimations from '@components/LottieAnimations';
import ScrollView from '@components/ScrollView';
import useLocalize from '@hooks/useLocalize';
-import usePolicy from '@hooks/usePolicy';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
-import {bookATrip} from '@libs/actions/Travel';
import colors from '@styles/theme/colors';
import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import {isEmptyObject} from '@src/types/utils/EmptyObject';
const tripsFeatures: FeatureListItem[] = [
{
@@ -33,15 +28,6 @@ function ManageTrips() {
const styles = useThemeStyles();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const {translate} = useLocalize();
- const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
- const policy = usePolicy(activePolicyID);
- const {setRootStatusBarEnabled} = useContext(CustomStatusBarAndBackgroundContext);
-
- const [ctaErrorMessage, setCtaErrorMessage] = useState('');
-
- if (isEmptyObject(policy)) {
- return ;
- }
const navigateToBookTravelDemo = () => {
Linking.openURL(CONST.BOOK_TRAVEL_DEMO_URL);
@@ -54,20 +40,23 @@ function ManageTrips() {
menuItems={tripsFeatures}
title={translate('travel.title')}
subtitle={translate('travel.subtitle')}
- ctaText={translate('travel.bookTravel')}
- ctaAccessibilityLabel={translate('travel.bookTravel')}
- onCtaPress={() => {
- bookATrip(translate, setCtaErrorMessage, setRootStatusBarEnabled, ctaErrorMessage);
- }}
- ctaErrorMessage={ctaErrorMessage}
illustration={LottieAnimations.TripsEmptyState}
illustrationStyle={[styles.mv4]}
- secondaryButtonText={translate('travel.bookDemo')}
- secondaryButtonAccessibilityLabel={translate('travel.bookDemo')}
- onSecondaryButtonPress={navigateToBookTravelDemo}
illustrationBackgroundColor={colors.blue600}
titleStyles={styles.textHeadlineH1}
contentPaddingOnLargeScreens={styles.p5}
+ footer={
+ <>
+
+
+ >
+ }
/>
diff --git a/src/pages/Travel/TravelTerms.tsx b/src/pages/Travel/TravelTerms.tsx
index 6185dbd7793e..baf7b464c883 100644
--- a/src/pages/Travel/TravelTerms.tsx
+++ b/src/pages/Travel/TravelTerms.tsx
@@ -1,6 +1,6 @@
import type {StackScreenProps} from '@react-navigation/stack';
import React, {useCallback, useEffect, useState} from 'react';
-import {NativeModules, View} from 'react-native';
+import {Linking, NativeModules, View} from 'react-native';
import {ScrollView} from 'react-native-gesture-handler';
import {useOnyx} from 'react-native-onyx';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
@@ -13,12 +13,14 @@ import TextLink from '@components/TextLink';
import useLocalize from '@hooks/useLocalize';
import usePermissions from '@hooks/usePermissions';
import useThemeStyles from '@hooks/useThemeStyles';
-import {acceptSpotnanaTerms, handleProvisioningPermissionDeniedError, openTravelDotAfterProvisioning} from '@libs/actions/Travel';
+import {buildTravelDotURL} from '@libs/actions/Link';
+import {acceptSpotnanaTerms, cleanupTravelProvisioningSession} from '@libs/actions/Travel';
import {getLatestErrorMessage} from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
import type {TravelNavigatorParamList} from '@libs/Navigation/types';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
type TravelTermsPageProps = StackScreenProps;
@@ -35,10 +37,15 @@ function TravelTerms({route}: TravelTermsPageProps) {
useEffect(() => {
if (travelProvisioning?.error === CONST.TRAVEL.PROVISIONING.ERROR_PERMISSION_DENIED && domain) {
- handleProvisioningPermissionDeniedError(domain);
+ Navigation.navigate(ROUTES.TRAVEL_DOMAIN_PERMISSION_INFO.getRoute(domain));
+ cleanupTravelProvisioningSession();
}
if (travelProvisioning?.spotnanaToken) {
- openTravelDotAfterProvisioning(travelProvisioning.spotnanaToken);
+ Navigation.closeRHPFlow();
+ cleanupTravelProvisioningSession();
+
+ // TravelDot is a standalone white-labeled implementation of Spotnana so it has to be opened in a new tab
+ Linking.openURL(buildTravelDotURL(travelProvisioning.spotnanaToken));
}
if (travelProvisioning?.errors && !travelProvisioning?.error) {
setErrorMessage(getLatestErrorMessage(travelProvisioning));
diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx
index f3f3c7a2b195..68046fedc7d5 100644
--- a/src/pages/home/ReportScreen.tsx
+++ b/src/pages/home/ReportScreen.tsx
@@ -386,7 +386,14 @@ function ReportScreen({route, navigation}: ReportScreenProps) {
() => !!linkedAction && !shouldReportActionBeVisible(linkedAction, linkedAction.reportActionID, canUserPerformWriteAction(report)),
[linkedAction, report],
);
+
const prevIsLinkedActionDeleted = usePrevious(linkedAction ? isLinkedActionDeleted : undefined);
+
+ // eslint-disable-next-line react-compiler/react-compiler
+ const lastReportActionIDFromRoute = usePrevious(!firstRenderRef.current ? reportActionIDFromRoute : undefined);
+
+ const [isNavigatingToDeletedAction, setIsNavigatingToDeletedAction] = useState(false);
+
const isLinkedActionInaccessibleWhisper = useMemo(
() => !!linkedAction && isWhisperAction(linkedAction) && !(linkedAction?.whisperedToAccountIDs ?? []).includes(currentUserAccountID),
[currentUserAccountID, linkedAction],
@@ -416,11 +423,9 @@ function ReportScreen({route, navigation}: ReportScreenProps) {
(!!deleteTransactionNavigateBackUrl && getReportIDFromLink(deleteTransactionNavigateBackUrl) === report?.reportID) ||
(!reportMetadata.isOptimisticReport && isLoading);
- const isLinkedActionBecomesDeleted = prevIsLinkedActionDeleted !== undefined && !prevIsLinkedActionDeleted && isLinkedActionDeleted;
-
// eslint-disable-next-line rulesdir/no-negated-variables
const shouldShowNotFoundLinkedAction =
- (!isLinkedActionInaccessibleWhisper && isLinkedActionDeleted && !isLinkedActionBecomesDeleted) ||
+ (!isLinkedActionInaccessibleWhisper && isLinkedActionDeleted && isNavigatingToDeletedAction) ||
(shouldShowSkeleton &&
!reportMetadata.isLoadingInitialReportActions &&
!!reportActionIDFromRoute &&
@@ -518,7 +523,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) {
return;
}
- if (!shouldFetchReport(report, reportMetadata)) {
+ if (!shouldFetchReport(report, reportMetadata.isOptimisticReport)) {
return;
}
// When creating an optimistic report that already exists, we need to skip openReport
@@ -529,7 +534,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) {
}
fetchReport();
- }, [reportIDFromRoute, isLoadingApp, report, reportMetadata, fetchReport]);
+ }, [reportIDFromRoute, isLoadingApp, report, fetchReport, reportMetadata.isOptimisticReport]);
const dismissBanner = useCallback(() => {
setIsBannerVisible(false);
@@ -735,13 +740,23 @@ function ReportScreen({route, navigation}: ReportScreenProps) {
}, [fetchReport]);
useEffect(() => {
- // If the linked action is previously available but now deleted,
- // remove the reportActionID from the params to not link to the deleted action.
- if (!isLinkedActionBecomesDeleted) {
+ // Only handle deletion cases when there's a deleted action
+ if (!isLinkedActionDeleted) {
+ setIsNavigatingToDeletedAction(false);
return;
}
- Navigation.setParams({reportActionID: ''});
- }, [isLinkedActionBecomesDeleted]);
+
+ // we want to do this destinguish between normal navigation and delete behavior
+ if (lastReportActionIDFromRoute !== reportActionIDFromRoute) {
+ setIsNavigatingToDeletedAction(true);
+ return;
+ }
+
+ // Clear params when Action gets deleted while heighlighted
+ if (!isNavigatingToDeletedAction && prevIsLinkedActionDeleted === false) {
+ Navigation.setParams({reportActionID: ''});
+ }
+ }, [isLinkedActionDeleted, prevIsLinkedActionDeleted, lastReportActionIDFromRoute, reportActionIDFromRoute, isNavigatingToDeletedAction]);
// If user redirects to an inaccessible whisper via a deeplink, on a report they have access to,
// then we set reportActionID as empty string, so we display them the report and not the "Not found page".
@@ -775,7 +790,6 @@ function ReportScreen({route, navigation}: ReportScreenProps) {
!isDeletedAction(mostRecentReportAction);
const lastRoute = usePrevious(route);
- const lastReportActionIDFromRoute = usePrevious(reportActionIDFromRoute);
const onComposerFocus = useCallback(() => setIsComposerFocus(true), []);
const onComposerBlur = useCallback(() => setIsComposerFocus(false), []);
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
index 7d8b2f031e3b..46e2c814db24 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
@@ -119,10 +119,8 @@ function setClipboardMessage(content: string | undefined) {
if (!Clipboard.canSetHtml()) {
Clipboard.setString(Parser.htmlToMarkdown(content));
} else {
- const anchorRegex = CONST.REGEX_LINK_IN_ANCHOR;
- const isAnchorTag = anchorRegex.test(content);
- const plainText = isAnchorTag ? Parser.htmlToMarkdown(content) : Parser.htmlToText(content);
- Clipboard.setHtml(content, plainText);
+ const markdownText = Parser.htmlToMarkdown(content);
+ Clipboard.setHtml(content, markdownText);
}
}
diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx
index e093850ec276..c36391ecec70 100644
--- a/src/pages/home/report/PureReportActionItem.tsx
+++ b/src/pages/home/report/PureReportActionItem.tsx
@@ -371,6 +371,7 @@ function PureReportActionItem({
const downloadedPreviews = useRef([]);
const prevDraftMessage = usePrevious(draftMessage);
const isReportActionLinked = linkedReportActionID && action.reportActionID && linkedReportActionID === action.reportActionID;
+ const [isReportActionActive, setIsReportActionActive] = useState(!!isReportActionLinked);
const isActionableWhisper = isActionableMentionWhisper(action) || isActionableTrackExpense(action) || isActionableReportMentionWhisper(action);
const highlightedBackgroundColorIfNeeded = useMemo(
@@ -410,7 +411,6 @@ function PureReportActionItem({
}
clearAllRelatedReportActionErrors(reportID, action);
};
-
useEffect(
() => () => {
// ReportActionContextMenu, EmojiPicker and PopoverReactionList are global components,
@@ -1010,9 +1010,10 @@ function PureReportActionItem({
childReportID={`${action.childReportID}`}
numberOfReplies={numberOfThreadReplies}
mostRecentReply={`${action.childLastVisibleActionCreated}`}
- isHovered={hovered}
+ isHovered={hovered || isContextMenuActive}
icons={getIconsForParticipants(oldestFourAccountIDs, personalDetails)}
onSecondaryInteraction={showPopover}
+ isActive={isReportActionActive && !isContextMenuActive}
/>
)}
@@ -1044,7 +1045,8 @@ function PureReportActionItem({
shouldShowSubscriptAvatar={shouldShowSubscriptAvatar}
report={report}
iouReport={iouReport}
- isHovered={hovered}
+ isHovered={hovered || isContextMenuActive}
+ isActive={isReportActionActive && !isContextMenuActive}
hasBeenFlagged={
![CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING].some((item) => item === moderationDecision) && !isPendingRemove(action)
}
@@ -1130,6 +1132,12 @@ function PureReportActionItem({
shouldHandleScroll
isDisabled={draftMessage !== undefined}
shouldFreezeCapture={isPaymentMethodPopoverActive}
+ onHoverIn={() => {
+ setIsReportActionActive(false);
+ }}
+ onHoverOut={() => {
+ setIsReportActionActive(!!isReportActionLinked);
+ }}
>
{(hovered) => (
diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx
index 2eae046fe007..e417823262de 100644
--- a/src/pages/home/report/ReportActionItemSingle.tsx
+++ b/src/pages/home/report/ReportActionItemSingle.tsx
@@ -70,6 +70,9 @@ type ReportActionItemSingleProps = Partial & {
/** If the action is being hovered */
isHovered?: boolean;
+
+ /** If the action is being actived */
+ isActive?: boolean;
};
const showUserDetails = (accountID: number | undefined) => {
@@ -93,6 +96,7 @@ function ReportActionItemSingle({
report,
iouReport,
isHovered = false,
+ isActive = false,
}: ReportActionItemSingleProps) {
const theme = useTheme();
const styles = useThemeStyles();
@@ -223,6 +227,15 @@ function ReportActionItemSingle({
[action, isWorkspaceActor, actorAccountID],
);
+ const getBackgroundColor = () => {
+ if (isActive) {
+ return theme.messageHighlightBG;
+ }
+ if (isHovered) {
+ return theme.hoverComponentBG;
+ }
+ return theme.sidebar;
+ };
const getAvatar = () => {
if (shouldShowSubscriptAvatar) {
return (
@@ -230,6 +243,7 @@ function ReportActionItemSingle({
mainAvatar={icon}
secondaryAvatar={secondaryAvatar}
noMargin
+ backgroundColor={getBackgroundColor()}
/>
);
}
diff --git a/src/pages/home/report/ReportActionItemThread.tsx b/src/pages/home/report/ReportActionItemThread.tsx
index 13072a653749..223c2713ac70 100644
--- a/src/pages/home/report/ReportActionItemThread.tsx
+++ b/src/pages/home/report/ReportActionItemThread.tsx
@@ -6,9 +6,9 @@ import PressableWithSecondaryInteraction from '@components/PressableWithSecondar
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import {navigateToAndOpenChildReport} from '@libs/actions/Report';
import Timing from '@libs/actions/Timing';
import Performance from '@libs/Performance';
-import * as Report from '@userActions/Report';
import CONST from '@src/CONST';
import type {Icon} from '@src/types/onyx/OnyxCommon';
@@ -28,11 +28,14 @@ type ReportActionItemThreadProps = {
/** Whether the thread item / message is being hovered */
isHovered: boolean;
+ /** Whether the thread item / message is being actived */
+ isActive?: boolean;
+
/** The function that should be called when the thread is LongPressed or right-clicked */
onSecondaryInteraction: (event: GestureResponderEvent | MouseEvent) => void;
};
-function ReportActionItemThread({numberOfReplies, icons, mostRecentReply, childReportID, isHovered, onSecondaryInteraction}: ReportActionItemThreadProps) {
+function ReportActionItemThread({numberOfReplies, icons, mostRecentReply, childReportID, isHovered, onSecondaryInteraction, isActive}: ReportActionItemThreadProps) {
const styles = useThemeStyles();
const {translate, datetimeToCalendarTime} = useLocalize();
@@ -48,7 +51,7 @@ function ReportActionItemThread({numberOfReplies, icons, mostRecentReply, childR
onPress={() => {
Performance.markStart(CONST.TIMING.OPEN_REPORT_THREAD);
Timing.start(CONST.TIMING.OPEN_REPORT_THREAD);
- Report.navigateToAndOpenChildReport(childReportID);
+ navigateToAndOpenChildReport(childReportID);
}}
role={CONST.ROLE.BUTTON}
accessibilityLabel={`${numberOfReplies} ${replyText}`}
@@ -60,6 +63,7 @@ function ReportActionItemThread({numberOfReplies, icons, mostRecentReply, childR
icons={icons}
shouldStackHorizontally
isHovered={isHovered}
+ isActive={isActive}
isInReportAction
/>
diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx
index ceb704b30e6a..99327cbef2d8 100755
--- a/src/pages/home/report/ReportActionsView.tsx
+++ b/src/pages/home/report/ReportActionsView.tsx
@@ -118,7 +118,7 @@ function ReportActionsView({
const reportID = report.reportID;
const isReportFullyVisible = useMemo((): boolean => getIsReportFullyVisible(isFocused), [isFocused]);
const openReportIfNecessary = () => {
- if (!shouldFetchReport(report, reportMetadata)) {
+ if (!shouldFetchReport(report, reportMetadata?.isOptimisticReport)) {
return;
}
diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx
index 32a75380f88a..0289d5cb70f5 100644
--- a/src/pages/iou/request/step/IOURequestStepAmount.tsx
+++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx
@@ -31,8 +31,8 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
-import type * as OnyxTypes from '@src/types/onyx';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
+import type Transaction from '@src/types/onyx/Transaction';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import StepScreenWrapper from './StepScreenWrapper';
import withFullTransactionOrNotFound from './withFullTransactionOrNotFound';
@@ -47,7 +47,7 @@ type AmountParams = {
type IOURequestStepAmountProps = WithCurrentUserPersonalDetailsProps &
WithWritableReportOrNotFoundProps & {
/** The transaction object being modified in Onyx */
- transaction: OnyxEntry;
+ transaction: OnyxEntry;
/** Whether the user input should be kept or not */
shouldKeepUserInput?: boolean;
@@ -219,18 +219,21 @@ function IOURequestStepAmount({
}
if (iouType === CONST.IOU.TYPE.TRACK) {
playSound(SOUNDS.DONE);
- trackExpense(
+ trackExpense({
report,
- backendAmount,
- currency ?? 'USD',
- transaction?.created ?? '',
- CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT,
- currentUserPersonalDetails.login,
- currentUserPersonalDetails.accountID,
- participants.at(0) ?? {},
- '',
- false,
- );
+ isDraftPolicy: false,
+ participantParams: {
+ payeeEmail: currentUserPersonalDetails.login,
+ payeeAccountID: currentUserPersonalDetails.accountID,
+ participant: participants.at(0) ?? {},
+ },
+ transactionParams: {
+ amount: backendAmount,
+ currency: currency ?? 'USD',
+ created: transaction?.created,
+ merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT,
+ },
+ });
return;
}
}
diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
index ead1d2a1fc3a..5697a8626aa6 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
@@ -326,6 +326,7 @@ function IOURequestStepConfirmation({
category: transaction.category,
tag: transaction.tag,
customUnit: transaction.comment?.customUnit,
+ billable: transaction.billable,
},
});
},
@@ -341,34 +342,40 @@ function IOURequestStepConfirmation({
if (!participant) {
return;
}
- trackExpenseIOUActions(
+ trackExpenseIOUActions({
report,
- transaction.amount,
- transaction.currency,
- transaction.created,
- transaction.merchant,
- currentUserPersonalDetails.login,
- currentUserPersonalDetails.accountID,
- participant,
- trimmedComment,
isDraftPolicy,
- receiptObj,
- transaction.category,
- transaction.tag,
- transactionTaxCode,
- transactionTaxAmount,
- transaction.billable,
- policy,
- policyTags,
- policyCategories,
- gpsPoints,
- Object.keys(transaction?.comment?.waypoints ?? {}).length ? getValidWaypoints(transaction.comment?.waypoints, true) : undefined,
action,
- transaction.actionableWhisperReportActionID,
- transaction.linkedTrackedExpenseReportAction,
- transaction.linkedTrackedExpenseReportID,
- customUnitRateID,
- );
+ participantParams: {
+ payeeEmail: currentUserPersonalDetails.login,
+ payeeAccountID: currentUserPersonalDetails.accountID,
+ participant,
+ },
+ policyParams: {
+ policy,
+ policyCategories,
+ policyTagList: policyTags,
+ },
+ transactionParams: {
+ amount: transaction.amount,
+ currency: transaction.currency,
+ created: transaction.created,
+ merchant: transaction.merchant,
+ comment: trimmedComment,
+ receipt: receiptObj,
+ category: transaction.category,
+ tag: transaction.tag,
+ taxCode: transactionTaxCode,
+ taxAmount: transactionTaxAmount,
+ billable: transaction.billable,
+ gpsPoints,
+ validWaypoints: Object.keys(transaction?.comment?.waypoints ?? {}).length ? getValidWaypoints(transaction.comment?.waypoints, true) : undefined,
+ actionableWhisperReportActionID: transaction.actionableWhisperReportActionID,
+ linkedTrackedExpenseReportAction: transaction.linkedTrackedExpenseReportAction,
+ linkedTrackedExpenseReportID: transaction.linkedTrackedExpenseReportID,
+ customUnitRateID,
+ },
+ });
},
[
report,
diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx
index 8c63012fb186..8133ff20fe65 100644
--- a/src/pages/iou/request/step/IOURequestStepDistance.tsx
+++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx
@@ -320,34 +320,28 @@ function IOURequestStepDistance({
const participant = participants.at(0);
if (iouType === CONST.IOU.TYPE.TRACK && participant) {
playSound(SOUNDS.DONE);
- trackExpense(
+ trackExpense({
report,
- 0,
- transaction?.currency ?? 'USD',
- transaction?.created ?? '',
- translate('iou.fieldPending'),
- currentUserPersonalDetails.login,
- currentUserPersonalDetails.accountID,
- participant,
- '',
- false,
- {},
- '',
- '',
- '',
- 0,
- false,
- policy,
- undefined,
- undefined,
- undefined,
- getValidWaypoints(waypoints, true),
- undefined,
- undefined,
- undefined,
- undefined,
- customUnitRateID,
- );
+ isDraftPolicy: false,
+ participantParams: {
+ payeeEmail: currentUserPersonalDetails.login,
+ payeeAccountID: currentUserPersonalDetails.accountID,
+ participant,
+ },
+ policyParams: {
+ policy,
+ },
+ transactionParams: {
+ amount: 0,
+ currency: transaction?.currency ?? 'USD',
+ created: transaction?.created ?? '',
+ merchant: translate('iou.fieldPending'),
+ receipt: {},
+ billable: false,
+ validWaypoints: getValidWaypoints(waypoints, true),
+ customUnitRateID,
+ },
+ });
return;
}
@@ -535,6 +529,7 @@ function IOURequestStepDistance({
waypoints={waypoints}
navigateToWaypointEditPage={navigateToWaypointEditPage}
transaction={transaction}
+ policy={policy}
/>
}
/>
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
index ec32b709ad8f..d99807bc9759 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
@@ -251,19 +251,21 @@ function IOURequestStepScan({
const createTransaction = useCallback(
(receipt: Receipt, participant: Participant) => {
if (iouType === CONST.IOU.TYPE.TRACK && report) {
- trackExpense(
+ trackExpense({
report,
- 0,
- transaction?.currency ?? 'USD',
- transaction?.created ?? '',
- '',
- currentUserPersonalDetails.login,
- currentUserPersonalDetails.accountID,
- participant,
- '',
- false,
- receipt,
- );
+ isDraftPolicy: false,
+ participantParams: {
+ payeeEmail: currentUserPersonalDetails.login,
+ payeeAccountID: currentUserPersonalDetails.accountID,
+ participant,
+ },
+ transactionParams: {
+ amount: 0,
+ currency: transaction?.currency ?? 'USD',
+ created: transaction?.created,
+ receipt,
+ },
+ });
} else {
requestMoney({
report,
@@ -339,31 +341,29 @@ function IOURequestStepScan({
(successData) => {
playSound(SOUNDS.DONE);
if (iouType === CONST.IOU.TYPE.TRACK && report) {
- trackExpense(
+ trackExpense({
report,
- 0,
- transaction?.currency ?? 'USD',
- transaction?.created ?? '',
- '',
- currentUserPersonalDetails.login,
- currentUserPersonalDetails.accountID,
- participant,
- '',
- false,
- receipt,
- '',
- '',
- '',
- 0,
- false,
- policy,
- {},
- {},
- {
- lat: successData.coords.latitude,
- long: successData.coords.longitude,
+ isDraftPolicy: false,
+ participantParams: {
+ payeeEmail: currentUserPersonalDetails.login,
+ payeeAccountID: currentUserPersonalDetails.accountID,
+ participant,
},
- );
+ policyParams: {
+ policy,
+ },
+ transactionParams: {
+ amount: 0,
+ currency: transaction?.currency ?? 'USD',
+ created: transaction?.created,
+ receipt,
+ billable: false,
+ gpsPoints: {
+ lat: successData.coords.latitude,
+ long: successData.coords.longitude,
+ },
+ },
+ });
} else {
requestMoney({
report,
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx
index e31b13a4091e..c2f51c6001b6 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx
@@ -276,19 +276,21 @@ function IOURequestStepScan({
const createTransaction = useCallback(
(receipt: Receipt, participant: Participant) => {
if (iouType === CONST.IOU.TYPE.TRACK && report) {
- trackExpense(
+ trackExpense({
report,
- 0,
- transaction?.currency ?? 'USD',
- transaction?.created ?? '',
- '',
- currentUserPersonalDetails.login,
- currentUserPersonalDetails.accountID,
- participant,
- '',
- false,
- receipt,
- );
+ isDraftPolicy: false,
+ participantParams: {
+ payeeEmail: currentUserPersonalDetails.login,
+ payeeAccountID: currentUserPersonalDetails.accountID,
+ participant,
+ },
+ transactionParams: {
+ amount: 0,
+ currency: transaction?.currency ?? 'USD',
+ created: transaction?.created,
+ receipt,
+ },
+ });
} else {
requestMoney({
report,
@@ -365,31 +367,29 @@ function IOURequestStepScan({
(successData) => {
playSound(SOUNDS.DONE);
if (iouType === CONST.IOU.TYPE.TRACK && report) {
- trackExpense(
+ trackExpense({
report,
- 0,
- transaction?.currency ?? 'USD',
- transaction?.created ?? '',
- '',
- currentUserPersonalDetails.login,
- currentUserPersonalDetails.accountID,
- participant,
- '',
- false,
- receipt,
- '',
- '',
- '',
- 0,
- false,
- policy,
- {},
- {},
- {
- lat: successData.coords.latitude,
- long: successData.coords.longitude,
+ isDraftPolicy: false,
+ participantParams: {
+ payeeEmail: currentUserPersonalDetails.login,
+ payeeAccountID: currentUserPersonalDetails.accountID,
+ participant,
},
- );
+ policyParams: {
+ policy,
+ },
+ transactionParams: {
+ amount: 0,
+ currency: transaction?.currency ?? 'USD',
+ created: transaction?.created,
+ receipt,
+ billable: false,
+ gpsPoints: {
+ lat: successData.coords.latitude,
+ long: successData.coords.longitude,
+ },
+ },
+ });
} else {
requestMoney({
report,
diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx
index 99325ce45242..85efdb764ee8 100644
--- a/src/pages/workspace/WorkspaceInitialPage.tsx
+++ b/src/pages/workspace/WorkspaceInitialPage.tsx
@@ -242,6 +242,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac
icon: CalendarSolid,
action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM.getRoute(policyID)))),
routeName: SCREENS.WORKSPACE.PER_DIEM,
+ highlighted: highlightedFeature === CONST.POLICY.MORE_FEATURES.ARE_PER_DIEM_RATES_ENABLED,
});
}
diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx
index 7531b8d71c83..14df1fb1811f 100644
--- a/src/pages/workspace/WorkspaceMembersPage.tsx
+++ b/src/pages/workspace/WorkspaceMembersPage.tsx
@@ -12,8 +12,8 @@ import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
import type {DropdownOption, WorkspaceMemberBulkActionType} from '@components/ButtonWithDropdownMenu/types';
import ConfirmModal from '@components/ConfirmModal';
import DecisionModal from '@components/DecisionModal';
-import * as Expensicons from '@components/Icon/Expensicons';
-import * as Illustrations from '@components/Icon/Illustrations';
+import {Download, FallbackAvatar, MakeAdmin, Plus, RemoveMembers, Table, User, UserEye} from '@components/Icon/Expensicons';
+import {ReceiptWrangler} from '@components/Icon/Illustrations';
import MessagesRow from '@components/MessagesRow';
import TableListItem from '@components/SelectionList/TableListItem';
import type {ListItem, SelectionListHandle} from '@components/SelectionList/types';
@@ -27,22 +27,33 @@ import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
import useNetwork from '@hooks/useNetwork';
import usePrevious from '@hooks/usePrevious';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSearchBackPress from '@hooks/useSearchBackPress';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
-import * as DeviceCapabilities from '@libs/DeviceCapabilities';
-import * as LocalePhoneNumber from '@libs/LocalePhoneNumber';
+import {
+ clearAddMemberError,
+ clearDeleteMemberError,
+ clearInviteDraft,
+ clearWorkspaceOwnerChangeFlow,
+ downloadMembersCSV,
+ isApprover,
+ openWorkspaceMembersPage,
+ removeMembers,
+ updateWorkspaceMembersRole,
+} from '@libs/actions/Policy/Member';
+import {canUseTouchScreen} from '@libs/DeviceCapabilities';
+import {formatPhoneNumber as formatPhoneNumberUtil} from '@libs/LocalePhoneNumber';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {FullScreenNavigatorParamList} from '@libs/Navigation/types';
-import * as OptionsListUtils from '@libs/OptionsListUtils';
-import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
-import * as PolicyUtils from '@libs/PolicyUtils';
+import {isPersonalDetailsReady, sortAlphabetically} from '@libs/OptionsListUtils';
+import {getDisplayNameOrDefault, getPersonalDetailsByIDs} from '@libs/PersonalDetailsUtils';
+import {getMemberAccountIDsForWorkspace, isDeletedPolicyEmployee, isExpensifyTeam, isPaidGroupPolicy, isPolicyAdmin as isPolicyAdminUtils} from '@libs/PolicyUtils';
import {getDisplayNameForParticipant} from '@libs/ReportUtils';
-import * as Modal from '@userActions/Modal';
-import * as Member from '@userActions/Policy/Member';
-import * as Policy from '@userActions/Policy/Policy';
+import {close} from '@userActions/Modal';
+import {dismissAddedWithPrimaryLoginMessages} from '@userActions/Policy/Policy';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
@@ -69,7 +80,7 @@ function invertObject(object: Record): Record {
type MemberOption = Omit & {accountID: number};
function WorkspaceMembersPage({personalDetails, route, policy, currentUserPersonalDetails}: WorkspaceMembersPageProps) {
- const policyMemberEmailsToAccountIDs = useMemo(() => PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList, true), [policy?.employeeList]);
+ const policyMemberEmailsToAccountIDs = useMemo(() => getMemberAccountIDsForWorkspace(policy?.employeeList, true), [policy?.employeeList]);
const styles = useThemeStyles();
const [selectedEmployees, setSelectedEmployees] = useState([]);
const [removeMembersConfirmModalVisible, setRemoveMembersConfirmModalVisible] = useState(false);
@@ -89,9 +100,9 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
- const isPolicyAdmin = PolicyUtils.isPolicyAdmin(policy);
+ const isPolicyAdmin = isPolicyAdminUtils(policy);
const isLoading = useMemo(
- () => !isOfflineAndNoMemberDataAvailable && (!OptionsListUtils.isPersonalDetailsReady(personalDetails) || isEmptyObject(policy?.employeeList)),
+ () => !isOfflineAndNoMemberDataAvailable && (!isPersonalDetailsReady(personalDetails) || isEmptyObject(policy?.employeeList)),
[isOfflineAndNoMemberDataAvailable, personalDetails, policy?.employeeList],
);
@@ -106,11 +117,11 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
const canSelectMultiple = isPolicyAdmin && (shouldUseNarrowLayout ? selectionMode?.isEnabled : true);
const confirmModalPrompt = useMemo(() => {
- const approverAccountID = selectedEmployees.find((selectedEmployee) => Member.isApprover(policy, selectedEmployee));
+ const approverAccountID = selectedEmployees.find((selectedEmployee) => isApprover(policy, selectedEmployee));
if (!approverAccountID) {
return translate('workspace.people.removeMembersPrompt', {
count: selectedEmployees.length,
- memberName: LocalePhoneNumber.formatPhoneNumber(PersonalDetailsUtils.getPersonalDetailsByIDs({accountIDs: selectedEmployees, currentUserAccountID}).at(0)?.displayName ?? ''),
+ memberName: formatPhoneNumberUtil(getPersonalDetailsByIDs({accountIDs: selectedEmployees, currentUserAccountID}).at(0)?.displayName ?? ''),
});
}
return translate('workspace.people.removeMembersWarningPrompt', {
@@ -133,7 +144,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
* Get members for the current workspace
*/
const getWorkspaceMembers = useCallback(() => {
- Member.openWorkspaceMembersPage(route.params.policyID, Object.keys(PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList)));
+ openWorkspaceMembersPage(route.params.policyID, Object.keys(getMemberAccountIDsForWorkspace(policy?.employeeList)));
}, [route.params.policyID, policy?.employeeList]);
/**
@@ -179,7 +190,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
return res?.accountID ?? id;
});
- const currentSelectedElements = Object.entries(PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList))
+ const currentSelectedElements = Object.entries(getMemberAccountIDsForWorkspace(policy?.employeeList))
.filter((employee) => policy?.employeeList?.[employee[0]]?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)
.map((employee) => employee[1]);
@@ -201,7 +212,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
* Open the modal to invite a user
*/
const inviteUser = useCallback(() => {
- Member.clearInviteDraft(route.params.policyID);
+ clearInviteDraft(route.params.policyID);
Navigation.navigate(ROUTES.WORKSPACE_INVITE.getRoute(route.params.policyID, Navigation.getActiveRouteWithoutParams()));
}, [route.params.policyID]);
@@ -219,7 +230,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
setSelectedEmployees([]);
setRemoveMembersConfirmModalVisible(false);
InteractionManager.runAfterInteractions(() => {
- Member.removeMembers(accountIDsToRemove, route.params.policyID);
+ removeMembers(accountIDsToRemove, route.params.policyID);
});
};
@@ -298,11 +309,11 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
/** Opens the member details page */
const openMemberDetails = useCallback(
(item: MemberOption) => {
- if (!isPolicyAdmin || !PolicyUtils.isPaidGroupPolicy(policy)) {
+ if (!isPolicyAdmin || !isPaidGroupPolicy(policy)) {
Navigation.navigate(ROUTES.PROFILE.getRoute(item.accountID));
return;
}
- Member.clearWorkspaceOwnerChangeFlow(policyID);
+ clearWorkspaceOwnerChangeFlow(policyID);
Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(route.params.policyID, item.accountID));
},
[isPolicyAdmin, policy, policyID, route.params.policyID],
@@ -314,9 +325,9 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
const dismissError = useCallback(
(item: MemberOption) => {
if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
- Member.clearDeleteMemberError(route.params.policyID, item.accountID);
+ clearDeleteMemberError(route.params.policyID, item.accountID);
} else {
- Member.clearAddMemberError(route.params.policyID, item.accountID);
+ clearAddMemberError(route.params.policyID, item.accountID);
}
},
[route.params.policyID],
@@ -330,7 +341,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
Object.entries(policy?.employeeList ?? {}).forEach(([email, policyEmployee]) => {
const accountID = Number(policyMemberEmailsToAccountIDs[email] ?? '');
- if (PolicyUtils.isDeletedPolicyEmployee(policyEmployee, isOffline)) {
+ if (isDeletedPolicyEmployee(policyEmployee, isOffline)) {
return;
}
@@ -344,8 +355,8 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
// If this policy is owned by Expensify then show all support (expensify.com or team.expensify.com) emails
// We don't want to show guides as policy members unless the user is a guide. Some customers get confused when they
// see random people added to their policy, but guides having access to the policies help set them up.
- if (PolicyUtils.isExpensifyTeam(details?.login ?? details?.displayName)) {
- if (policyOwner && currentUserLogin && !PolicyUtils.isExpensifyTeam(policyOwner) && !PolicyUtils.isExpensifyTeam(currentUserLogin)) {
+ if (isExpensifyTeam(details?.login ?? details?.displayName)) {
+ if (policyOwner && currentUserLogin && !isExpensifyTeam(policyOwner) && !isExpensifyTeam(currentUserLogin)) {
return;
}
}
@@ -371,12 +382,12 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
isDisabled: isPendingDeleteOrError,
isInteractive: !details.isOptimisticPersonalDetail,
cursorStyle: details.isOptimisticPersonalDetail ? styles.cursorDefault : {},
- text: formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(details)),
+ text: formatPhoneNumber(getDisplayNameOrDefault(details)),
alternateText: formatPhoneNumber(details?.login ?? ''),
rightElement: roleBadge,
icons: [
{
- source: details.avatar ?? Expensicons.FallbackAvatar,
+ source: details.avatar ?? FallbackAvatar,
name: formatPhoneNumber(details?.login ?? ''),
type: CONST.ICON_TYPE_AVATAR,
id: accountID,
@@ -388,7 +399,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
invitedSecondaryLogin: details?.login ? invitedPrimaryToSecondaryLogins[details.login] ?? '' : '',
});
});
- result = OptionsListUtils.sortAlphabetically(result, 'text');
+ result = sortAlphabetically(result, 'text');
return result;
}, [
isOffline,
@@ -420,7 +431,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
}
const invitedEmails = Object.values(invitedEmailsToAccountIDsDraft).map(String);
selectionListRef.current?.scrollAndHighlightItem?.(invitedEmails);
- Member.clearInviteDraft(route.params.policyID);
+ clearInviteDraft(route.params.policyID);
}, [invitedEmailsToAccountIDsDraft, isFocused, accountIDs, prevAccountIDs, route.params.policyID]);
const getHeaderMessage = () => {
@@ -440,7 +451,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
// eslint-disable-next-line @typescript-eslint/naming-convention
messages={{0: translate('workspace.people.addedWithPrimary')}}
containerStyles={[styles.pb5, styles.ph5]}
- onClose={() => Policy.dismissAddedWithPrimaryLoginMessages(policyID)}
+ onClose={() => dismissAddedWithPrimaryLoginMessages(policyID)}
/>
)}
@@ -454,6 +465,11 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
setSelectedEmployees([]);
}, [setSelectedEmployees, selectionMode?.isEnabled]);
+ useSearchBackPress({
+ onClearSelection: () => setSelectedEmployees([]),
+ onNavigationCallBack: () => Navigation.goBack(),
+ });
+
const getCustomListHeader = () => {
return (
changeUserRole(CONST.POLICY.ROLE.USER),
};
const adminOption = {
text: translate('workspace.people.makeAdmin'),
value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_ADMIN,
- icon: Expensicons.MakeAdmin,
+ icon: MakeAdmin,
onSelected: () => changeUserRole(CONST.POLICY.ROLE.ADMIN),
};
const auditorOption = {
text: translate('workspace.people.makeAuditor'),
value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_AUDITOR,
- icon: Expensicons.UserEye,
+ icon: UserEye,
onSelected: () => changeUserRole(CONST.POLICY.ROLE.AUDITOR),
};
@@ -557,7 +573,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
success
onPress={inviteUser}
text={translate('workspace.invite.member')}
- icon={Expensicons.Plus}
+ icon={Plus}
innerStyles={[shouldUseNarrowLayout && styles.alignItemsCenter]}
style={[shouldUseNarrowLayout && styles.flexGrow1, shouldUseNarrowLayout && styles.mb3]}
/>
@@ -571,27 +587,27 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
const menuItems = [
{
- icon: Expensicons.Table,
+ icon: Table,
text: translate('spreadsheet.importSpreadsheet'),
onSelected: () => {
if (isOffline) {
- Modal.close(() => setIsOfflineModalVisible(true));
+ close(() => setIsOfflineModalVisible(true));
return;
}
Navigation.navigate(ROUTES.WORKSPACE_MEMBERS_IMPORT.getRoute(policyID));
},
},
{
- icon: Expensicons.Download,
+ icon: Download,
text: translate('spreadsheet.downloadCSV'),
onSelected: () => {
if (isOffline) {
- Modal.close(() => setIsOfflineModalVisible(true));
+ close(() => setIsOfflineModalVisible(true));
return;
}
- Modal.close(() => {
- Member.downloadMembersCSV(policyID, () => {
+ close(() => {
+ downloadMembersCSV(policyID, () => {
setIsDownloadFailureModalVisible(true);
});
});
@@ -609,7 +625,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
headerText={selectionModeHeader ? translate('common.selectMultiple') : translate('workspace.common.members')}
route={route}
guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS}
- icon={!selectionModeHeader ? Illustrations.ReceiptWrangler : undefined}
+ icon={!selectionModeHeader ? ReceiptWrangler : undefined}
headerContent={!shouldUseNarrowLayout && getHeaderButtons()}
testID={WorkspaceMembersPage.displayName}
shouldShowLoading={false}
@@ -684,7 +700,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
onSelectAll={() => toggleAllUsers(data)}
onDismissError={dismissError}
showLoadingPlaceholder={isLoading}
- shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
+ shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()}
textInputRef={textInputRef}
customListHeader={getCustomListHeader()}
listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]}
diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
index a773cae02905..fcf01d9df4ef 100644
--- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
+++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
@@ -29,6 +29,7 @@ import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
import useNetwork from '@hooks/useNetwork';
import usePolicy from '@hooks/usePolicy';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSearchBackPress from '@hooks/useSearchBackPress';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
@@ -104,6 +105,11 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) {
setSelectedCategories({});
}, [isFocused]);
+ useSearchBackPress({
+ onClearSelection: () => setSelectedCategories({}),
+ onNavigationCallBack: () => Navigation.goBack(backTo),
+ });
+
const updateWorkspaceRequiresCategory = useCallback(
(value: boolean, categoryName: string) => {
setWorkspaceCategoryEnabled(policyId, {[categoryName]: {name: categoryName, enabled: value}});
diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsFeedPendingPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsFeedPendingPage.tsx
index fc191ede1647..7068fccb34c4 100644
--- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsFeedPendingPage.tsx
+++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsFeedPendingPage.tsx
@@ -1,37 +1,34 @@
import React from 'react';
import EmptyStateComponent from '@components/EmptyStateComponent';
-import * as Illustrations from '@components/Icon/Illustrations';
+import {CompanyCardsPendingState} from '@components/Icon/Illustrations';
import CardRowSkeleton from '@components/Skeletons/CardRowSkeleton';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import {navigateToConciergeChat} from '@libs/actions/Report';
import colors from '@styles/theme/colors';
-import * as ReportInstance from '@userActions/Report';
import CONST from '@src/CONST';
function WorkspaceCompanyCardsFeedPendingPage() {
const {translate} = useLocalize();
const styles = useThemeStyles();
- const subtitle = (
-
- {translate('workspace.moreFeatures.companyCards.pendingFeedDescription')}
- ReportInstance.navigateToConciergeChat()}> {CONST?.CONCIERGE_CHAT_NAME}.
-
- );
-
return (
+ >
+
+ {translate('workspace.moreFeatures.companyCards.pendingFeedDescription')}
+ navigateToConciergeChat()}> {CONST.CONCIERGE_CHAT_NAME}.
+
+
);
}
diff --git a/src/pages/workspace/companyCards/assignCard/TransactionStartDateSelectorModal.tsx b/src/pages/workspace/companyCards/assignCard/TransactionStartDateSelectorModal.tsx
index 8c0990800bb4..1b2ba15bdbd7 100644
--- a/src/pages/workspace/companyCards/assignCard/TransactionStartDateSelectorModal.tsx
+++ b/src/pages/workspace/companyCards/assignCard/TransactionStartDateSelectorModal.tsx
@@ -8,7 +8,7 @@ import Modal from '@components/Modal';
import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import * as ValidationUtils from '@libs/ValidationUtils';
+import {getFieldRequiredErrors} from '@libs/ValidationUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import INPUT_IDS from '@src/types/form/AssignCardForm';
@@ -32,7 +32,7 @@ function TransactionStartDateSelectorModal({isVisible, date, handleSelectDate, o
const {translate} = useLocalize();
const validate = (values: FormOnyxValues): FormInputErrors =>
- ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.START_DATE]);
+ getFieldRequiredErrors(values, [INPUT_IDS.START_DATE]);
const submit = (values: FormOnyxValues) => {
handleSelectDate(values[INPUT_IDS.START_DATE]);
@@ -51,7 +51,6 @@ function TransactionStartDateSelectorModal({isVisible, date, handleSelectDate, o
setSelectedDistanceRates([]),
+ onNavigationCallBack: () => Navigation.goBack(),
+ });
+
const updateDistanceRateEnabled = useCallback(
(value: boolean, rateID: string) => {
if (!customUnit) {
diff --git a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx
index 1f51af583d8a..e14dfb06dedf 100644
--- a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx
+++ b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx
@@ -24,6 +24,7 @@ import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
import useNetwork from '@hooks/useNetwork';
import usePolicy from '@hooks/usePolicy';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSearchBackPress from '@hooks/useSearchBackPress';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
@@ -171,16 +172,26 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) {
rightElement: (
<>
- {value.subRateName}
+
+ {value.subRateName}
+
-
- {convertAmountToDisplayString(value.rate, value.currency)}
+
+
+ {convertAmountToDisplayString(value.rate, value.currency)}
+
>
),
};
}),
- [allSubRates, selectedPerDiem, canSelectMultiple, styles.flex2, styles.alignItemsStart, styles.textSupporting, styles.label, styles.pl2, styles.flex1, styles.alignSelfEnd],
+ [allSubRates, selectedPerDiem, canSelectMultiple, styles.flex2, styles.alignItemsStart, styles.textSupporting, styles.label, styles.pl2, styles.alignSelfEnd],
);
const toggleSubRate = (subRate: PolicyOption) => {
@@ -211,7 +222,7 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) {
{translate('common.subrate')}
-
+
{translate('workspace.perDiem.amount')}
@@ -279,6 +290,13 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) {
setSelectedPerDiem([]);
}, [setSelectedPerDiem, selectionMode?.isEnabled]);
+ useSearchBackPress({
+ onClearSelection: () => {
+ setSelectedPerDiem([]);
+ },
+ onNavigationCallBack: () => Navigation.goBack(backTo),
+ });
+
const hasVisibleSubRates = subRatesList.some((subRate) => subRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline);
const getHeaderText = () => (
diff --git a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx
index fa5a911b51c3..2c23f1877523 100644
--- a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx
+++ b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx
@@ -20,6 +20,7 @@ import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSearchBackPress from '@hooks/useSearchBackPress';
import useThemeStyles from '@hooks/useThemeStyles';
import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
import {
@@ -107,6 +108,13 @@ function ReportFieldsListValuesPage({
[policyID, reportFieldID],
);
+ useSearchBackPress({
+ onClearSelection: () => {
+ setSelectedValues({});
+ },
+ onNavigationCallBack: () => Navigation.goBack(),
+ });
+
const listValuesSections = useMemo(() => {
const data = listValues
.map((value, index) => ({
diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx
index 5602acec4767..298effde4158 100644
--- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx
+++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx
@@ -28,6 +28,7 @@ import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
import useNetwork from '@hooks/useNetwork';
import usePolicy from '@hooks/usePolicy';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSearchBackPress from '@hooks/useSearchBackPress';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
@@ -101,6 +102,13 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) {
setSelectedTags({});
}, [isFocused]);
+ useSearchBackPress({
+ onClearSelection: () => {
+ setSelectedTags({});
+ },
+ onNavigationCallBack: () => Navigation.goBack(backTo),
+ });
+
const getPendingAction = (policyTagList: PolicyTagList): PendingAction | undefined => {
if (!policyTagList) {
return undefined;
diff --git a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx
index 6f54a36d92b8..f378e9c29e65 100644
--- a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx
+++ b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx
@@ -19,6 +19,7 @@ import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
import useNetwork from '@hooks/useNetwork';
import usePolicy from '@hooks/usePolicy';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSearchBackPress from '@hooks/useSearchBackPress';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
@@ -88,6 +89,13 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) {
};
}, [isFocused]);
+ useSearchBackPress({
+ onClearSelection: () => {
+ setSelectedTags({});
+ },
+ onNavigationCallBack: () => Navigation.goBack(isQuickSettingsFlow ? ROUTES.SETTINGS_TAGS_ROOT.getRoute(policyID) : undefined),
+ });
+
const updateWorkspaceTagEnabled = useCallback(
(value: boolean, tagName: string) => {
setWorkspaceTagEnabled(policyID, {[tagName]: {name: tagName, enabled: value}}, route.params.orderWeight);
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
index 8e8696afb1d2..a72d2206fae9 100644
--- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
+++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
@@ -22,6 +22,7 @@ import useLocalize from '@hooks/useLocalize';
import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
import useNetwork from '@hooks/useNetwork';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSearchBackPress from '@hooks/useSearchBackPress';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {isConnectionInProgress} from '@libs/actions/connections';
@@ -93,6 +94,13 @@ function WorkspaceTaxesPage({
setSelectedTaxesIDs([]);
}, [isFocused]);
+ useSearchBackPress({
+ onClearSelection: () => {
+ setSelectedTaxesIDs([]);
+ },
+ onNavigationCallBack: () => Navigation.goBack(),
+ });
+
const textForDefault = useCallback(
(taxID: string, taxRate: TaxRate): string => {
let suffix;
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 1d42c30b98f6..7eaeaeff459a 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -4001,6 +4001,19 @@ const styles = (theme: ThemeColors) =>
...wordBreak.breakWord,
},
+ distanceLabelWrapper: {
+ backgroundColor: colors.green500,
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ borderRadius: 4,
+ textAlign: 'center',
+ },
+ distanceLabelText: {
+ fontSize: 13,
+ fontWeight: FontUtils.fontWeight.bold,
+ color: colors.productLight100,
+ },
+
productTrainingTooltipWrapper: {
backgroundColor: theme.tooltipHighlightBG,
borderRadius: variables.componentBorderRadiusNormal,
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index bbfa1e5515be..e70dbc436fb6 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -753,14 +753,25 @@ type AvatarBorderStyleParams = {
isPressed: boolean;
isInReportAction: boolean;
shouldUseCardBackground: boolean;
+ isActive?: boolean;
};
-function getHorizontalStackedAvatarBorderStyle({theme, isHovered, isPressed, isInReportAction = false, shouldUseCardBackground = false}: AvatarBorderStyleParams): ViewStyle {
+function getHorizontalStackedAvatarBorderStyle({
+ theme,
+ isHovered,
+ isPressed,
+ isInReportAction = false,
+ shouldUseCardBackground = false,
+ isActive = false,
+}: AvatarBorderStyleParams): ViewStyle {
let borderColor = shouldUseCardBackground ? theme.cardBG : theme.appBG;
if (isHovered) {
borderColor = isInReportAction ? theme.hoverComponentBG : theme.border;
}
+ if (isActive) {
+ borderColor = theme.messageHighlightBG;
+ }
if (isPressed) {
borderColor = isInReportAction ? theme.hoverComponentBG : theme.buttonPressedBG;
diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts
index 7002571ccda2..7f75d31baec2 100644
--- a/tests/actions/IOUTest.ts
+++ b/tests/actions/IOUTest.ts
@@ -190,34 +190,28 @@ describe('actions/IOU', () => {
mockFetch?.pause?.();
// When the user submits the transaction to the selfDM report
- trackExpense(
- selfDMReport,
- fakeTransaction.amount,
- fakeTransaction.currency,
- format(new Date(), CONST.DATE.FNS_FORMAT_STRING),
- fakeTransaction.merchant,
- participant.login,
- participant.accountID,
- participant,
- '',
- true,
- undefined,
- '',
- undefined,
- '',
- 0,
- false,
- undefined,
- undefined,
- undefined,
- undefined,
- fakeWayPoints,
- CONST.IOU.ACTION.CREATE,
- fakeTransaction?.actionableWhisperReportActionID,
- fakeTransaction?.linkedTrackedExpenseReportAction,
- fakeTransaction?.linkedTrackedExpenseReportID,
- CONST.CUSTOM_UNITS.FAKE_P2P_ID,
- );
+ trackExpense({
+ report: selfDMReport,
+ isDraftPolicy: true,
+ action: CONST.IOU.ACTION.CREATE,
+ participantParams: {
+ payeeEmail: participant.login,
+ payeeAccountID: participant.accountID,
+ participant,
+ },
+ transactionParams: {
+ amount: fakeTransaction.amount,
+ currency: fakeTransaction.currency,
+ created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING),
+ merchant: fakeTransaction.merchant,
+ billable: false,
+ validWaypoints: fakeWayPoints,
+ actionableWhisperReportActionID: fakeTransaction?.actionableWhisperReportActionID,
+ linkedTrackedExpenseReportAction: fakeTransaction?.linkedTrackedExpenseReportAction,
+ linkedTrackedExpenseReportID: fakeTransaction?.linkedTrackedExpenseReportID,
+ customUnitRateID: CONST.CUSTOM_UNITS.FAKE_P2P_ID,
+ },
+ });
await waitForBatchedUpdates();
await mockFetch?.resume?.();
@@ -283,34 +277,32 @@ describe('actions/IOU', () => {
const transactionDraft = allTransactionsDraft?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction?.transactionID}`];
// When the user confirms the category for the tracked expense
- trackExpense(
- expenseReport,
- transactionDraft?.amount ?? fakeTransaction.amount,
- transactionDraft?.currency ?? fakeTransaction.currency,
- format(new Date(), CONST.DATE.FNS_FORMAT_STRING),
- transactionDraft?.merchant ?? fakeTransaction.merchant,
- participant.login,
- participant.accountID,
- {...participant, isPolicyExpenseChat: true},
- '',
- false,
- undefined,
- Object.keys(fakeCategories).at(0) ?? '',
- '',
- '',
- 0,
- undefined,
- fakePolicy,
- undefined,
- fakeCategories,
- undefined,
- Object.keys(transactionDraft?.comment?.waypoints ?? {}).length ? getValidWaypoints(transactionDraft?.comment?.waypoints, true) : undefined,
- CONST.IOU.ACTION.CATEGORIZE,
- transactionDraft?.actionableWhisperReportActionID,
- transactionDraft?.linkedTrackedExpenseReportAction,
- transactionDraft?.linkedTrackedExpenseReportID,
- CONST.CUSTOM_UNITS.FAKE_P2P_ID,
- );
+ trackExpense({
+ report: expenseReport,
+ isDraftPolicy: false,
+ action: CONST.IOU.ACTION.CATEGORIZE,
+ participantParams: {
+ payeeEmail: participant.login,
+ payeeAccountID: participant.accountID,
+ participant: {...participant, isPolicyExpenseChat: true},
+ },
+ policyParams: {
+ policy: fakePolicy,
+ policyCategories: fakeCategories,
+ },
+ transactionParams: {
+ amount: transactionDraft?.amount ?? fakeTransaction.amount,
+ currency: transactionDraft?.currency ?? fakeTransaction.currency,
+ created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING),
+ merchant: transactionDraft?.merchant ?? fakeTransaction.merchant,
+ category: Object.keys(fakeCategories).at(0) ?? '',
+ validWaypoints: Object.keys(transactionDraft?.comment?.waypoints ?? {}).length ? getValidWaypoints(transactionDraft?.comment?.waypoints, true) : undefined,
+ actionableWhisperReportActionID: transactionDraft?.actionableWhisperReportActionID,
+ linkedTrackedExpenseReportAction: transactionDraft?.linkedTrackedExpenseReportAction,
+ linkedTrackedExpenseReportID: transactionDraft?.linkedTrackedExpenseReportID,
+ customUnitRateID: CONST.CUSTOM_UNITS.FAKE_P2P_ID,
+ },
+ });
await waitForBatchedUpdates();
await mockFetch?.resume?.();
@@ -4441,37 +4433,30 @@ describe('actions/IOU', () => {
[WRITE_COMMANDS.SHARE_TRACKED_EXPENSE, CONST.IOU.ACTION.SHARE],
])('%s', async (expectedCommand: ApiCommand, action: IOUAction) => {
// When a track expense is created
- trackExpense(
- {reportID: ''},
- 10000,
- CONST.CURRENCY.USD,
- '2024-10-30',
- 'KFC',
- RORY_EMAIL,
- RORY_ACCOUNT_ID,
- {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID},
- '',
- false,
- {},
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
+ trackExpense({
+ report: {reportID: ''},
+ isDraftPolicy: false,
action,
- '1',
- {
- reportActionID: '',
- actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ participantParams: {
+ payeeEmail: RORY_EMAIL,
+ payeeAccountID: RORY_ACCOUNT_ID,
+ participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID},
+ },
+ transactionParams: {
+ amount: 10000,
+ currency: CONST.CURRENCY.USD,
created: '2024-10-30',
+ merchant: 'KFC',
+ receipt: {},
+ actionableWhisperReportActionID: '1',
+ linkedTrackedExpenseReportAction: {
+ reportActionID: '',
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ created: '2024-10-30',
+ },
+ linkedTrackedExpenseReportID: '1',
},
- '1',
- );
+ });
await waitForBatchedUpdates();
diff --git a/tests/perf-test/SearchRouter.perf-test.tsx b/tests/perf-test/SearchRouter.perf-test.tsx
index 089f08b14fb3..95807a12cd1b 100644
--- a/tests/perf-test/SearchRouter.perf-test.tsx
+++ b/tests/perf-test/SearchRouter.perf-test.tsx
@@ -69,6 +69,11 @@ jest.mock('@react-navigation/native', () => {
};
});
+jest.mock('@libs/runOnLiveMarkdownRuntime', () => {
+ const runOnLiveMarkdownRuntime = (worklet: (...args: Args) => ReturnValue) => worklet;
+ return runOnLiveMarkdownRuntime;
+});
+
const getMockedReports = (length = 100) =>
createCollection(
(item) => `${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`,
@@ -117,6 +122,7 @@ function SearchAutocompleteInputWrapper() {
value={value}
onSearchQueryChange={(searchTerm) => setValue(searchTerm)}
isFullWidth={false}
+ substitutionMap={CONST.EMPTY_OBJECT}
/>
);
diff --git a/tests/unit/GoogleTagManagerTest.tsx b/tests/unit/GoogleTagManagerTest.tsx
index dcb6bdea0eec..327867361081 100644
--- a/tests/unit/GoogleTagManagerTest.tsx
+++ b/tests/unit/GoogleTagManagerTest.tsx
@@ -1,9 +1,9 @@
import {NavigationContainer} from '@react-navigation/native';
import {render} from '@testing-library/react-native';
import Onyx from 'react-native-onyx';
-import * as IOU from '@libs/actions/IOU';
-import * as PaymentMethods from '@libs/actions/PaymentMethods';
-import * as Policy from '@libs/actions/Policy/Policy';
+import {trackExpense} from '@libs/actions/IOU';
+import {addPaymentCard, addSubscriptionPaymentCard} from '@libs/actions/PaymentMethods';
+import {createWorkspace} from '@libs/actions/Policy/Policy';
import GoogleTagManager from '@libs/GoogleTagManager';
import OnboardingModalNavigator from '@libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator';
import CONST from '@src/CONST';
@@ -55,11 +55,11 @@ describe('GoogleTagManagerTest', () => {
test('workspace_created', async () => {
// When we run the createWorkspace action a few times
- Policy.createWorkspace();
+ createWorkspace();
await waitForBatchedUpdates();
- Policy.createWorkspace();
+ createWorkspace();
await waitForBatchedUpdates();
- Policy.createWorkspace();
+ createWorkspace();
// Then we publish a workspace_created event only once
expect(GoogleTagManager.publishEvent).toBeCalledTimes(1);
@@ -67,34 +67,29 @@ describe('GoogleTagManagerTest', () => {
});
test('workspace_created - categorizeTrackedExpense', () => {
- // When we categorize a tracked expense with a draft policy
- IOU.trackExpense(
- {reportID: '123'},
- 1000,
- 'USD',
- '2024-10-30',
- 'merchant',
- undefined,
- 0,
- {accountID},
- 'comment',
- true,
- undefined,
- 'category',
- 'tag',
- 'taxCode',
- 0,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- CONST.IOU.ACTION.CATEGORIZE,
- 'actionableWhisperReportActionID',
- {actionName: 'IOU', reportActionID: 'linkedTrackedExpenseReportAction', created: '2024-10-30'},
- 'linkedTrackedExpenseReportID',
- );
+ trackExpense({
+ report: {reportID: '123'},
+ isDraftPolicy: true,
+ action: CONST.IOU.ACTION.CATEGORIZE,
+ participantParams: {
+ payeeEmail: undefined,
+ payeeAccountID: 0,
+ participant: {accountID},
+ },
+ transactionParams: {
+ amount: 1000,
+ currency: 'USD',
+ created: '2024-10-30',
+ merchant: 'merchant',
+ comment: 'comment',
+ category: 'category',
+ tag: 'tag',
+ taxCode: 'taxCode',
+ actionableWhisperReportActionID: 'actionableWhisperReportActionID',
+ linkedTrackedExpenseReportAction: {actionName: 'IOU', reportActionID: 'linkedTrackedExpenseReportAction', created: '2024-10-30'},
+ linkedTrackedExpenseReportID: 'linkedTrackedExpenseReportID',
+ },
+ });
// Then we publish a workspace_created event only once
expect(GoogleTagManager.publishEvent).toBeCalledTimes(1);
@@ -103,7 +98,7 @@ describe('GoogleTagManagerTest', () => {
test('paid_adoption - addPaymentCard', () => {
// When we add a payment card
- PaymentMethods.addPaymentCard(accountID, {
+ addPaymentCard(accountID, {
expirationDate: '2077-10-30',
addressZipCode: 'addressZipCode',
cardNumber: 'cardNumber',
@@ -118,7 +113,7 @@ describe('GoogleTagManagerTest', () => {
test('paid_adoption - addSubscriptionPaymentCard', () => {
// When we add a payment card
- PaymentMethods.addSubscriptionPaymentCard(accountID, {
+ addSubscriptionPaymentCard(accountID, {
cardNumber: 'cardNumber',
cardYear: 'cardYear',
cardMonth: 'cardMonth',
diff --git a/tests/unit/ModifiedExpenseMessageTest.ts b/tests/unit/ModifiedExpenseMessageTest.ts
index d6de02e61640..f12632bbc946 100644
--- a/tests/unit/ModifiedExpenseMessageTest.ts
+++ b/tests/unit/ModifiedExpenseMessageTest.ts
@@ -364,17 +364,17 @@ describe('ModifiedExpenseMessage', () => {
...createRandomReportAction(1),
actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE,
originalMessage: {
- oldMerchant: '1.00 mi @ $0.67 / mi',
- merchant: '10.00 mi @ $0.67 / mi',
- oldAmount: 67,
- amount: 670,
+ oldMerchant: '1.00 mi @ $0.70 / mi',
+ merchant: '10.00 mi @ $0.70 / mi',
+ oldAmount: 70,
+ amount: 700,
oldCurrency: CONST.CURRENCY.USD,
currency: CONST.CURRENCY.USD,
},
};
it('then the message says the distance is changed and shows the new and old merchant and amount', () => {
- const expectedResult = `changed the distance to ${reportAction.originalMessage.merchant} (previously ${reportAction.originalMessage.oldMerchant}), which updated the amount to $6.70 (previously $0.67)`;
+ const expectedResult = `changed the distance to ${reportAction.originalMessage.merchant} (previously ${reportAction.originalMessage.oldMerchant}), which updated the amount to $7.00 (previously $0.70)`;
const result = ModifiedExpenseMessage.getForReportAction({reportOrID: report.reportID, reportAction});
expect(result).toEqual(expectedResult);
});
@@ -385,9 +385,9 @@ describe('ModifiedExpenseMessage', () => {
...createRandomReportAction(1),
actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE,
originalMessage: {
- oldMerchant: '56.36 mi @ $0.67 / mi',
+ oldMerchant: '56.36 mi @ $0.70 / mi',
merchant: '56.36 mi @ $0.99 / mi',
- oldAmount: 3776,
+ oldAmount: 3945,
amount: 5580,
oldCurrency: CONST.CURRENCY.USD,
currency: CONST.CURRENCY.USD,
@@ -395,7 +395,7 @@ describe('ModifiedExpenseMessage', () => {
};
it('then the message says the rate is changed and shows the new and old merchant and amount', () => {
- const expectedResult = `changed the rate to ${reportAction.originalMessage.merchant} (previously ${reportAction.originalMessage.oldMerchant}), which updated the amount to $55.80 (previously $37.76)`;
+ const expectedResult = `changed the rate to ${reportAction.originalMessage.merchant} (previously ${reportAction.originalMessage.oldMerchant}), which updated the amount to $55.80 (previously $39.45)`;
const result = ModifiedExpenseMessage.getForReportAction({reportOrID: report.reportID, reportAction});
expect(result).toEqual(expectedResult);
});
diff --git a/tests/unit/PolicyDistanceRatesUtilsTest.ts b/tests/unit/PolicyDistanceRatesUtilsTest.ts
index c7116fcf6f82..1191adc6800e 100644
--- a/tests/unit/PolicyDistanceRatesUtilsTest.ts
+++ b/tests/unit/PolicyDistanceRatesUtilsTest.ts
@@ -6,17 +6,17 @@ describe('PolicyDistanceRatesUtils', () => {
// Given a tax claimable value inserted for a distance rate
// When the taxClaimableValue is equal to the tax rate
- const validate = validateTaxClaimableValue({taxClaimableValue: '0.67'}, {rate: 67, customUnitRateID: ''});
+ const validate = validateTaxClaimableValue({taxClaimableValue: '0.70'}, {rate: 70, customUnitRateID: ''});
// Then validateTaxClaimableValue will return an error.
expect(validate.taxClaimableValue).toBeDefined();
// When the taxClaimableValue is greater than the tax rate
- const validate2 = validateTaxClaimableValue({taxClaimableValue: '0.69'}, {rate: 67, customUnitRateID: ''});
+ const validate2 = validateTaxClaimableValue({taxClaimableValue: '0.72'}, {rate: 70, customUnitRateID: ''});
// Then validateTaxClaimableValue will return an error.
expect(validate2.taxClaimableValue).toBeDefined();
// When the taxClaimableValue is less than the tax rate
- const validate3 = validateTaxClaimableValue({taxClaimableValue: '0.65'}, {rate: 67, customUnitRateID: ''});
+ const validate3 = validateTaxClaimableValue({taxClaimableValue: '0.65'}, {rate: 70, customUnitRateID: ''});
// Then validateTaxClaimableValue will not return an error.
expect(validate3.taxClaimableValue).toBeUndefined();
});
diff --git a/tests/unit/SearchAutocompleteParserTest.ts b/tests/unit/SearchAutocompleteParserTest.ts
index 995d23eaeff5..84fbf70b3aeb 100644
--- a/tests/unit/SearchAutocompleteParserTest.ts
+++ b/tests/unit/SearchAutocompleteParserTest.ts
@@ -1,5 +1,5 @@
import type {SearchQueryJSON} from '@components/Search/types';
-import * as autocompleteParser from '@libs/SearchParser/autocompleteParser';
+import {parse} from '@libs/SearchParser/autocompleteParser';
import parserCommonTests from '../utils/fixtures/searchParsersCommonQueries';
const tests = [
@@ -13,7 +13,9 @@ const tests = [
length: 3,
},
ranges: [
+ {key: 'syntax', value: 'type', start: 0, length: 5},
{key: 'type', value: 'expense', start: 5, length: 7},
+ {key: 'syntax', value: 'status', start: 13, length: 7},
{key: 'status', value: 'all', start: 20, length: 3},
],
},
@@ -23,8 +25,11 @@ const tests = [
expected: {
autocomplete: null,
ranges: [
+ {key: 'syntax', value: 'taxRate', start: 0, length: 9},
{key: 'taxRate', value: 'rate1', start: 9, length: 5},
+ {key: 'syntax', value: 'expenseType', start: 15, length: 13},
{key: 'expenseType', value: 'card', start: 28, length: 4},
+ {key: 'syntax', value: 'cardID', start: 33, length: 5},
{key: 'cardID', value: 'Big Bank', start: 38, length: 10},
],
},
@@ -34,8 +39,11 @@ const tests = [
expected: {
autocomplete: null,
ranges: [
+ {key: 'syntax', value: 'taxRate', start: 0, length: 8},
{key: 'taxRate', value: 'rate1', start: 8, length: 5},
+ {key: 'syntax', value: 'expenseType', start: 14, length: 12},
{key: 'expenseType', value: 'card', start: 26, length: 4},
+ {key: 'syntax', value: 'cardID', start: 31, length: 7},
{key: 'cardID', value: 'Big Bank', start: 38, length: 10},
],
},
@@ -50,8 +58,10 @@ const tests = [
value: 'meal & entertainment',
},
ranges: [
+ {key: 'syntax', value: 'expenseType', start: 11, length: 13},
{key: 'expenseType', length: 4, start: 24, value: 'cash'},
{key: 'expenseType', length: 4, start: 29, value: 'card'},
+ {key: 'syntax', value: 'category', start: 80, length: 9},
{key: 'category', length: 6, start: 89, value: 'travel'},
{key: 'category', length: 5, start: 96, value: 'hotel'},
{key: 'category', length: 22, start: 102, value: 'meal & entertainment'},
@@ -68,8 +78,11 @@ const tests = [
value: 'a b',
},
ranges: [
+ {key: 'syntax', value: 'type', start: 0, length: 5},
{key: 'type', value: 'expense', start: 5, length: 7},
+ {key: 'syntax', value: 'status', start: 13, length: 7},
{key: 'status', value: 'all', start: 20, length: 3},
+ {key: 'syntax', value: 'category', start: 24, length: 9},
{key: 'category', value: 'a b', start: 33, length: 5},
],
},
@@ -92,7 +105,7 @@ const tests = [
query: 'tag:,,',
expected: {
autocomplete: null,
- ranges: [],
+ ranges: [{key: 'syntax', value: 'tag', start: 0, length: 4}],
},
},
{
@@ -105,7 +118,9 @@ const tests = [
length: 3,
},
ranges: [
+ {key: 'syntax', value: 'in', start: 0, length: 3},
{key: 'in', value: '123456', start: 3, length: 6},
+ {key: 'syntax', value: 'currency', start: 10, length: 9},
{key: 'currency', value: 'USD', start: 19, length: 3},
],
},
@@ -120,6 +135,7 @@ const tests = [
length: 4,
},
ranges: [
+ {key: 'syntax', value: 'tag', start: 0, length: 4},
{key: 'tag', value: 'aa', start: 4, length: 2},
{key: 'tag', value: 'bbb', start: 7, length: 3},
{key: 'tag', value: 'cccc', start: 11, length: 4},
@@ -135,7 +151,7 @@ const tests = [
start: 9,
length: 0,
},
- ranges: [],
+ ranges: [{key: 'syntax', value: 'category', start: 0, length: 9}],
},
},
{
@@ -147,7 +163,10 @@ const tests = [
start: 21,
length: 0,
},
- ranges: [{key: 'category', value: 'Advertising', start: 9, length: 11}],
+ ranges: [
+ {key: 'syntax', value: 'category', start: 0, length: 9},
+ {key: 'category', value: 'Advertising', start: 9, length: 11},
+ ],
},
},
{
@@ -160,6 +179,7 @@ const tests = [
length: 12,
},
ranges: [
+ {key: 'syntax', value: 'in', start: 0, length: 3},
{key: 'in', value: 'Big Room', start: 3, length: 10},
{key: 'in', value: 'small room', start: 14, length: 12},
],
@@ -174,7 +194,10 @@ const tests = [
start: 12,
length: 3,
},
- ranges: [{key: 'category', value: 'Car', start: 12, length: 3}],
+ ranges: [
+ {key: 'syntax', value: 'category', start: 0, length: 12},
+ {key: 'category', value: 'Car', start: 12, length: 3},
+ ],
},
},
{
@@ -182,7 +205,9 @@ const tests = [
expected: {
autocomplete: null,
ranges: [
+ {key: 'syntax', value: 'type', start: 0, length: 5},
{key: 'type', value: 'expense', start: 5, length: 7},
+ {key: 'syntax', value: 'status', start: 13, length: 7},
{key: 'status', value: 'all', start: 20, length: 3},
],
},
@@ -197,11 +222,16 @@ const tests = [
length: 4,
},
ranges: [
+ {key: 'syntax', value: 'in', start: 0, length: 3},
{key: 'in', value: 'Big Room', start: 3, length: 10},
+ {key: 'syntax', value: 'from', start: 14, length: 5},
{key: 'from', value: 'Friend', start: 19, length: 6},
+ {key: 'syntax', value: 'category', start: 26, length: 9},
{key: 'category', value: 'Car', start: 35, length: 3},
{key: 'category', value: 'Cell Phone', start: 39, length: 12},
+ {key: 'syntax', value: 'status', start: 52, length: 7},
{key: 'status', value: 'all', start: 59, length: 3},
+ {key: 'syntax', value: 'expenseType', start: 63, length: 13},
{key: 'expenseType', value: 'card', start: 76, length: 4},
{key: 'expenseType', value: 'cash', start: 81, length: 4},
],
@@ -217,11 +247,15 @@ const tests = [
length: 8,
},
ranges: [
+ {key: 'syntax', value: 'currency', start: 0, length: 9},
{key: 'currency', value: 'PLN', start: 9, length: 3},
{key: 'currency', value: 'USD', start: 13, length: 3},
+ {key: 'syntax', value: 'taxRate', start: 25, length: 9},
{key: 'taxRate', value: 'tax', start: 34, length: 3},
+ {key: 'syntax', value: 'tag', start: 66, length: 4},
{key: 'tag', value: 'General Overhead', start: 70, length: 18},
{key: 'tag', value: 'IT', start: 89, length: 2},
+ {key: 'syntax', value: 'expenseType', start: 92, length: 13},
{key: 'expenseType', value: 'card', start: 105, length: 4},
{key: 'expenseType', value: 'distance', start: 110, length: 8},
],
@@ -231,7 +265,7 @@ const tests = [
describe('autocomplete parser', () => {
test.each(tests)(`parsing: $query`, ({query, expected}) => {
- const result = autocompleteParser.parse(query) as SearchQueryJSON;
+ const result = parse(query) as SearchQueryJSON;
expect(result).toEqual(expected);
});