diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index f4f6a90ae6db..1446f1e4d851 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -94,7 +94,7 @@ This is a checklist for PR authors. Please make sure to complete all tasks and c
- [ ] I followed the guidelines as stated in the [Review Guidelines](https://github.com/Expensify/App/blob/main/contributingGuides/PR_REVIEW_GUIDELINES.md)
- [ ] I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like `Avatar`, I verified the components using `Avatar` are working as expected)
- [ ] I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
-- [ ] I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such
+- [ ] I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
- [ ] I verified that if a function's arguments changed that all usages have also been updated correctly
- [ ] If any new file was added I verified that:
- [ ] The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
@@ -109,6 +109,7 @@ This is a checklist for PR authors. Please make sure to complete all tasks and c
- [ ] I verified that all the inputs inside a form are aligned with each other.
- [ ] I added `Design` label and/or tagged `@Expensify/design` so the design team can review the changes.
- [ ] If a new page is added, I verified it's using the `ScrollView` component to make it scrollable when more elements are added to the page.
+- [ ] I added [unit tests](https://github.com/Expensify/App/blob/main/tests/README.md) for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
- [ ] If the `main` branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the `Test` steps.
### Screenshots/Videos
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 672776f6ecb4..799f84615216 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 1009006607
- versionName "9.0.66-7"
+ versionCode 1009006703
+ versionName "9.0.67-3"
// 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/contributingGuides/PROPOSAL_TEMPLATE.md b/contributingGuides/PROPOSAL_TEMPLATE.md
index 8c9fa7968fe2..d5ab0bf4a864 100644
--- a/contributingGuides/PROPOSAL_TEMPLATE.md
+++ b/contributingGuides/PROPOSAL_TEMPLATE.md
@@ -7,6 +7,9 @@
### What changes do you think we should make in order to solve the problem?
+### What specific scenarios should we cover in automated tests to prevent reintroducing this issue in the future?
+
+
### What alternative solutions did you explore? (Optional)
**Reminder:** Please use plain English, be brief and avoid jargon. Feel free to use images, charts or pseudo-code if necessary. Do not post large multi-line diffs or write walls of text. Do not create PRs unless you have been hired for this job.
diff --git a/contributingGuides/REVIEWER_CHECKLIST.md b/contributingGuides/REVIEWER_CHECKLIST.md
index 5fc14328f3b4..545c79a95af1 100644
--- a/contributingGuides/REVIEWER_CHECKLIST.md
+++ b/contributingGuides/REVIEWER_CHECKLIST.md
@@ -30,7 +30,7 @@
- [ ] I verified that this PR follows the guidelines as stated in the [Review Guidelines](https://github.com/Expensify/App/blob/main/contributingGuides/PR_REVIEW_GUIDELINES.md)
- [ ] I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like `Avatar`, I verified the components using `Avatar` have been tested & I retested again)
- [ ] I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
-- [ ] I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such
+- [ ] I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
- [ ] If a new component is created I verified that:
- [ ] A similar component doesn't exist in the codebase
- [ ] All props are defined accurately and each prop has a `/** comment above it */`
@@ -54,6 +54,7 @@
- [ ] I verified that all the inputs inside a form are aligned with each other.
- [ ] I added `Design` label and/or tagged `@Expensify/design` so the design team can review the changes.
- [ ] If a new page is added, I verified it's using the `ScrollView` component to make it scrollable when more elements are added to the page.
+- [ ] For any bug fix or new feature in this PR, I verified that sufficient [unit tests](https://github.com/Expensify/App/blob/main/tests/README.md) are included to prevent regressions in this flow.
- [ ] If the `main` branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the `Test` steps.
- [ ] I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.
diff --git a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Commercial-Card-Feeds.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Commercial-Card-Feeds.md
index f46c1a1442c2..e5d80b80017d 100644
--- a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Commercial-Card-Feeds.md
+++ b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Commercial-Card-Feeds.md
@@ -82,7 +82,7 @@ Any transactions that were posted prior to this date will not be imported into E
Click the Assign button
Once assigned, you will see each cardholder associated with their card as well as the start date listed.
-If you're using a connected accounting system such as NetSuite, Xero, Intacct, Quickbooks Desktop, or QuickBooks Online, you can also connect the card to export to a specific credit card GL account.
+If you're using a connected accounting system such as NetSuite, Xero, Intacct, QuickBooks Desktop, or QuickBooks Online, you can also connect the card to export to a specific credit card GL account.
Go to Settings > Domains > [Domain name] > Company Cards
Click Edit Exports on the right-hand side of the card table and select the GL account you want to export expenses to.
diff --git a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings.md
index 553171d73dde..7492d705c2ef 100644
--- a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings.md
+++ b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings.md
@@ -49,7 +49,7 @@ If Scheduled Submit is disabled on the group workspace level (or set to a manual
# How to connect company cards to an accounting integration
-If you're using a connected accounting system such as NetSuite, Xero, Intacct, Quickbooks Desktop, or QuickBooks Online, you can also connect the card to export to a specific credit card GL account. First, connect the card itself, and once completed, follow the steps below:
+If you're using a connected accounting system such as NetSuite, Xero, Intacct, QuickBooks Desktop, or QuickBooks Online, you can also connect the card to export to a specific credit card GL account. First, connect the card itself, and once completed, follow the steps below:
Go to Settings > Domains > Domain name > Company Cards
Click Edit Exports on the right-hand side of the card table and select the GL account you want to export expenses to
You're all done. After the account is set, exported expenses will be mapped to the specific account selected when exported by a Domain Admin.
diff --git a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Direct-Bank-Connections.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Direct-Bank-Connections.md
index c9720177a8fc..f790309fbefa 100644
--- a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Direct-Bank-Connections.md
+++ b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Direct-Bank-Connections.md
@@ -56,7 +56,7 @@ To completely remove the card connection, unassign every card from the list and
# Deep Dive
## Configure card settings
Once you’ve imported your company cards, the next step is configuring the cards’ settings.
-If you're using a connected accounting system such as NetSuite, Xero, Sage Intacct, Quickbooks Desktop, or QuickBooks Online. In that case, you can connect the card to export to a specific credit card GL account.
+If you're using a connected accounting system such as NetSuite, Xero, Sage Intacct, QuickBooks Desktop, or QuickBooks Online. In that case, you can connect the card to export to a specific credit card GL account.
1. Go to **Settings > Domains > _Domain Name_ > Company Cards**
2. Click **Edit Exports** on the right-hand side of the card table and select the GL account you want to export expenses to
3. You're all done. After the account is set, exported expenses will be mapped to the specific account selected when exported by a Domain Admin.
diff --git a/docs/articles/expensify-classic/connections/quickbooks-desktop/Configure-Quickbooks-Desktop.md b/docs/articles/expensify-classic/connections/quickbooks-desktop/Configure-Quickbooks-Desktop.md
index dd913af1c497..b1c36666d6ca 100644
--- a/docs/articles/expensify-classic/connections/quickbooks-desktop/Configure-Quickbooks-Desktop.md
+++ b/docs/articles/expensify-classic/connections/quickbooks-desktop/Configure-Quickbooks-Desktop.md
@@ -1,6 +1,6 @@
---
-title: Configure Quickbooks Desktop
-description: Configure Quickbooks Desktop
+title: Configure QuickBooks Desktop
+description: Configure QuickBooks Desktop
---
Our new QuickBooks Desktop integration allows you to automate the import and export process with Expensify.
@@ -71,7 +71,7 @@ For manual syncing, we recommend completing this process at least once a week an
When syncing Expensify to QuickBooks Desktop, we recommend waiting until the sync finishes to access either Expensify and/or QuickBooks Desktop, as performance may vary during this process. You cannot open an instance of QuickBooks Desktop while a program is syncing - this may cause QuickBooks Desktop to behave unexpectedly.
-## **What are the different types of accounts that can be imported from Quickbooks Desktop?**
+## **What are the different types of accounts that can be imported from QuickBooks Desktop?**
Here is the list of accounts from QuickBooks Desktop and how they are pulled into Expensify:
diff --git a/docs/articles/expensify-classic/connections/quickbooks-online/Configure-Quickbooks-Online.md b/docs/articles/expensify-classic/connections/quickbooks-online/Configure-Quickbooks-Online.md
index 3fd1df0c0a1c..d9b4d846110e 100644
--- a/docs/articles/expensify-classic/connections/quickbooks-online/Configure-Quickbooks-Online.md
+++ b/docs/articles/expensify-classic/connections/quickbooks-online/Configure-Quickbooks-Online.md
@@ -1,6 +1,6 @@
---
-title: Configure Quickbooks Online
-description: Configure Quickbooks Online
+title: Configure QuickBooks Online
+description: Configure QuickBooks Online
---
# Best Practices Using QuickBooks Online
@@ -88,7 +88,7 @@ The following steps help you determine the advanced settings for your connection
- _Automatically Create Entities_: If you export reimbursable expenses as Vendor Bills or Journal Entries, Expensify will automatically create a vendor in QuickBooks (If one does not already exist). Expensify will also automatically create a customer when exporting Invoices.
- _Sync Reimbursed Reports_: Enabling will mark the Vendor Bill as paid in QuickBooks Online if you reimburse a report via ACH direct deposit in Expensify. If you reimburse outside of Expensify, then marking the Vendor Bill as paid in QuickBooks Online will automatically mark the report as reimbursed in Expensify.
- _QuickBooks Account_: Select the bank account your reimbursements are coming out of, and we'll create the payment in QuickBooks.
- - _Collection Account_: When exporting invoices from Expensify to Quickbooks Online, the invoice will appear against the Collection Account once marked as Paid.
+ - _Collection Account_: When exporting invoices from Expensify to QuickBooks Online, the invoice will appear against the Collection Account once marked as Paid.
{% include faq-begin.md %}
diff --git a/docs/articles/expensify-classic/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md b/docs/articles/expensify-classic/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md
index a397e34accb0..66cf4df2788f 100644
--- a/docs/articles/expensify-classic/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md
+++ b/docs/articles/expensify-classic/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md
@@ -1,6 +1,6 @@
---
-title: Quickbooks Online Troubleshooting
-description: Quickbooks Online Troubleshooting
+title: QuickBooks Online Troubleshooting
+description: QuickBooks Online Troubleshooting
---
# ExpensiError QBO022: When exporting billable expenses, please make sure the account in QuickBooks Online has been marked as billable.
diff --git a/docs/articles/expensify-classic/expenses/Apply-Tax.md b/docs/articles/expensify-classic/expenses/Apply-Tax.md
index c89176bcc0e8..9360962cb2ba 100644
--- a/docs/articles/expensify-classic/expenses/Apply-Tax.md
+++ b/docs/articles/expensify-classic/expenses/Apply-Tax.md
@@ -28,6 +28,21 @@ To handle these, you can create a single tax that combines both taxes into a sin
From the Reports page, you can select Reports and then click **Export To > Tax Report** to generate a CSV containing all the expense information, including the split-out taxes.
+## Why is the tax amount different than I expect?
+
+In Expensify, tax is *inclusive*, meaning it's already part of the total amount shown.
+
+To determine the inclusive tax from a total price that already includes tax, you can use the following formula:
+
+### **Tax amount = (Total price x Tax rate) ÷ (1 + Tax Rate)**
+
+For example, if an item costs $100 and the tax rate is 20%:
+Tax amount = (**$100** x .20) ÷ (1 + .**20**) = **$16.67**
+This means the tax amount $16.67 is included in the total.
+
+If you are simply trying to calculate the price before tax, you can use the formula:
+
+### **Price before tax = (Total price) ÷ (1 + Tax rate)**
# Deep Dive
diff --git a/docs/articles/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md b/docs/articles/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md
index 73e3340d41a2..19e30196e023 100644
--- a/docs/articles/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md
+++ b/docs/articles/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md
@@ -1,5 +1,5 @@
---
-title: Configure Quickbooks Online
+title: Configure QuickBooks Online
description: Configure your QuickBooks Online connection with Expensify
---
diff --git a/docs/articles/new-expensify/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md b/docs/articles/new-expensify/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md
index ff1b9bfab9fb..497c618442b1 100644
--- a/docs/articles/new-expensify/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md
+++ b/docs/articles/new-expensify/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md
@@ -1,5 +1,5 @@
---
-title: Quickbooks Online Troubleshooting
+title: QuickBooks Online Troubleshooting
description: A list of common QuickBooks Online errors and how to resolve them
---
diff --git a/help/map.md b/help/map.md
index eb218e67dcc0..73940652ff22 100644
--- a/help/map.md
+++ b/help/map.md
@@ -254,8 +254,8 @@ Lost in the app? Let this map guide you!
* Delete
* Accounting
* Connections list
- * Quickbooks Online Connect
- * Quickbooks Desktop Connect
+ * QuickBooks Online Connect
+ * QuickBooks Desktop Connect
* Xero
* NetSuite
* Sage Intacct
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 45fe3eb36805..9ca1a6366cab 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 9.0.66
+ 9.0.67
CFBundleSignature
????
CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 9.0.66.7
+ 9.0.67.3
FullStory
OrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 05f70824981c..9e6468012d71 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 9.0.66
+ 9.0.67
CFBundleSignature
????
CFBundleVersion
- 9.0.66.7
+ 9.0.67.3
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index eb799cfd6323..57aba1fd4cb1 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName
$(PRODUCT_NAME)
CFBundleShortVersionString
- 9.0.66
+ 9.0.67
CFBundleVersion
- 9.0.66.7
+ 9.0.67.3
NSExtension
NSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index 44b5c246aaa4..134e1db15247 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.0.66-7",
+ "version": "9.0.67-3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.66-7",
+ "version": "9.0.67-3",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -95,7 +95,7 @@
"react-native-launch-arguments": "^4.0.2",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "2.0.81",
+ "react-native-onyx": "2.0.82",
"react-native-pager-view": "6.5.0",
"react-native-pdf": "6.7.3",
"react-native-performance": "^5.1.0",
@@ -35765,9 +35765,9 @@
}
},
"node_modules/react-native-onyx": {
- "version": "2.0.81",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.81.tgz",
- "integrity": "sha512-EwBqruX4lLnlk3KyZp4bst/voekLJFus7UhtvKmDuqR2Iz/FremwE04JW6YxGyc7C6KpbQrCFdWg/oF9ptRAtg==",
+ "version": "2.0.82",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.82.tgz",
+ "integrity": "sha512-12+NgkC4fOeGu2J6s985NKUuLHP4aijBhpE6Us5IfVL+9dwxr/KqUVgV00OzXtYAABcWcpMC5PrvESqe8T5Iyw==",
"license": "MIT",
"dependencies": {
"ascii-table": "0.0.9",
diff --git a/package.json b/package.json
index f01da59de585..0b7bd0d5ccbf 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.66-7",
+ "version": "9.0.67-3",
"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.",
@@ -152,7 +152,7 @@
"react-native-launch-arguments": "^4.0.2",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "2.0.81",
+ "react-native-onyx": "2.0.82",
"react-native-pager-view": "6.5.0",
"react-native-pdf": "6.7.3",
"react-native-performance": "^5.1.0",
diff --git a/src/CONST.ts b/src/CONST.ts
index ee70e3b29668..6a15217ddd49 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -2575,8 +2575,8 @@ const CONST = {
},
NAME_USER_FRIENDLY: {
netsuite: 'NetSuite',
- quickbooksOnline: 'Quickbooks Online',
- quickbooksDesktop: 'Quickbooks Desktop',
+ quickbooksOnline: 'QuickBooks Online',
+ quickbooksDesktop: 'QuickBooks Desktop',
xero: 'Xero',
intacct: 'Sage Intacct',
financialForce: 'FinancialForce',
@@ -3815,8 +3815,8 @@ const CONST = {
},
GA: {},
GB: {
- regex: /^[A-Z]{1,2}[0-9R][0-9A-Z]?\s*[0-9][A-Z-CIKMOV]{2}$/,
- samples: 'LA102UX, BL2F8FX, BD1S9LU, WR4G 6LH',
+ regex: /^[A-Z]{1,2}[0-9R][0-9A-Z]?\s*([0-9][ABD-HJLNP-UW-Z]{2})?$/,
+ samples: 'LA102UX, BL2F8FX, BD1S9LU, WR4G 6LH, W1U',
},
GD: {},
GE: {
@@ -4974,9 +4974,8 @@ const CONST = {
'2. Go to *Workspaces*.\n' +
'3. Select your workspace.\n' +
'4. Click *Categories*.\n' +
- '5. Add or import your own categories.\n' +
- "6. Disable any default categories you don't need.\n" +
- '7. Require a category for every expense in *Settings*.\n' +
+ "5. Disable any categories you don't need.\n" +
+ '6. Add your own categories in the top right.\n' +
'\n' +
`[Take me to workspace category settings](${workspaceCategoriesLink}).`,
},
@@ -5949,6 +5948,7 @@ const CONST = {
MAX_TAX_RATE_INTEGER_PLACES: 4,
MAX_TAX_RATE_DECIMAL_PLACES: 4,
+ MIN_TAX_RATE_DECIMAL_PLACES: 2,
DOWNLOADS_PATH: '/Downloads',
DOWNLOADS_TIMEOUT: 5000,
@@ -6150,6 +6150,14 @@ const CONST = {
description: 'workspace.upgrade.reportFields.description' as const,
icon: 'Pencil',
},
+ categories: {
+ id: 'categories' as const,
+ alias: 'categories',
+ name: 'Categories',
+ title: 'workspace.upgrade.categories.title' as const,
+ description: 'workspace.upgrade.categories.description' as const,
+ icon: 'FolderOpen',
+ },
[this.POLICY.CONNECTIONS.NAME.NETSUITE]: {
id: this.POLICY.CONNECTIONS.NAME.NETSUITE,
alias: 'netsuite',
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index d8f8b0f91105..a96b5f17ba2e 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -156,7 +156,11 @@ const ROUTES = {
SETTINGS_ABOUT: 'settings/about',
SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links',
SETTINGS_WALLET: 'settings/wallet',
- SETTINGS_WALLET_VERIFY_ACCOUNT: {route: 'settings/wallet/verify', getRoute: (backTo?: string) => getUrlWithBackToParam('settings/wallet/verify', backTo)},
+ SETTINGS_WALLET_VERIFY_ACCOUNT: {
+ route: 'settings/wallet/verify',
+ getRoute: (backTo?: string, forwardTo?: string) =>
+ getUrlWithBackToParam(forwardTo ? `settings/wallet/verify?forwardTo=${encodeURIComponent(forwardTo)}` : 'settings/wallet/verify', backTo),
+ },
SETTINGS_WALLET_DOMAINCARD: {
route: 'settings/wallet/card/:cardID?',
getRoute: (cardID: string) => `settings/wallet/card/${cardID}` as const,
@@ -471,6 +475,11 @@ const ROUTES = {
getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') =>
getUrlWithBackToParam(`${action as string}/${iouType as string}/attendees/${transactionID}/${reportID}`, backTo),
},
+ MONEY_REQUEST_UPGRADE: {
+ route: ':action/:iouType/upgrade/:transactionID/:reportID',
+ getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') =>
+ getUrlWithBackToParam(`${action as string}/${iouType as string}/upgrade/${transactionID}/${reportID}`, backTo),
+ },
SETTINGS_TAGS_ROOT: {
route: 'settings/:policyID/tags',
getRoute: (policyID: string, backTo = '') => getUrlWithBackToParam(`settings/${policyID}/tags`, backTo),
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 5fd64b0fc0d0..092a5c28f07a 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -199,6 +199,7 @@ const SCREENS = {
HOLD: 'Money_Request_Hold_Reason',
STEP_CONFIRMATION: 'Money_Request_Step_Confirmation',
START: 'Money_Request_Start',
+ STEP_UPGRADE: 'Money_Request_Step_Upgrade',
STEP_AMOUNT: 'Money_Request_Step_Amount',
STEP_CATEGORY: 'Money_Request_Step_Category',
STEP_CURRENCY: 'Money_Request_Step_Currency',
diff --git a/src/components/EmptyStateComponent/index.tsx b/src/components/EmptyStateComponent/index.tsx
index 81a31174a2ce..6d51d1b3c5c3 100644
--- a/src/components/EmptyStateComponent/index.tsx
+++ b/src/components/EmptyStateComponent/index.tsx
@@ -99,7 +99,7 @@ function EmptyStateComponent({
{title}
{typeof subtitle === 'string' ? {subtitle} : subtitle}
- {buttons?.map(({buttonText, buttonAction, success}, index) => (
+ {buttons?.map(({buttonText, buttonAction, success, icon, isDisabled}, index) => (
))}
diff --git a/src/components/EmptyStateComponent/types.ts b/src/components/EmptyStateComponent/types.ts
index 354141ae672c..a3778459b2e6 100644
--- a/src/components/EmptyStateComponent/types.ts
+++ b/src/components/EmptyStateComponent/types.ts
@@ -9,7 +9,7 @@ import type IconAsset from '@src/types/utils/IconAsset';
type ValidSkeletons = typeof SearchRowSkeleton | typeof TableRowSkeleton;
type MediaTypes = ValueOf;
-type Button = {buttonText?: string; buttonAction?: () => void; success?: boolean};
+type Button = {buttonText?: string; buttonAction?: () => void; success?: boolean; icon?: IconAsset; isDisabled?: boolean};
type SharedProps = {
SkeletonComponent: ValidSkeletons;
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx
index 36586b09e514..96bdf8e9e1e8 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx
@@ -4,9 +4,9 @@ import isEmpty from 'lodash/isEmpty';
import React from 'react';
import {StyleSheet} from 'react-native';
import type {TextStyle} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html';
import {TNodeChildrenRenderer} from 'react-native-render-html';
-import {usePersonalDetails} from '@components/OnyxProvider';
import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext';
import Text from '@components/Text';
import UserDetailsTooltip from '@components/UserDetailsTooltip';
@@ -20,6 +20,7 @@ import Navigation from '@libs/Navigation/Navigation';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as ReportUtils from '@libs/ReportUtils';
import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {Route} from '@src/ROUTES';
import asMutable from '@src/types/utils/asMutable';
@@ -31,7 +32,7 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const htmlAttribAccountID = tnode.attributes.accountid;
- const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
const htmlAttributeAccountID = tnode.attributes.accountid;
let accountID: number;
@@ -56,7 +57,7 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona
return displayText.split('@').at(0);
};
- if (!isEmpty(htmlAttribAccountID)) {
+ if (!isEmpty(htmlAttribAccountID) && personalDetails?.[htmlAttribAccountID]) {
const user = personalDetails[htmlAttribAccountID];
accountID = parseInt(htmlAttribAccountID, 10);
mentionDisplayText = LocalePhoneNumber.formatPhoneNumber(user?.login ?? '') || PersonalDetailsUtils.getDisplayNameOrDefault(user);
diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx
index 0fe2a1542ca3..ccf12aa4ce24 100644
--- a/src/components/LHNOptionsList/OptionRowLHNData.tsx
+++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx
@@ -38,7 +38,10 @@ function OptionRowLHNData({
const optionItemRef = useRef();
const shouldDisplayViolations = ReportUtils.shouldDisplayViolationsRBRInLHN(fullReport, transactionViolations);
- const shouldDisplayReportViolations = ReportUtils.isReportOwner(fullReport) && ReportUtils.hasReportViolations(reportID);
+ const isSettled = ReportUtils.isSettled(fullReport);
+ const shouldDisplayReportViolations = !isSettled && ReportUtils.isReportOwner(fullReport) && ReportUtils.hasReportViolations(reportID);
+ // We only want to show RBR for expense reports with transaction violations not for transaction threads reports.
+ const doesExpenseReportHasViolations = ReportUtils.isExpenseReport(fullReport) && !isSettled && ReportUtils.hasViolations(reportID, transactionViolations, true);
const optionItem = useMemo(() => {
// Note: ideally we'd have this as a dependent selector in onyx!
@@ -49,7 +52,7 @@ function OptionRowLHNData({
preferredLocale: preferredLocale ?? CONST.LOCALES.DEFAULT,
policy,
parentReportAction,
- hasViolations: !!shouldDisplayViolations || shouldDisplayReportViolations,
+ hasViolations: !!shouldDisplayViolations || shouldDisplayReportViolations || doesExpenseReportHasViolations,
lastMessageTextFromReport,
transactionViolations,
invoiceReceiverPolicy,
diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx
index 7fb5533fd172..19703f7a3c92 100644
--- a/src/components/MenuItem.tsx
+++ b/src/components/MenuItem.tsx
@@ -224,6 +224,9 @@ type MenuItemBaseProps = {
/** Whether the secondary right avatar should show as a subscript */
shouldShowSubscriptRightAvatar?: boolean;
+ /** Whether the secondary avatar should show as a subscript */
+ shouldShowSubscriptAvatar?: boolean;
+
/** Affects avatar size */
viewMode?: ValueOf;
@@ -342,9 +345,21 @@ type MenuItemBaseProps = {
/** Should use auto width for the icon container. */
shouldIconUseAutoWidthStyle?: boolean;
+
+ /** Should break word for room title */
+ shouldBreakWord?: boolean;
};
type MenuItemProps = (IconProps | AvatarProps | NoIcon) & MenuItemBaseProps;
+
+const getSubscriptpAvatarBackgroundColor = (isHovered: boolean, isPressed: boolean, hoveredBackgroundColor: string, pressedBackgroundColor: string) => {
+ if (isPressed) {
+ return pressedBackgroundColor;
+ }
+ if (isHovered) {
+ return hoveredBackgroundColor;
+ }
+};
function MenuItem(
{
interactive = true,
@@ -407,6 +422,7 @@ function MenuItem(
floatRightAvatars = [],
floatRightAvatarSize,
shouldShowSubscriptRightAvatar = false,
+ shouldShowSubscriptAvatar: shouldShowSubscriptAvatarProp = false,
avatarSize = CONST.AVATAR_SIZE.DEFAULT,
isSmallAvatarSubscriptMenu = false,
brickRoadIndicator,
@@ -444,6 +460,7 @@ function MenuItem(
shouldShowSelectedItemCheck = false,
onHideTooltip,
shouldIconUseAutoWidthStyle = false,
+ shouldBreakWord = false,
}: MenuItemProps,
ref: PressableRef,
) {
@@ -457,7 +474,7 @@ function MenuItem(
const isDeleted = style && Array.isArray(style) ? style.includes(styles.offlineFeedback.deleted) : false;
const descriptionVerticalMargin = shouldShowDescriptionOnTop ? styles.mb1 : styles.mt1;
const fallbackAvatarSize = viewMode === CONST.OPTION_MODE.COMPACT ? CONST.AVATAR_SIZE.SMALL : CONST.AVATAR_SIZE.DEFAULT;
- const firstIcon = floatRightAvatars.at(0);
+ const firstRightIcon = floatRightAvatars.at(0);
const combinedTitleTextStyle = StyleUtils.combineStyles(
[
styles.flexShrink1,
@@ -469,9 +486,13 @@ function MenuItem(
interactive && disabled ? {...styles.userSelectNone} : {},
styles.ltr,
isDeleted ? styles.offlineFeedback.deleted : {},
+ shouldBreakWord ? styles.breakWord : {},
],
titleStyle ?? {},
);
+ const shouldShowAvatar = !!icon && Array.isArray(icon);
+ const firstIcon = Array.isArray(icon) && !!icon.length ? icon.at(0) : undefined;
+ const shouldShowSubscriptAvatar = shouldShowSubscriptAvatarProp && !!firstIcon;
const descriptionTextStyles = StyleUtils.combineStyles([
styles.textLabelSupporting,
icon && !Array.isArray(icon) ? styles.ml3 : {},
@@ -621,7 +642,7 @@ function MenuItem(
)}
- {!!icon && Array.isArray(icon) && (
+ {shouldShowAvatar && !shouldShowSubscriptAvatar && (
)}
+ {shouldShowAvatar && shouldShowSubscriptAvatar && (
+
+ )}
{!icon && shouldPutLeftPaddingWhenNoIcon && (
{subtitle}
)}
- {floatRightAvatars?.length > 0 && !!firstIcon && (
+ {floatRightAvatars?.length > 0 && !!firstRightIcon && (
{shouldShowSubscriptRightAvatar ? (
diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
index cd9ed19a31ee..62c1ed22b42c 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
+++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
@@ -106,6 +106,8 @@ function MoneyRequestPreviewContent({
currency: requestCurrency,
comment: requestComment,
merchant,
+ tag,
+ category,
} = useMemo>(() => ReportUtils.getTransactionDetails(transaction) ?? {}, [transaction]);
const description = truncate(StringUtils.lineBreaksToSpaces(requestComment), {length: CONST.REQUEST_PREVIEW.MAX_LENGTH});
@@ -145,6 +147,7 @@ function MoneyRequestPreviewContent({
// When there are no settled transactions in duplicates, show the "Keep this one" button
const shouldShowKeepButton = !!(allDuplicates.length && duplicates.length && allDuplicates.length === duplicates.length);
+ const shouldShowCategoryOrTag = !!tag || !!category;
const shouldShowRBR = hasNoticeTypeViolations || hasWarningTypeViolations || hasViolations || hasFieldErrors || (!isFullySettled && !isFullyApproved && isOnHold);
const showCashOrCard = isCardTransaction ? translate('iou.card') : translate('iou.cash');
// We don't use isOnHold because it's true for duplicated transaction too and we only want to show hold message if the transaction is truly on hold
@@ -297,7 +300,11 @@ function MoneyRequestPreviewContent({
// Clear the draft before selecting a different expense to prevent merging fields from the previous expense
// (e.g., category, tag, tax) that may be not enabled/available in the new expense's policy.
Transaction.abandonReviewDuplicateTransactions();
- const comparisonResult = TransactionUtils.compareDuplicateTransactionFields(reviewingTransactionID, transaction?.reportID ?? '');
+ const comparisonResult = TransactionUtils.compareDuplicateTransactionFields(
+ reviewingTransactionID,
+ transaction?.reportID ?? '',
+ transaction?.transactionID ?? reviewingTransactionID,
+ );
Transaction.setReviewDuplicatesKey({...comparisonResult.keep, duplicates, transactionID: transaction?.transactionID ?? '', reportID: transaction?.reportID});
if ('merchant' in comparisonResult.change) {
@@ -427,6 +434,43 @@ function MoneyRequestPreviewContent({
)}
+ {shouldShowCategoryOrTag && }
+ {shouldShowCategoryOrTag && (
+
+ {!!category && (
+
+
+
+ {category}
+
+
+ )}
+ {!!tag && (
+
+
+
+ {tag}
+
+
+ )}
+
+ )}
diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx
index 1cbfee0a0524..03f143f5c2a0 100644
--- a/src/components/ReportActionItem/ReportPreview.tsx
+++ b/src/components/ReportActionItem/ReportPreview.tsx
@@ -344,7 +344,7 @@ function ReportPreview({
const shouldShowSettlementButton = (shouldShowPayButton || shouldShowApproveButton) && !showRTERViolationMessage && !shouldShowBrokenConnectionViolation;
const shouldPromptUserToAddBankAccount = ReportUtils.hasMissingPaymentMethod(userWallet, iouReportID) || ReportUtils.hasMissingInvoiceBankAccount(iouReportID);
- const shouldShowRBR = hasErrors;
+ const shouldShowRBR = hasErrors && !iouSettled;
/*
Show subtitle if at least one of the expenses is not being smart scanned, and either:
@@ -487,7 +487,6 @@ function ReportPreview({
fill={theme.danger}
/>
)}
-
{!shouldShowRBR && shouldPromptUserToAddBankAccount && (
& {
- text: string;
- value: string;
- isCannedQuery: boolean;
- onSubmit: () => void;
- setValue: (input: string) => void;
-};
-
-function HeaderWrapper({icon, children, text, value, isCannedQuery, onSubmit, setValue}: HeaderWrapperProps) {
- const styles = useThemeStyles();
- // If the icon is present, the header bar should be taller and use different font.
- const isCentralPaneSettings = !!icon;
-
- return (
-
- {isCannedQuery ? (
-
- {!!icon && (
-
- )}
- {text}} />
- {children}
-
- ) : (
-
-
-
- )}
-
- );
-}
-
-type SearchPageHeaderProps = {
- queryJSON: SearchQueryJSON;
- hash: number;
-};
+type SearchPageHeaderProps = {queryJSON: SearchQueryJSON};
type SearchHeaderOptionValue = DeepValueOf | undefined;
-type HeaderContent = {
- icon: IconAsset;
- titleText: TranslationPaths;
-};
-
-function getHeaderContent(type: SearchDataTypes): HeaderContent {
- switch (type) {
- case CONST.SEARCH.DATA_TYPES.INVOICE:
- return {icon: Illustrations.EnvelopeReceipt, titleText: 'workspace.common.invoices'};
- case CONST.SEARCH.DATA_TYPES.TRIP:
- return {icon: Illustrations.Luggage, titleText: 'travel.trips'};
- case CONST.SEARCH.DATA_TYPES.CHAT:
- return {icon: Illustrations.CommentBubblesBlue, titleText: 'common.chats'};
- case CONST.SEARCH.DATA_TYPES.EXPENSE:
- default:
- return {icon: Illustrations.MoneyReceipts, titleText: 'common.expenses'};
- }
-}
-
-function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) {
+function SearchPageHeader({queryJSON}: SearchPageHeaderProps) {
const {translate} = useLocalize();
const theme = useTheme();
const styles = useThemeStyles();
@@ -136,19 +54,10 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) {
const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false);
const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false);
- const {status, type} = queryJSON;
- const isCannedQuery = SearchQueryUtils.isCannedSearchQuery(queryJSON);
- const headerText = isCannedQuery ? translate(getHeaderContent(type).titleText) : SearchQueryUtils.buildUserReadableQueryString(queryJSON, personalDetails, cardList, reports, taxRates);
- const [inputValue, setInputValue] = useState(headerText);
-
- useEffect(() => {
- setInputValue(headerText);
- }, [headerText]);
+ const {status, hash} = queryJSON;
const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {});
- const headerIcon = getHeaderContent(type).icon;
-
const handleDeleteExpenses = () => {
if (selectedTransactionsKeys.length === 0) {
return;
@@ -182,7 +91,7 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) {
return;
}
- const reportIDList = selectedReports?.filter((report) => !!report) ?? [];
+ const reportIDList = selectedReports.filter((report): report is string => !!report) ?? [];
SearchActions.exportSearchItemsToCSV(
{query: status, jsonQuery: JSON.stringify(queryJSON), reportIDList, transactionIDList: selectedTransactionsKeys, policyIDs: [activeWorkspaceID ?? '']},
() => {
@@ -327,41 +236,18 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) {
return null;
}
- const onPress = () => {
+ const onFiltersButtonPress = () => {
const filterFormValues = SearchQueryUtils.buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTagsLists, currencyList, personalDetails, cardList, reports, taxRates);
SearchActions.updateAdvancedFilters(filterFormValues);
Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS);
};
- const onSubmit = () => {
- if (!inputValue) {
- return;
- }
- const inputQueryJSON = SearchQueryUtils.buildSearchQueryJSON(inputValue);
- if (inputQueryJSON) {
- // Todo traverse the tree to update all the display values into id values; this is only temporary until autocomplete code from SearchRouter is implement here
- // After https://github.com/Expensify/App/pull/51633 is merged, autocomplete functionality will be included into this component, and `getFindIDFromDisplayValue` can be removed
- const computeNodeValueFn = SearchQueryUtils.getFindIDFromDisplayValue(cardList, taxRates);
- const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(inputQueryJSON, computeNodeValueFn);
- const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery);
- SearchActions.clearAllFilters();
- Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query}));
- } else {
- Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} user query failed to parse`, inputValue, false);
- }
- };
+ const isCannedQuery = SearchQueryUtils.isCannedSearchQuery(queryJSON);
return (
<>
-
+
{headerButtonsOptions.length > 0 ? (
null}
@@ -377,11 +263,10 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) {
innerStyles={!isCannedQuery && [styles.searchRouterInputResults, styles.borderNone]}
text={translate('search.filtersHeader')}
icon={Expensicons.Filters}
- onPress={onPress}
+ onPress={onFiltersButtonPress}
/>
)}
- {isCannedQuery && }
-
+
getAllTaxRates(), []);
+
+ const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({});
+ // The actual input text that the user sees
+ const [textInputValue, setTextInputValue] = useState(' '); // initial empty space to avoid quick flash of placeholder text
+ // The input text that was last used for autocomplete; needed for the SearchRouterList when browsing list via arrow keys
+ const [autocompleteQueryValue, setAutocompleteQueryValue] = useState(textInputValue);
+
+ const [isAutocompleteListVisible, setIsAutocompleteListVisible] = useState(false);
+ const listRef = useRef(null);
+
+ const {type, inputQuery: originalInputQuery} = queryJSON;
+ const isCannedQuery = SearchQueryUtils.isCannedSearchQuery(queryJSON);
+ const headerText = isCannedQuery ? translate(getHeaderContent(type).titleText) : '';
+ const queryText = SearchQueryUtils.buildUserReadableQueryString(queryJSON, personalDetails, reports, taxRates);
+
+ useEffect(() => {
+ setTextInputValue(queryText);
+ }, [queryText]);
+
+ useEffect(() => {
+ const substitutionsMap = buildSubstitutionsMap(originalInputQuery, personalDetails, reports, taxRates);
+ setAutocompleteSubstitutions(substitutionsMap);
+ }, [originalInputQuery, personalDetails, reports, taxRates]);
+
+ const onSearchQueryChange = useCallback(
+ (userQuery: string) => {
+ const updatedUserQuery = SearchAutocompleteUtils.getAutocompleteQueryWithComma(textInputValue, userQuery);
+ setTextInputValue(updatedUserQuery);
+ setAutocompleteQueryValue(updatedUserQuery);
+
+ const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions);
+ setAutocompleteSubstitutions(updatedSubstitutionsMap);
+
+ if (updatedUserQuery || textInputValue.length > 0) {
+ listRef.current?.updateAndScrollToFocusedIndex(0);
+ } else {
+ listRef.current?.updateAndScrollToFocusedIndex(-1);
+ }
+ },
+ [autocompleteSubstitutions, setTextInputValue, textInputValue],
+ );
+
+ const submitSearch = useCallback(
+ (queryString: SearchQueryString) => {
+ if (!queryString) {
+ return;
+ }
+
+ const cleanedQueryString = getQueryWithSubstitutions(queryString, autocompleteSubstitutions);
+ const userQueryJSON = SearchQueryUtils.buildSearchQueryJSON(cleanedQueryString);
+
+ if (!userQueryJSON) {
+ Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} user query failed to parse`, {}, false);
+ return;
+ }
+
+ const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(userQueryJSON, SearchQueryUtils.getUpdatedAmountValue);
+ const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery);
+
+ Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query}));
+
+ if (query !== originalInputQuery) {
+ SearchActions.clearAllFilters();
+ setTextInputValue('');
+ setAutocompleteQueryValue('');
+ setIsAutocompleteListVisible(false);
+ }
+ },
+ [autocompleteSubstitutions, originalInputQuery],
+ );
+
+ const onListItemPress = (item: OptionData | SearchQueryItem) => {
+ if (!isSearchQueryItem(item)) {
+ return;
+ }
+
+ if (!item.searchQuery) {
+ return;
+ }
+
+ if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) {
+ const trimmedUserSearchQuery = SearchAutocompleteUtils.getQueryWithoutAutocompletedPart(textInputValue);
+ onSearchQueryChange(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `);
+
+ if (item.text && item.autocompleteID) {
+ const substitutions = {...autocompleteSubstitutions, [item.text]: item.autocompleteID};
+
+ setAutocompleteSubstitutions(substitutions);
+ }
+ } else if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH) {
+ submitSearch(item.searchQuery);
+ }
+ };
+
+ const onListItemFocus = (focusedItem: SearchQueryItem) => {
+ if (!focusedItem.searchQuery) {
+ return;
+ }
+
+ const trimmedUserSearchQuery = SearchAutocompleteUtils.getQueryWithoutAutocompletedPart(textInputValue);
+ setTextInputValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(focusedItem.searchQuery)} `);
+
+ if (focusedItem.autocompleteID && focusedItem.text) {
+ const substitutions = {...autocompleteSubstitutions, [focusedItem.text]: focusedItem.autocompleteID};
+
+ setAutocompleteSubstitutions(substitutions);
+ }
+ };
+
+ const hideAutocompleteList = () => setIsAutocompleteListVisible(false);
+ const showAutocompleteList = () => setIsAutocompleteListVisible(true);
+
+ if (isCannedQuery) {
+ const headerIcon = getHeaderContent(type).icon;
+
+ return (
+
+
+
+ {headerText}} />
+
+ {children}
+
+
+
+
+ );
+ }
+
+ const searchQueryItem = textInputValue
+ ? {
+ text: textInputValue,
+ singleIcon: Expensicons.MagnifyingGlass,
+ searchQuery: textInputValue,
+ itemStyle: styles.activeComponentBG,
+ keyForList: 'findItem',
+ searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH,
+ }
+ : undefined;
+
+ const isHeaderInputActive = isAutocompleteListVisible;
+
+ // we need `- BORDER_WIDTH` to achieve the effect that the input will not "jump"
+ const popoverHorizontalPosition = 12 - BORDER_WIDTH;
+ const autocompleteInputStyle = isHeaderInputActive
+ ? [
+ styles.border,
+ styles.borderRadiusComponentLarge,
+ styles.pAbsolute,
+ styles.pt2,
+ {top: 8 - BORDER_WIDTH, left: popoverHorizontalPosition, right: popoverHorizontalPosition},
+ {boxShadow: variables.popoverMenuShadow},
+ ]
+ : [styles.pt4];
+ const inputWrapperStyle = isHeaderInputActive ? styles.ph2 : null;
+
+ return (
+
+
+ {
+ submitSearch(textInputValue);
+ }}
+ autoFocus={false}
+ onFocus={showAutocompleteList}
+ onBlur={hideAutocompleteList}
+ wrapperStyle={[styles.searchRouterInputResults, styles.br2]}
+ wrapperFocusedStyle={styles.searchRouterInputResultsFocused}
+ outerWrapperStyle={inputWrapperStyle}
+ rightComponent={children}
+ routerListRef={listRef}
+ />
+
+
+
+
+
+ );
+}
+
+SearchPageHeaderInput.displayName = 'SearchPageHeaderInput';
+
+export default SearchPageHeaderInput;
diff --git a/src/components/Search/SearchRouter/SearchButton.tsx b/src/components/Search/SearchRouter/SearchButton.tsx
index 90699e951998..e69a11c30da2 100644
--- a/src/components/Search/SearchRouter/SearchButton.tsx
+++ b/src/components/Search/SearchRouter/SearchButton.tsx
@@ -1,5 +1,5 @@
-import React from 'react';
-import type {StyleProp, ViewStyle} from 'react-native';
+import React, {useRef} from 'react';
+import type {StyleProp, View, ViewStyle} from 'react-native';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import {PressableWithoutFeedback} from '@components/Pressable';
@@ -22,14 +22,19 @@ function SearchButton({style}: SearchButtonProps) {
const theme = useTheme();
const {translate} = useLocalize();
const {openSearchRouter} = useSearchRouterContext();
+ const pressableRef = useRef(null);
return (
{
+ pressableRef?.current?.blur();
+
Timing.start(CONST.TIMING.OPEN_SEARCH);
Performance.markStart(CONST.TIMING.OPEN_SEARCH);
diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx
index 87cb065d5c88..8b98858405c2 100644
--- a/src/components/Search/SearchRouter/SearchRouter.tsx
+++ b/src/components/Search/SearchRouter/SearchRouter.tsx
@@ -1,50 +1,70 @@
import {useNavigationState} from '@react-navigation/native';
-import {Str} from 'expensify-common';
-import isEmpty from 'lodash/isEmpty';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import type {TextInputProps} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import * as Expensicons from '@components/Icon/Expensicons';
import {usePersonalDetails} from '@components/OnyxProvider';
import {useOptionsList} from '@components/OptionListContextProvider';
-import type {SearchAutocompleteQueryRange, SearchQueryString} from '@components/Search/types';
+import type {SearchQueryString} from '@components/Search/types';
+import {isSearchQueryItem} from '@components/SelectionList/Search/SearchQueryListItem';
+import type {SearchQueryItem} from '@components/SelectionList/Search/SearchQueryListItem';
import type {SelectionListHandle} from '@components/SelectionList/types';
-import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useDebouncedState from '@hooks/useDebouncedState';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useLocalize from '@hooks/useLocalize';
-import usePolicy from '@hooks/usePolicy';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
-import * as CardUtils from '@libs/CardUtils';
import * as OptionsListUtils from '@libs/OptionsListUtils';
+import type {SearchOption} from '@libs/OptionsListUtils';
import {getAllTaxRates} from '@libs/PolicyUtils';
import type {OptionData} from '@libs/ReportUtils';
-import {
- getAutocompleteCategories,
- getAutocompleteRecentCategories,
- getAutocompleteRecentTags,
- getAutocompleteTags,
- getAutocompleteTaxList,
- parseForAutocomplete,
-} from '@libs/SearchAutocompleteUtils';
+import * as SearchAutocompleteUtils from '@libs/SearchAutocompleteUtils';
import * as SearchQueryUtils from '@libs/SearchQueryUtils';
import Navigation from '@navigation/Navigation';
import variables from '@styles/variables';
-import * as Report from '@userActions/Report';
+import * as ReportUserActions from '@userActions/Report';
import Timing from '@userActions/Timing';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type PersonalDetails from '@src/types/onyx/PersonalDetails';
+import type Report from '@src/types/onyx/Report';
import {getQueryWithSubstitutions} from './getQueryWithSubstitutions';
import type {SubstitutionMap} from './getQueryWithSubstitutions';
import {getUpdatedSubstitutionsMap} from './getUpdatedSubstitutionsMap';
import SearchRouterInput from './SearchRouterInput';
import SearchRouterList from './SearchRouterList';
-import type {AutocompleteItemData} from './SearchRouterList';
+
+function getContextualSearchAutocompleteKey(item: SearchQueryItem) {
+ if (item.roomType === CONST.SEARCH.DATA_TYPES.INVOICE) {
+ return `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${item.searchQuery}`;
+ }
+ if (item.roomType === CONST.SEARCH.DATA_TYPES.CHAT) {
+ return `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${item.searchQuery}`;
+ }
+}
+
+function getContextualSearchQuery(item: SearchQueryItem) {
+ const baseQuery = `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${item.roomType}`;
+ let additionalQuery = '';
+
+ switch (item.roomType) {
+ case CONST.SEARCH.DATA_TYPES.EXPENSE:
+ case CONST.SEARCH.DATA_TYPES.INVOICE:
+ additionalQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID}:${item.policyID}`;
+ if (item.roomType === CONST.SEARCH.DATA_TYPES.INVOICE && item.autocompleteID) {
+ additionalQuery += ` ${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${SearchQueryUtils.sanitizeSearchValue(item.searchQuery ?? '')}`;
+ }
+ break;
+ case CONST.SEARCH.DATA_TYPES.CHAT:
+ default:
+ additionalQuery = ` ${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${SearchQueryUtils.sanitizeSearchValue(item.searchQuery ?? '')}`;
+ break;
+ }
+ return baseQuery + additionalQuery;
+}
type SearchRouterProps = {
onRouterClose: () => void;
@@ -57,13 +77,20 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps)
const [betas] = useOnyx(ONYXKEYS.BETAS);
const [recentSearches] = useOnyx(ONYXKEYS.RECENT_SEARCHES);
const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false});
- const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]);
const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({});
+ const personalDetails = usePersonalDetails();
+ const [reports = {}] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
+ const taxRates = getAllTaxRates();
+
const {shouldUseNarrowLayout} = useResponsiveLayout();
const listRef = useRef(null);
+ // The actual input text that the user sees
const [textInputValue, debouncedInputValue, setTextInputValue] = useDebouncedState('', 500);
+ // The input text that was last used for autocomplete; needed for the SearchRouterList when browsing list via arrow keys
+ const [autocompleteQueryValue, setAutocompleteQueryValue] = useState(textInputValue);
+
const contextualReportID = useNavigationState, string | undefined>((state) => {
return state?.routes.at(-1)?.params?.reportID;
});
@@ -105,256 +132,105 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps)
return searchOptions.recentReports.slice(0, 10);
}
- const reports: OptionData[] = [...filteredOptions.recentReports, ...filteredOptions.personalDetails];
+ const reportOptions: OptionData[] = [...filteredOptions.recentReports, ...filteredOptions.personalDetails];
if (filteredOptions.userToInvite) {
- reports.push(filteredOptions.userToInvite);
+ reportOptions.push(filteredOptions.userToInvite);
}
- return reports.slice(0, 10);
+ return reportOptions.slice(0, 10);
}, [debouncedInputValue, filteredOptions, searchOptions]);
- const contextualReportData = contextualReportID ? searchOptions.recentReports?.find((option) => option.reportID === contextualReportID) : undefined;
-
- const {activeWorkspaceID} = useActiveWorkspace();
- const policy = usePolicy(activeWorkspaceID);
-
- const typeAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES);
- const statusAutocompleteList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP});
- const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE);
- const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST);
- const cardAutocompleteList = Object.values(cardList);
- const personalDetailsForParticipants = usePersonalDetails();
-
- const participantsAutocompleteList = useMemo(
- () =>
- Object.values(personalDetailsForParticipants)
- .filter((details): details is NonNullable => !!(details && details?.login))
- .map((details) => ({
- name: details.displayName ?? Str.removeSMSDomain(details.login ?? ''),
- accountID: details?.accountID.toString(),
- })),
- [personalDetailsForParticipants],
- );
- const allTaxRates = getAllTaxRates();
- const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(allTaxRates, policy), [policy, allTaxRates]);
- const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
- const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES);
- const categoryAutocompleteList = useMemo(() => {
- return getAutocompleteCategories(allPolicyCategories, activeWorkspaceID);
- }, [activeWorkspaceID, allPolicyCategories]);
- const recentCategoriesAutocompleteList = useMemo(() => {
- return getAutocompleteRecentCategories(allRecentCategories, activeWorkspaceID);
- }, [activeWorkspaceID, allRecentCategories]);
-
- const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST);
- const currencyAutocompleteList = Object.keys(currencyList ?? {});
- const [recentCurrencyAutocompleteList] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES);
-
- const [allPoliciesTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
- const [allRecentTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS);
- const tagAutocompleteList = useMemo(() => {
- return getAutocompleteTags(allPoliciesTags, activeWorkspaceID);
- }, [activeWorkspaceID, allPoliciesTags]);
- const recentTagsAutocompleteList = getAutocompleteRecentTags(allRecentTags, activeWorkspaceID);
-
- const updateAutocomplete = useCallback(
- (autocompleteValue: string, ranges: SearchAutocompleteQueryRange[], autocompleteType?: ValueOf) => {
- const alreadyAutocompletedKeys: string[] = [];
- ranges.forEach((range) => {
- if (!autocompleteType || range.key !== autocompleteType) {
- return;
- }
- alreadyAutocompletedKeys.push(range.value.toLowerCase());
- });
-
- let filteredAutocompleteSuggestions: AutocompleteItemData[] | undefined;
- switch (autocompleteType) {
- case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG: {
- const autocompleteList = autocompleteValue ? tagAutocompleteList : recentTagsAutocompleteList ?? [];
- const filteredTags = autocompleteList
- .filter((tag) => tag.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tag))
- .sort()
- .slice(0, 10);
-
- filteredAutocompleteSuggestions = filteredTags.map((tagName) => ({
- filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG,
- text: tagName,
- }));
- break;
- }
- case CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY: {
- const autocompleteList = autocompleteValue ? categoryAutocompleteList : recentCategoriesAutocompleteList;
- const filteredCategories = autocompleteList
- .filter((category) => {
- return category.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(category.toLowerCase());
- })
- .sort()
- .slice(0, 10);
-
- filteredAutocompleteSuggestions = filteredCategories.map((categoryName) => ({
- filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY,
- text: categoryName,
- }));
- break;
- }
- case CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY: {
- const autocompleteList = autocompleteValue ? currencyAutocompleteList : recentCurrencyAutocompleteList ?? [];
- const filteredCurrencies = autocompleteList
- .filter((currency) => currency.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(currency.toLowerCase()))
- .sort()
- .slice(0, 10);
-
- filteredAutocompleteSuggestions = filteredCurrencies.map((currencyName) => ({
- filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY,
- text: currencyName,
- }));
- break;
- }
- case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE: {
- const filteredTaxRates = taxAutocompleteList
- .filter((tax) => tax.taxRateName.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tax.taxRateName.toLowerCase()))
- .sort()
- .slice(0, 10);
- filteredAutocompleteSuggestions = filteredTaxRates.map((tax) => ({
- filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE,
- text: tax.taxRateName,
- autocompleteID: tax.taxRateIds.join(','),
- }));
-
- break;
- }
- case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: {
- const filteredParticipants = participantsAutocompleteList
- .filter(
- (participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase()),
- )
- .sort()
- .slice(0, 10);
- filteredAutocompleteSuggestions = filteredParticipants.map((participant) => ({
- filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM,
- text: participant.name,
- autocompleteID: participant.accountID,
- }));
- break;
- }
- case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: {
- const filteredParticipants = participantsAutocompleteList
- .filter(
- (participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase()),
- )
- .sort()
- .slice(0, 10);
- filteredAutocompleteSuggestions = filteredParticipants.map((participant) => ({
- filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO,
- text: participant.name,
- autocompleteID: participant.accountID,
- }));
- break;
- }
- case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN: {
- const filteredChats = searchOptions.recentReports
- .filter((chat) => chat.text?.toLowerCase()?.includes(autocompleteValue.toLowerCase()))
- .sort((chatA, chatB) => (chatA > chatB ? 1 : -1))
- .slice(0, 10);
- filteredAutocompleteSuggestions = filteredChats.map((chat) => ({
- filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.IN,
- text: chat.text ?? '',
- autocompleteID: chat.reportID,
- }));
- break;
- }
- case CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE: {
- const filteredTypes = typeAutocompleteList
- .filter((type) => type.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(type.toLowerCase()))
- .sort();
- filteredAutocompleteSuggestions = filteredTypes.map((type) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE, text: type}));
- break;
- }
- case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: {
- const filteredStatuses = statusAutocompleteList
- .filter((status) => status.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(status))
- .sort()
- .slice(0, 10);
- filteredAutocompleteSuggestions = filteredStatuses.map((status) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS, text: status}));
- break;
- }
- case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE: {
- const filteredExpenseTypes = expenseTypes
- .filter((expenseType) => expenseType.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(expenseType))
- .sort();
-
- filteredAutocompleteSuggestions = filteredExpenseTypes.map((expenseType) => ({
- filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE,
- text: expenseType,
- }));
- break;
- }
- case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID: {
- const filteredCards = cardAutocompleteList
- .filter((card) => card.bank.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(card.bank.toLowerCase()))
- .sort()
- .slice(0, 10);
-
- filteredAutocompleteSuggestions = filteredCards.map((card) => ({
- filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID,
- text: CardUtils.getCardDescription(card.cardID),
- autocompleteID: card.cardID.toString(),
- }));
- break;
- }
- default: {
- filteredAutocompleteSuggestions = undefined;
- }
- }
- setAutocompleteSuggestions(filteredAutocompleteSuggestions);
- },
- [
- tagAutocompleteList,
- recentTagsAutocompleteList,
- categoryAutocompleteList,
- recentCategoriesAutocompleteList,
- currencyAutocompleteList,
- recentCurrencyAutocompleteList,
- taxAutocompleteList,
- participantsAutocompleteList,
- searchOptions.recentReports,
- typeAutocompleteList,
- statusAutocompleteList,
- expenseTypes,
- cardAutocompleteList,
- ],
- );
+ const reportForContextualSearch = contextualReportID ? searchOptions.recentReports?.find((option) => option.reportID === contextualReportID) : undefined;
- const prevUserQueryRef = useRef(null);
useEffect(() => {
- Report.searchInServer(debouncedInputValue.trim());
+ ReportUserActions.searchInServer(debouncedInputValue.trim());
}, [debouncedInputValue]);
- const onSearchChange = useCallback(
- (userQuery: string) => {
- let newUserQuery = userQuery;
- if (autocompleteSuggestions && userQuery.endsWith(',')) {
- newUserQuery = `${userQuery.slice(0, userQuery.length - 1).trim()},`;
+ const sections = [];
+
+ if (reportForContextualSearch && !textInputValue) {
+ const reportQueryValue = reportForContextualSearch.text ?? reportForContextualSearch.alternateText ?? reportForContextualSearch.reportID;
+
+ let roomType: ValueOf = CONST.SEARCH.DATA_TYPES.CHAT;
+ let autocompleteID = reportForContextualSearch.reportID;
+ if (reportForContextualSearch.isInvoiceRoom) {
+ roomType = CONST.SEARCH.DATA_TYPES.INVOICE;
+ const report = reportForContextualSearch as SearchOption;
+ if (report.item && report.item?.invoiceReceiver && report.item.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) {
+ autocompleteID = report.item.invoiceReceiver.accountID.toString();
+ } else {
+ autocompleteID = '';
}
- setTextInputValue(newUserQuery);
- const autocompleteParsedQuery = parseForAutocomplete(newUserQuery);
- updateAutocomplete(autocompleteParsedQuery?.autocomplete?.value ?? '', autocompleteParsedQuery?.ranges ?? [], autocompleteParsedQuery?.autocomplete?.key);
+ }
+ if (reportForContextualSearch.isPolicyExpenseChat) {
+ roomType = CONST.SEARCH.DATA_TYPES.EXPENSE;
+ autocompleteID = reportForContextualSearch.policyID ?? '';
+ }
+
+ sections.push({
+ data: [
+ {
+ text: `${translate('search.searchIn')} ${reportForContextualSearch.text ?? reportForContextualSearch.alternateText}`,
+ singleIcon: Expensicons.MagnifyingGlass,
+ searchQuery: reportQueryValue,
+ autocompleteID,
+ itemStyle: styles.activeComponentBG,
+ keyForList: 'contextualSearch',
+ searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION,
+ roomType,
+ policyID: reportForContextualSearch.policyID,
+ },
+ ],
+ });
+ }
+
+ const recentSearchesData = sortedRecentSearches?.slice(0, 5).map(({query, timestamp}) => {
+ const searchQueryJSON = SearchQueryUtils.buildSearchQueryJSON(query);
+ return {
+ text: searchQueryJSON ? SearchQueryUtils.buildUserReadableQueryString(searchQueryJSON, personalDetails, reports, taxRates) : query,
+ singleIcon: Expensicons.History,
+ searchQuery: query,
+ keyForList: timestamp,
+ searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH,
+ };
+ });
+
+ if (!textInputValue && recentSearchesData && recentSearchesData.length > 0) {
+ sections.push({title: translate('search.recentSearches'), data: recentSearchesData});
+ }
+
+ const styledRecentReports = recentReports.map((item) => ({...item, pressableStyle: styles.br2, wrapperStyle: [styles.pr3, styles.pl3]}));
+ sections.push({title: translate('search.recentChats'), data: styledRecentReports});
+
+ const searchQueryItem = textInputValue
+ ? {
+ text: textInputValue,
+ singleIcon: Expensicons.MagnifyingGlass,
+ searchQuery: textInputValue,
+ itemStyle: styles.activeComponentBG,
+ keyForList: 'findItem',
+ searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH,
+ }
+ : undefined;
+
+ const onSearchQueryChange = useCallback(
+ (userQuery: string) => {
+ const updatedUserQuery = SearchAutocompleteUtils.getAutocompleteQueryWithComma(textInputValue, userQuery);
+ setTextInputValue(updatedUserQuery);
+ setAutocompleteQueryValue(updatedUserQuery);
const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions);
setAutocompleteSubstitutions(updatedSubstitutionsMap);
- if (newUserQuery || !isEmpty(prevUserQueryRef.current)) {
+ if (updatedUserQuery || textInputValue.length > 0) {
listRef.current?.updateAndScrollToFocusedIndex(0);
} else {
listRef.current?.updateAndScrollToFocusedIndex(-1);
}
-
- // Store the previous newUserQuery
- prevUserQueryRef.current = newUserQuery;
},
- [autocompleteSubstitutions, autocompleteSuggestions, setTextInputValue, updateAutocomplete],
+ [autocompleteSubstitutions, setTextInputValue, textInputValue],
);
- const onSearchSubmit = useCallback(
+ const submitSearch = useCallback(
(queryString: SearchQueryString) => {
const cleanedQueryString = getQueryWithSubstitutions(queryString, autocompleteSubstitutions);
const queryJSON = SearchQueryUtils.buildSearchQueryJSON(cleanedQueryString);
@@ -369,14 +245,63 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps)
Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query}));
setTextInputValue('');
+ setAutocompleteQueryValue('');
},
[autocompleteSubstitutions, onRouterClose, setTextInputValue],
);
- const onAutocompleteSuggestionClick = (autocompleteKey: string, autocompleteID: string) => {
- const substitutions = {...autocompleteSubstitutions, [autocompleteKey]: autocompleteID};
+ const onListItemPress = (item: OptionData | SearchQueryItem) => {
+ if (isSearchQueryItem(item)) {
+ if (!item.searchQuery) {
+ return;
+ }
+
+ if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) {
+ const searchQuery = getContextualSearchQuery(item);
+ onSearchQueryChange(`${searchQuery} `);
+
+ const autocompleteKey = getContextualSearchAutocompleteKey(item);
+ if (autocompleteKey && item.autocompleteID) {
+ const substitutions = {...autocompleteSubstitutions, [autocompleteKey]: item.autocompleteID};
+
+ setAutocompleteSubstitutions(substitutions);
+ }
+ } else if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) {
+ const trimmedUserSearchQuery = SearchAutocompleteUtils.getQueryWithoutAutocompletedPart(textInputValue);
+ onSearchQueryChange(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `);
+
+ if (item.text && item.autocompleteID) {
+ const substitutions = {...autocompleteSubstitutions, [item.text]: item.autocompleteID};
- setAutocompleteSubstitutions(substitutions);
+ setAutocompleteSubstitutions(substitutions);
+ }
+ } else {
+ submitSearch(item.searchQuery);
+ }
+ } else {
+ onRouterClose();
+
+ if (item?.reportID) {
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item?.reportID));
+ } else if ('login' in item) {
+ ReportUserActions.navigateToAndOpenReport(item.login ? [item.login] : [], false);
+ }
+ }
+ };
+
+ const onListItemFocus = (focusedItem: SearchQueryItem) => {
+ if (!focusedItem.searchQuery) {
+ return;
+ }
+
+ const trimmedUserSearchQuery = SearchAutocompleteUtils.getQueryWithoutAutocompletedPart(textInputValue);
+ setTextInputValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(focusedItem.searchQuery)} `);
+
+ if (focusedItem.autocompleteID && focusedItem.text) {
+ const substitutions = {...autocompleteSubstitutions, [focusedItem.text]: focusedItem.autocompleteID};
+
+ setAutocompleteSubstitutions(substitutions);
+ }
};
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => {
@@ -399,9 +324,9 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps)
{
- onSearchSubmit(textInputValue);
+ submitSearch(textInputValue);
}}
caretHidden={shouldHideInputCaret}
routerListRef={listRef}
@@ -412,16 +337,11 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps)
isSearchingForReports={isSearchingForReports}
/>
diff --git a/src/components/Search/SearchRouter/SearchRouterInput.tsx b/src/components/Search/SearchRouter/SearchRouterInput.tsx
index 6b99588a21df..3bd9146cac84 100644
--- a/src/components/Search/SearchRouter/SearchRouterInput.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterInput.tsx
@@ -17,7 +17,7 @@ type SearchRouterInputProps = {
value: string;
/** Callback to update search in SearchRouter */
- updateSearch: (searchTerm: string) => void;
+ onSearchQueryChange: (searchTerm: string) => void;
/** Callback invoked when the user submits the input */
onSubmit?: () => void;
@@ -34,6 +34,12 @@ type SearchRouterInputProps = {
/** Whether the offline message should be shown */
shouldShowOfflineMessage?: boolean;
+ /** Callback to call when the input gets focus */
+ onFocus?: () => void;
+
+ /** Callback to call when the input gets blur */
+ onBlur?: () => void;
+
/** Any additional styles to apply */
wrapperStyle?: StyleProp;
@@ -52,13 +58,15 @@ type SearchRouterInputProps = {
function SearchRouterInput({
value,
- updateSearch,
+ onSearchQueryChange,
onSubmit = () => {},
routerListRef,
isFullWidth,
disabled = false,
shouldShowOfflineMessage = false,
autoFocus = true,
+ onFocus,
+ onBlur,
caretHidden = false,
wrapperStyle,
wrapperFocusedStyle,
@@ -81,7 +89,7 @@ function SearchRouterInput({
{
setIsFocused(true);
routerListRef?.current?.updateExternalTextInputFocus(true);
+ onFocus?.();
}}
onBlur={() => {
setIsFocused(false);
routerListRef?.current?.updateExternalTextInputFocus(false);
+ onBlur?.();
}}
isLoading={!!isSearchingForReports}
/>
diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx
index b16894f65f51..f626a3b34daf 100644
--- a/src/components/Search/SearchRouter/SearchRouterList.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterList.tsx
@@ -1,38 +1,40 @@
-import React, {forwardRef, useCallback} from 'react';
+import {Str} from 'expensify-common';
+import React, {forwardRef, useMemo} from 'react';
import type {ForwardedRef} from 'react';
import {useOnyx} from 'react-native-onyx';
-import type {ValueOf} from 'type-fest';
import * as Expensicons from '@components/Icon/Expensicons';
-import {usePersonalDetails} from '@components/OnyxProvider';
-import type {SearchFilterKey, SearchQueryString} from '@components/Search/types';
+import {useOptionsList} from '@components/OptionListContextProvider';
+import type {SearchFilterKey} from '@components/Search/types';
import SelectionList from '@components/SelectionList';
-import SearchQueryListItem from '@components/SelectionList/Search/SearchQueryListItem';
+import SearchQueryListItem, {isSearchQueryItem} from '@components/SelectionList/Search/SearchQueryListItem';
import type {SearchQueryItem, SearchQueryListItemProps} from '@components/SelectionList/Search/SearchQueryListItem';
import type {SectionListDataType, SelectionListHandle, UserListItemProps} from '@components/SelectionList/types';
import UserListItem from '@components/SelectionList/UserListItem';
+import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useLocalize from '@hooks/useLocalize';
+import usePolicy from '@hooks/usePolicy';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
-import Navigation from '@libs/Navigation/Navigation';
+import * as CardUtils from '@libs/CardUtils';
+import * as OptionsListUtils from '@libs/OptionsListUtils';
import type {SearchOption} from '@libs/OptionsListUtils';
import Performance from '@libs/Performance';
import {getAllTaxRates} from '@libs/PolicyUtils';
import type {OptionData} from '@libs/ReportUtils';
-import {getQueryWithoutAutocompletedPart} from '@libs/SearchAutocompleteUtils';
-import * as SearchQueryUtils from '@libs/SearchQueryUtils';
-import * as ReportUserActions from '@userActions/Report';
+import {
+ getAutocompleteCategories,
+ getAutocompleteRecentCategories,
+ getAutocompleteRecentTags,
+ getAutocompleteTags,
+ getAutocompleteTaxList,
+ parseForAutocomplete,
+} from '@libs/SearchAutocompleteUtils';
import Timing from '@userActions/Timing';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
-import type Report from '@src/types/onyx/Report';
+import type PersonalDetails from '@src/types/onyx/PersonalDetails';
import {getSubstitutionMapKey} from './getQueryWithSubstitutions';
-type SearchQueryItemData = {
- query: string;
- text?: string;
-};
-
type AutocompleteItemData = {
filterKey: SearchFilterKey;
text: string;
@@ -40,35 +42,30 @@ type AutocompleteItemData = {
};
type SearchRouterListProps = {
- /** value of TextInput */
- textInputValue: string;
-
- /** Callback to update text input value along with autocomplete suggestions */
- updateSearchValue: (newValue: string) => void;
-
- /** Callback to update text input value */
- setTextInputValue: (text: string) => void;
+ /** Value of TextInput */
+ autocompleteQueryValue: string;
- /** Recent searches */
- recentSearches: Array | undefined;
+ /** An optional item to always display on the top of the router list */
+ searchQueryItem?: SearchQueryItem;
- /** Recent reports */
- recentReports: OptionData[];
+ /** Any extra sections that should be displayed in the router list */
+ additionalSections?: Array>;
- /** Autocomplete items */
- autocompleteSuggestions: AutocompleteItemData[] | undefined;
+ shouldPreventDefault?: boolean;
- /** Callback to submit query when selecting a list item */
- onSearchSubmit: (query: SearchQueryString) => void;
+ /** Callback to call when an item is clicked/selected */
+ onListItemPress: (item: OptionData | SearchQueryItem) => void;
- /** Context present when opening SearchRouter from a report, invoice or workspace page */
- reportForContextualSearch?: OptionData;
-
- /** Callback to run when user clicks a suggestion item that contains autocomplete data */
- onAutocompleteSuggestionClick: (autocompleteKey: string, autocompleteID: string) => void;
+ /** Callback to call when an item is focused via arrow buttons */
+ onListItemFocus: (item: SearchQueryItem) => void;
+};
- /** Callback to close and clear SearchRouter */
- closeRouter: () => void;
+const defaultListOptions = {
+ userToInvite: null,
+ recentReports: [],
+ personalDetails: [],
+ currentUserOption: null,
+ categoryOptions: [],
};
const setPerformanceTimersEnd = () => {
@@ -76,30 +73,6 @@ const setPerformanceTimersEnd = () => {
Performance.markEnd(CONST.TIMING.OPEN_SEARCH);
};
-function getContextualSearchQuery(item: SearchQueryItem) {
- const baseQuery = `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${item.roomType}`;
- let additionalQuery = '';
-
- switch (item.roomType) {
- case CONST.SEARCH.DATA_TYPES.EXPENSE:
- case CONST.SEARCH.DATA_TYPES.INVOICE:
- additionalQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID}:${item.policyID}`;
- if (item.roomType === CONST.SEARCH.DATA_TYPES.INVOICE && item.autocompleteID) {
- additionalQuery += ` ${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${SearchQueryUtils.sanitizeSearchValue(item.searchQuery ?? '')}`;
- }
- break;
- case CONST.SEARCH.DATA_TYPES.CHAT:
- default:
- additionalQuery = ` ${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${SearchQueryUtils.sanitizeSearchValue(item.searchQuery ?? '')}`;
- break;
- }
- return baseQuery + additionalQuery;
-}
-
-function isSearchQueryItem(item: OptionData | SearchQueryItem): item is SearchQueryItem {
- return 'searchItemType' in item;
-}
-
function isSearchQueryListItem(listItem: UserListItemProps | SearchQueryListItemProps): listItem is SearchQueryListItemProps {
return isSearchQueryItem(listItem.item);
}
@@ -131,192 +104,297 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList
);
}
+// Todo rename to SearchAutocompleteList once it's used in both Router and SearchPage
function SearchRouterList(
- {
- textInputValue,
- updateSearchValue,
- setTextInputValue,
- reportForContextualSearch,
- recentSearches,
- autocompleteSuggestions,
- recentReports,
- onSearchSubmit,
- onAutocompleteSuggestionClick,
- closeRouter,
- }: SearchRouterListProps,
+ {autocompleteQueryValue, searchQueryItem, additionalSections, shouldPreventDefault = true, onListItemFocus, onListItemPress}: SearchRouterListProps,
ref: ForwardedRef,
) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {shouldUseNarrowLayout} = useResponsiveLayout();
- const personalDetails = usePersonalDetails();
- const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
- const taxRates = getAllTaxRates();
- const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST);
- const sections: Array> = [];
-
- if (textInputValue) {
- sections.push({
- data: [
- {
- text: textInputValue,
- singleIcon: Expensicons.MagnifyingGlass,
- searchQuery: textInputValue,
- itemStyle: styles.activeComponentBG,
- keyForList: 'findItem',
- searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH,
- },
- ],
- });
- }
+ const {activeWorkspaceID} = useActiveWorkspace();
+ const policy = usePolicy(activeWorkspaceID);
+ const [betas] = useOnyx(ONYXKEYS.BETAS);
- if (reportForContextualSearch && !textInputValue) {
- const reportQueryValue = reportForContextualSearch.text ?? reportForContextualSearch.alternateText ?? reportForContextualSearch.reportID;
- let roomType: ValueOf = CONST.SEARCH.DATA_TYPES.CHAT;
- let autocompleteID = reportForContextualSearch.reportID;
- if (reportForContextualSearch.isInvoiceRoom) {
- roomType = CONST.SEARCH.DATA_TYPES.INVOICE;
- const report = reportForContextualSearch as SearchOption;
- if (report.item && report.item?.invoiceReceiver && report.item.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) {
- autocompleteID = report.item.invoiceReceiver.accountID.toString();
- } else {
- autocompleteID = '';
- }
+ const {options, areOptionsInitialized} = useOptionsList();
+ const searchOptions = useMemo(() => {
+ if (!areOptionsInitialized) {
+ return defaultListOptions;
}
- if (reportForContextualSearch.isPolicyExpenseChat) {
- roomType = CONST.SEARCH.DATA_TYPES.EXPENSE;
- autocompleteID = reportForContextualSearch.policyID ?? '';
+ return OptionsListUtils.getSearchOptions(options, '', betas ?? []);
+ }, [areOptionsInitialized, betas, options]);
+
+ const typeAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES);
+ const statusAutocompleteList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP});
+ const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE);
+
+ const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST);
+ const cardAutocompleteList = Object.values(cardList);
+
+ const participantsAutocompleteList = useMemo(() => {
+ if (!areOptionsInitialized) {
+ return [];
}
- sections.push({
- data: [
- {
- text: `${translate('search.searchIn')} ${reportForContextualSearch.text ?? reportForContextualSearch.alternateText}`,
- singleIcon: Expensicons.MagnifyingGlass,
- searchQuery: reportQueryValue,
- autocompleteID,
- itemStyle: styles.activeComponentBG,
- keyForList: 'contextualSearch',
- searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION,
- roomType,
- policyID: reportForContextualSearch.policyID,
- },
- ],
+
+ const filteredOptions = OptionsListUtils.getFilteredOptions({
+ reports: options.reports,
+ personalDetails: options.personalDetails,
+ excludeLogins: CONST.EXPENSIFY_EMAILS,
+ maxRecentReportsToShow: 0,
+ includeSelfDM: true,
});
- }
- const autocompleteData = autocompleteSuggestions?.map(({filterKey, text, autocompleteID}) => {
- return {
- text: getSubstitutionMapKey(filterKey, text),
- singleIcon: Expensicons.MagnifyingGlass,
- searchQuery: text,
- autocompleteID,
- keyForList: autocompleteID ?? text, // in case we have a unique identifier then use it because text might not be unique
- searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION,
- };
- });
-
- if (autocompleteData && autocompleteData.length > 0) {
- sections.push({title: translate('search.suggestions'), data: autocompleteData});
- }
+ // This cast is needed as something is incorrect in types OptionsListUtils.getOptions around l1490 and includeRecentReports types
+ const personalDetailsFromOptions = filteredOptions.personalDetails.map((option) => (option as SearchOption).item);
+ const autocompleteOptions = Object.values(personalDetailsFromOptions)
+ .filter((details): details is NonNullable => !!(details && details?.login))
+ .map((details) => {
+ return {
+ name: details.displayName ?? Str.removeSMSDomain(details.login ?? ''),
+ accountID: details.accountID.toString(),
+ };
+ });
+ const currentUser = filteredOptions.currentUserOption ? (filteredOptions.currentUserOption as SearchOption).item : undefined;
+ if (currentUser) {
+ autocompleteOptions.push({
+ name: currentUser.displayName ?? Str.removeSMSDomain(currentUser.login ?? ''),
+ accountID: currentUser.accountID?.toString() ?? '-1',
+ });
+ }
- const recentSearchesData = recentSearches?.map(({query, timestamp}) => {
- const searchQueryJSON = SearchQueryUtils.buildSearchQueryJSON(query);
- return {
- text: searchQueryJSON ? SearchQueryUtils.buildUserReadableQueryString(searchQueryJSON, personalDetails, cardList, reports, taxRates) : query,
- singleIcon: Expensicons.History,
- searchQuery: query,
- keyForList: timestamp,
- searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH,
- };
- });
-
- if (!textInputValue && recentSearchesData && recentSearchesData.length > 0) {
- sections.push({title: translate('search.recentSearches'), data: recentSearchesData});
- }
+ return autocompleteOptions;
+ }, [areOptionsInitialized, options.personalDetails, options.reports]);
- const styledRecentReports = recentReports.map((item) => ({...item, pressableStyle: styles.br2, wrapperStyle: [styles.pr3, styles.pl3]}));
- sections.push({title: translate('search.recentChats'), data: styledRecentReports});
-
- const onSelectRow = useCallback(
- (item: OptionData | SearchQueryItem) => {
- if (isSearchQueryItem(item)) {
- if (!item.searchQuery) {
- return;
- }
- if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) {
- const searchQuery = getContextualSearchQuery(item);
- updateSearchValue(`${searchQuery} `);
-
- if (item.roomType === CONST.SEARCH.DATA_TYPES.INVOICE && item.autocompleteID) {
- const autocompleteKey = `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${item.searchQuery}`;
- onAutocompleteSuggestionClick(autocompleteKey, item.autocompleteID);
- }
- if (item.roomType === CONST.SEARCH.DATA_TYPES.CHAT && item.autocompleteID) {
- const autocompleteKey = `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${item.searchQuery}`;
- onAutocompleteSuggestionClick(autocompleteKey, item.autocompleteID);
- }
- return;
- }
- if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) {
- const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue);
- updateSearchValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `);
-
- if (item.autocompleteID && item.text) {
- onAutocompleteSuggestionClick(item.text, item.autocompleteID);
- }
- return;
- }
-
- onSearchSubmit(item.searchQuery);
+ const taxRates = getAllTaxRates();
+ const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(taxRates, policy), [policy, taxRates]);
+
+ const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
+ const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES);
+ const categoryAutocompleteList = useMemo(() => {
+ return getAutocompleteCategories(allPolicyCategories, activeWorkspaceID);
+ }, [activeWorkspaceID, allPolicyCategories]);
+ const recentCategoriesAutocompleteList = useMemo(() => {
+ return getAutocompleteRecentCategories(allRecentCategories, activeWorkspaceID);
+ }, [activeWorkspaceID, allRecentCategories]);
+
+ const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST);
+ const currencyAutocompleteList = Object.keys(currencyList ?? {});
+ const [recentCurrencyAutocompleteList] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES);
+ const [allPoliciesTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
+ const [allRecentTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS);
+ const tagAutocompleteList = useMemo(() => {
+ return getAutocompleteTags(allPoliciesTags, activeWorkspaceID);
+ }, [activeWorkspaceID, allPoliciesTags]);
+ const recentTagsAutocompleteList = getAutocompleteRecentTags(allRecentTags, activeWorkspaceID);
+
+ const autocompleteSuggestions = useMemo(() => {
+ const autocompleteParsedQuery = parseForAutocomplete(autocompleteQueryValue);
+ const {autocomplete, ranges = []} = autocompleteParsedQuery ?? {};
+ const autocompleteKey = autocomplete?.key;
+ const autocompleteValue = autocomplete?.value ?? '';
+
+ const alreadyAutocompletedKeys = ranges
+ .filter((range) => {
+ return autocompleteKey && range.key === autocompleteKey;
+ })
+ .map((range) => range.value.toLowerCase());
+
+ switch (autocompleteKey) {
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG: {
+ const autocompleteList = autocompleteValue ? tagAutocompleteList : recentTagsAutocompleteList ?? [];
+ const filteredTags = autocompleteList
+ .filter((tag) => tag.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tag))
+ .sort()
+ .slice(0, 10);
+
+ return filteredTags.map((tagName) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG,
+ text: tagName,
+ }));
+ }
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY: {
+ const autocompleteList = autocompleteValue ? categoryAutocompleteList : recentCategoriesAutocompleteList;
+ const filteredCategories = autocompleteList
+ .filter((category) => category.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(category.toLowerCase()))
+ .sort()
+ .slice(0, 10);
+
+ return filteredCategories.map((categoryName) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY,
+ text: categoryName,
+ }));
+ }
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY: {
+ const autocompleteList = autocompleteValue ? currencyAutocompleteList : recentCurrencyAutocompleteList ?? [];
+ const filteredCurrencies = autocompleteList
+ .filter((currency) => currency.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(currency.toLowerCase()))
+ .sort()
+ .slice(0, 10);
+
+ return filteredCurrencies.map((currencyName) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY,
+ text: currencyName,
+ }));
+ }
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE: {
+ const filteredTaxRates = taxAutocompleteList
+ .filter((tax) => tax.taxRateName.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tax.taxRateName.toLowerCase()))
+ .sort()
+ .slice(0, 10);
+
+ return filteredTaxRates.map((tax) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE,
+ text: tax.taxRateName,
+ autocompleteID: tax.taxRateIds.join(','),
+ }));
+ }
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: {
+ const filteredParticipants = participantsAutocompleteList
+ .filter((participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase()))
+ .slice(0, 10);
+
+ return filteredParticipants.map((participant) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM,
+ text: participant.name,
+ autocompleteID: participant.accountID,
+ }));
+ }
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: {
+ const filteredParticipants = participantsAutocompleteList
+ .filter((participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase()))
+ .slice(0, 10);
+
+ return filteredParticipants.map((participant) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO,
+ text: participant.name,
+ autocompleteID: participant.accountID,
+ }));
+ }
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN: {
+ const filteredChats = searchOptions.recentReports
+ .filter((chat) => chat.text?.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(chat.text.toLowerCase()))
+ .slice(0, 10);
+
+ return filteredChats.map((chat) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.IN,
+ text: chat.text ?? '',
+ autocompleteID: chat.reportID,
+ }));
}
+ case CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE: {
+ const filteredTypes = typeAutocompleteList
+ .filter((type) => type.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(type.toLowerCase()))
+ .sort();
- // Handle selection of "Recent chat"
- closeRouter();
- if ('reportID' in item && item?.reportID) {
- Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item?.reportID));
- } else if ('login' in item) {
- ReportUserActions.navigateToAndOpenReport(item.login ? [item.login] : [], false);
+ return filteredTypes.map((type) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE, text: type}));
}
- },
- [closeRouter, textInputValue, onSearchSubmit, updateSearchValue, onAutocompleteSuggestionClick],
- );
+ case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: {
+ const filteredStatuses = statusAutocompleteList
+ .filter((status) => status.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(status))
+ .sort()
+ .slice(0, 10);
- const onArrowFocus = useCallback(
- (focusedItem: OptionData | SearchQueryItem) => {
- if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !focusedItem.searchQuery) {
- return;
+ return filteredStatuses.map((status) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS, text: status}));
+ }
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE: {
+ const filteredExpenseTypes = expenseTypes
+ .filter((expenseType) => expenseType.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(expenseType))
+ .sort();
+
+ return filteredExpenseTypes.map((expenseType) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE,
+ text: expenseType,
+ }));
+ }
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID: {
+ const filteredCards = cardAutocompleteList
+ .filter(
+ (card) =>
+ card.bank.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(CardUtils.getCardDescription(card.cardID).toLowerCase()),
+ )
+ .sort()
+ .slice(0, 10);
+
+ return filteredCards.map((card) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID,
+ text: CardUtils.getCardDescription(card.cardID),
+ autocompleteID: card.cardID.toString(),
+ }));
+ }
+ default: {
+ return [];
}
+ }
+ }, [
+ autocompleteQueryValue,
+ tagAutocompleteList,
+ recentTagsAutocompleteList,
+ categoryAutocompleteList,
+ recentCategoriesAutocompleteList,
+ currencyAutocompleteList,
+ recentCurrencyAutocompleteList,
+ taxAutocompleteList,
+ participantsAutocompleteList,
+ searchOptions.recentReports,
+ typeAutocompleteList,
+ statusAutocompleteList,
+ expenseTypes,
+ cardAutocompleteList,
+ ]);
- const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue);
- setTextInputValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(focusedItem.searchQuery)} `);
+ const sections: Array> = [];
- if (focusedItem.autocompleteID && focusedItem.text) {
- onAutocompleteSuggestionClick(focusedItem.text, focusedItem.autocompleteID);
- }
- },
- [setTextInputValue, textInputValue, onAutocompleteSuggestionClick],
- );
+ if (searchQueryItem) {
+ sections.push({data: [searchQueryItem]});
+ }
+
+ if (autocompleteSuggestions.length > 0) {
+ const autocompleteData = autocompleteSuggestions.map(({filterKey, text, autocompleteID}) => {
+ return {
+ text: getSubstitutionMapKey(filterKey, text),
+ singleIcon: Expensicons.MagnifyingGlass,
+ searchQuery: text,
+ autocompleteID,
+ keyForList: autocompleteID ?? text, // in case we have a unique identifier then use it because text might not be unique
+ searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION,
+ };
+ });
+
+ sections.push({title: translate('search.suggestions'), data: autocompleteData});
+ }
+
+ if (additionalSections) {
+ sections.push(...additionalSections);
+ }
+
+ const onArrowFocus = (focusedItem: OptionData | SearchQueryItem) => {
+ if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION) {
+ return;
+ }
+
+ onListItemFocus(focusedItem);
+ };
return (
sections={sections}
- onSelectRow={onSelectRow}
+ onSelectRow={onListItemPress}
ListItem={SearchRouterItem}
containerStyle={[styles.mh100]}
sectionListStyle={[shouldUseNarrowLayout ? styles.ph5 : styles.ph2, styles.pb2]}
listItemWrapperStyle={[styles.pr0, styles.pl0]}
getItemHeight={getItemHeight}
onLayout={setPerformanceTimersEnd}
- ref={ref}
showScrollIndicator={!shouldUseNarrowLayout}
sectionTitleStyles={styles.mhn2}
shouldSingleExecuteRowSelect
onArrowFocus={onArrowFocus}
+ shouldPreventDefault={shouldPreventDefault}
+ ref={ref}
/>
);
}
export default forwardRef(SearchRouterList);
export {SearchRouterItem};
-export type {AutocompleteItemData};
diff --git a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts
new file mode 100644
index 000000000000..a7185f126e55
--- /dev/null
+++ b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts
@@ -0,0 +1,81 @@
+import type {OnyxCollection} from 'react-native-onyx';
+import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types';
+import * as parser from '@libs/SearchParser/autocompleteParser';
+import * as SearchQueryUtils from '@libs/SearchQueryUtils';
+import CONST from '@src/CONST';
+import type * as OnyxTypes from '@src/types/onyx';
+import type {SubstitutionMap} from './getQueryWithSubstitutions';
+
+const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`;
+
+/**
+ * Given a plaintext query and specific entities data,
+ * this function will build the substitutions map from scratch for this query
+ *
+ * Ex:
+ * query: `Test from:12345 to:9876`
+ * personalDetails: {
+ * 12345: JohnDoe
+ * 98765: SomeoneElse
+ * }
+ *
+ * return: {
+ * from:JohnDoe: 12345,
+ * to:SomeoneElse: 98765,
+ * }
+ */
+function buildSubstitutionsMap(
+ query: string,
+ personalDetails: OnyxTypes.PersonalDetailsList,
+ reports: OnyxCollection,
+ allTaxRates: Record,
+): SubstitutionMap {
+ const parsedQuery = parser.parse(query) as {ranges: SearchAutocompleteQueryRange[]};
+
+ const searchAutocompleteQueryRanges = parsedQuery.ranges;
+
+ if (searchAutocompleteQueryRanges.length === 0) {
+ return {};
+ }
+
+ const substitutionsMap = searchAutocompleteQueryRanges.reduce((map, range) => {
+ const {key: filterKey, value: filterValue} = range;
+
+ if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) {
+ const taxRateID = filterValue;
+ const taxRates = Object.entries(allTaxRates)
+ .filter(([, IDs]) => IDs.includes(taxRateID))
+ .map(([name]) => name);
+
+ const taxRateNames = taxRates.length > 0 ? taxRates : [taxRateID];
+ const uniqueTaxRateNames = [...new Set(taxRateNames)];
+ uniqueTaxRateNames.forEach((taxRateName) => {
+ const substitutionKey = getSubstitutionsKey(filterKey, taxRateName);
+
+ // eslint-disable-next-line no-param-reassign
+ map[substitutionKey] = taxRateID;
+ });
+ } else if (
+ filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM ||
+ filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO ||
+ filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.IN ||
+ filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID
+ ) {
+ const displayValue = SearchQueryUtils.getFilterDisplayValue(filterKey, filterValue, personalDetails, reports);
+
+ // If displayValue === filterValue, then it means there is nothing to substitute, so we don't add any key to map
+ if (displayValue !== filterValue) {
+ const substitutionKey = getSubstitutionsKey(filterKey, displayValue);
+ // eslint-disable-next-line no-param-reassign
+ map[substitutionKey] = filterValue;
+ }
+ }
+
+ return map;
+ }, {} as SubstitutionMap);
+
+ return substitutionsMap;
+}
+
+// eslint-disable-next-line import/prefer-default-export
+export {buildSubstitutionsMap};
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index 0e12e993cc79..9358c4ad822c 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -67,6 +67,7 @@ function BaseSelectionList(
showScrollIndicator = true,
showLoadingPlaceholder = false,
showConfirmButton = false,
+ shouldUseDefaultTheme = false,
shouldPreventDefaultFocusOnSelectRow = false,
containerStyle,
sectionListStyle,
@@ -832,7 +833,7 @@ function BaseSelectionList(
{showConfirmButton && (