diff --git a/src/languages/de.ts b/src/languages/de.ts
index 8a3b4e9284a9..d3fc20db9b32 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -1134,6 +1134,7 @@ const translations = {
canceled: 'Abgebrochen',
posted: 'Gepostet',
deleteReceipt: 'Beleg löschen',
+ findExpense: 'Ausgabe finden',
deletedTransaction: ({amount, merchant}: DeleteTransactionParams) => `hat eine Ausgabe gelöscht (${amount} für ${merchant})`,
movedFromReport: ({reportName}: MovedFromReportParams) => `verschob eine Ausgabe${reportName ? `von ${reportName}` : ''}`,
movedTransaction: ({reportUrl, reportName}: MovedTransactionParams) => `verschob diese Ausgabe${reportName ? `to ${reportName}` : ''}`,
diff --git a/src/languages/en.ts b/src/languages/en.ts
index aa81412998c2..22402f2e2d7e 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -1118,6 +1118,7 @@ const translations = {
canceled: 'Canceled',
posted: 'Posted',
deleteReceipt: 'Delete receipt',
+ findExpense: 'Find expense',
deletedTransaction: ({amount, merchant}: DeleteTransactionParams) => `deleted an expense (${amount} for ${merchant})`,
movedFromReport: ({reportName}: MovedFromReportParams) => `moved an expense${reportName ? ` from ${reportName}` : ''}`,
movedTransaction: ({reportUrl, reportName}: MovedTransactionParams) => `moved this expense${reportName ? ` to ${reportName}` : ''}`,
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 6cc1d5ba4e1e..76a137877afc 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -1111,6 +1111,7 @@ const translations = {
pendingMatchWithCreditCardDescription: 'Recibo pendiente de adjuntar con la transacción de la tarjeta. Márcalo como efectivo para cancelar.',
markAsCash: 'Marcar como efectivo',
routePending: 'Ruta pendiente...',
+ findExpense: 'Buscar gasto',
deletedTransaction: ({amount, merchant}: DeleteTransactionParams) => `eliminó un gasto (${amount} para ${merchant})`,
movedFromReport: ({reportName}: MovedFromReportParams) => `movió un gasto${reportName ? ` desde ${reportName}` : ''}`,
movedTransaction: ({reportUrl, reportName}: MovedTransactionParams) => `movió este gasto${reportName ? ` a ${reportName}` : ''}`,
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 9aa0ba3a4e55..36439ba79889 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -1132,6 +1132,7 @@ const translations = {
canceled: 'Annulé',
posted: 'Publié',
deleteReceipt: 'Supprimer le reçu',
+ findExpense: 'Trouver une dépense',
deletedTransaction: ({amount, merchant}: DeleteTransactionParams) => `supprimé une dépense (${amount} pour ${merchant})`,
movedFromReport: ({reportName}: MovedFromReportParams) => `a déplacé une dépense${reportName ? `de ${reportName}` : ''}`,
movedTransaction: ({reportUrl, reportName}: MovedTransactionParams) => `déplacé cette dépense${reportName ? `à ${reportName}` : ''}`,
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 663804973837..1ae72b03b6e7 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -1128,6 +1128,7 @@ const translations = {
canceled: 'Annullato',
posted: 'Pubblicato',
deleteReceipt: 'Elimina ricevuta',
+ findExpense: 'Trova spesa',
deletedTransaction: ({amount, merchant}: DeleteTransactionParams) => `ha eliminato una spesa (${amount} per ${merchant})`,
movedFromReport: ({reportName}: MovedFromReportParams) => `ha spostato una spesa${reportName ? `da ${reportName}` : ''}`,
movedTransaction: ({reportUrl, reportName}: MovedTransactionParams) => `spostato questa spesa${reportName ? `a ${reportName}` : ''}`,
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index 32e82e9d3ce1..98b726415b97 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -1130,6 +1130,7 @@ const translations = {
canceled: 'キャンセルされました',
posted: '投稿済み',
deleteReceipt: '領収書を削除',
+ findExpense: '経費を検索',
deletedTransaction: ({amount, merchant}: DeleteTransactionParams) => `経費を削除しました (${merchant}の${amount})`,
movedFromReport: ({reportName}: MovedFromReportParams) => `費用${reportName ? `${reportName} から` : ''}を移動しました`,
movedTransaction: ({reportUrl, reportName}: MovedTransactionParams) => `この経費${reportName ? `to ${reportName}` : ''}を移動しました`,
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index e54963722c95..56028da88f48 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -1128,6 +1128,7 @@ const translations = {
canceled: 'Geannuleerd',
posted: 'Geplaatst',
deleteReceipt: 'Verwijder bonnetje',
+ findExpense: 'Uitgave zoeken',
deletedTransaction: ({amount, merchant}: DeleteTransactionParams) => `verwijderde een uitgave (${amount} voor ${merchant})`,
movedFromReport: ({reportName}: MovedFromReportParams) => `verplaatste een uitgave${reportName ? `van ${reportName}` : ''}`,
movedTransaction: ({reportUrl, reportName}: MovedTransactionParams) => `heeft deze uitgave verplaatst${reportName ? `naar ${reportName}` : ''}`,
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index f776be1698d9..fb553e36eb4b 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -1127,6 +1127,7 @@ const translations = {
canceled: 'Anulowano',
posted: 'Opublikowano',
deleteReceipt: 'Usuń paragon',
+ findExpense: 'Znajdź wydatek',
deletedTransaction: ({amount, merchant}: DeleteTransactionParams) => `usunął wydatek (${amount} dla ${merchant})`,
movedFromReport: ({reportName}: MovedFromReportParams) => `przeniósł wydatek${reportName ? `z ${reportName}` : ''}`,
movedTransaction: ({reportUrl, reportName}: MovedTransactionParams) => `przeniesiono ten wydatek${reportName ? `do ${reportName}` : ''}`,
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index aed4ddfc7650..4b7056f0ef67 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -1129,6 +1129,7 @@ const translations = {
canceled: 'Cancelado',
posted: 'Publicado',
deleteReceipt: 'Excluir recibo',
+ findExpense: 'Encontrar despesa',
deletedTransaction: ({amount, merchant}: DeleteTransactionParams) => `excluiu uma despesa (${amount} para ${merchant})`,
movedFromReport: ({reportName}: MovedFromReportParams) => `moveu uma despesa${reportName ? `de ${reportName}` : ''}`,
movedTransaction: ({reportUrl, reportName}: MovedTransactionParams) => `moveu esta despesa${reportName ? `para ${reportName}` : ''}`,
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index a7a8b81fc54a..203d764fb0ee 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -1120,6 +1120,7 @@ const translations = {
canceled: '已取消',
posted: '已发布',
deleteReceipt: '删除收据',
+ findExpense: '查找费用',
deletedTransaction: ({amount, merchant}: DeleteTransactionParams) => `删除了一笔费用 (${merchant} 的 ${amount})`,
movedFromReport: ({reportName}: MovedFromReportParams) => `移动了一笔费用${reportName ? `来自${reportName}` : ''}`,
movedTransaction: ({reportUrl, reportName}: MovedTransactionParams) => `移动了此费用${reportName ? `至 ${reportName}` : ''}`,
diff --git a/src/pages/AddUnreportedExpense.tsx b/src/pages/AddUnreportedExpense.tsx
index 239c7dcb534d..c7688b59b016 100644
--- a/src/pages/AddUnreportedExpense.tsx
+++ b/src/pages/AddUnreportedExpense.tsx
@@ -10,12 +10,14 @@ import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionListWithSections';
import type {ListItem, SectionListDataType, SelectionListHandle} from '@components/SelectionListWithSections/types';
import UnreportedExpensesSkeleton from '@components/Skeletons/UnreportedExpensesSkeleton';
+import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import usePolicy from '@hooks/usePolicy';
import useThemeStyles from '@hooks/useThemeStyles';
import {fetchUnreportedExpenses} from '@libs/actions/UnreportedExpenses';
+import {convertToDisplayString} from '@libs/CurrencyUtils';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import interceptAnonymousUser from '@libs/interceptAnonymousUser';
import type {AddUnreportedExpensesParamList} from '@libs/Navigation/types';
@@ -23,7 +25,8 @@ import Permissions from '@libs/Permissions';
import {canSubmitPerDiemExpenseFromWorkspace, getPerDiemCustomUnit} from '@libs/PolicyUtils';
import {isIOUReport} from '@libs/ReportUtils';
import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils';
-import {createUnreportedExpenseSections, isPerDiemRequest} from '@libs/TransactionUtils';
+import tokenizedSearch from '@libs/tokenizedSearch';
+import {createUnreportedExpenseSections, getAmount, getCurrency, getDescription, getMerchant, isPerDiemRequest} from '@libs/TransactionUtils';
import Navigation from '@navigation/Navigation';
import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types';
import {convertBulkTrackedExpensesToIOU, startMoneyRequest} from '@userActions/IOU';
@@ -45,6 +48,7 @@ function AddUnreportedExpense({route}: AddUnreportedExpensePageType) {
const [offset, setOffset] = useState(0);
const {isOffline} = useNetwork();
const [selectedIds, setSelectedIds] = useState(new Set());
+ const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState('');
const {reportID, backToReport} = route.params;
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: true});
@@ -106,11 +110,50 @@ function AddUnreportedExpense({route}: AddUnreportedExpensePageType) {
const styles = useThemeStyles();
const selectionListRef = useRef(null);
- const sections: Array> = useMemo(() => createUnreportedExpenseSections(transactions), [transactions]);
+ const filteredTransactions = useMemo(() => {
+ if (!debouncedSearchValue.trim()) {
+ return transactions;
+ }
+
+ return tokenizedSearch(transactions, debouncedSearchValue, (transaction) => {
+ const searchableFields: string[] = [];
+
+ const merchant = getMerchant(transaction);
+ if (merchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT) {
+ searchableFields.push(merchant);
+ }
+
+ const description = getDescription(transaction);
+ if (description.trim()) {
+ searchableFields.push(description);
+ }
+
+ const amount = getAmount(transaction);
+ const currency = getCurrency(transaction);
+ const formattedAmount = convertToDisplayString(amount, currency);
+ searchableFields.push(formattedAmount);
+
+ return searchableFields;
+ });
+ }, [transactions, debouncedSearchValue]);
+
+ const sections: Array> = useMemo(() => createUnreportedExpenseSections(filteredTransactions), [filteredTransactions]);
+
+ const shouldShowTextInput = useMemo(() => {
+ return transactions.length >= CONST.SEARCH_ITEM_LIMIT;
+ }, [transactions.length]);
+
+ const headerMessage = useMemo(() => {
+ if (debouncedSearchValue.trim() && sections.at(0)?.data.length === 0) {
+ return translate('common.noResultsFound');
+ }
+ return '';
+ }, [debouncedSearchValue, sections, translate]);
- const thereIsNoUnreportedTransaction = !((sections.at(0)?.data.length ?? 0) > 0);
+ const hasSearchTerm = debouncedSearchValue.trim().length > 0;
+ const isShowingEmptyState = !hasSearchTerm && transactions.length === 0;
- if (thereIsNoUnreportedTransaction && isLoadingUnreportedTransactions) {
+ if (isShowingEmptyState && isLoadingUnreportedTransactions) {
return (
selectedIds.has(item.transactionID)}
+ shouldShowTextInput={shouldShowTextInput}
+ textInputValue={searchValue}
+ textInputLabel={shouldShowTextInput ? translate('iou.findExpense') : undefined}
+ onChangeText={setSearchValue}
+ headerMessage={headerMessage}
canSelectMultiple
sections={sections}
ListItem={UnreportedExpenseListItem}
diff --git a/src/pages/UnreportedExpenseListItem.tsx b/src/pages/UnreportedExpenseListItem.tsx
index 2c1ac0363b9b..2a35fdc1c7b9 100644
--- a/src/pages/UnreportedExpenseListItem.tsx
+++ b/src/pages/UnreportedExpenseListItem.tsx
@@ -1,4 +1,4 @@
-import React, {useRef, useState} from 'react';
+import React, {useRef} from 'react';
import type {View} from 'react-native';
import {getButtonRole} from '@components/Button/utils';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
@@ -31,7 +31,7 @@ function UnreportedExpenseListItem({
}: UnreportedExpenseListItemProps) {
const styles = useThemeStyles();
const transactionItem = item as unknown as TransactionListItemType;
- const [isSelected, setIsSelected] = useState(false);
+ const isSelected = !!item.isSelected;
const theme = useTheme();
const pressableStyle = [styles.transactionListItemStyle, isSelected && styles.activeComponentBG];
@@ -55,7 +55,6 @@ function UnreportedExpenseListItem({
ref={pressableRef}
onPress={() => {
onSelectRow(item);
- setIsSelected((val) => !val);
}}
disabled={isItemDisabled}
accessibilityLabel={item.text ?? ''}
@@ -80,7 +79,6 @@ function UnreportedExpenseListItem({
taxAmountColumnSize={CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL}
onCheckboxPress={() => {
onSelectRow(item);
- setIsSelected((val) => !val);
}}
isDisabled={isItemDisabled}
shouldShowCheckbox
diff --git a/tests/unit/AddUnreportedExpenseSearchTest.ts b/tests/unit/AddUnreportedExpenseSearchTest.ts
new file mode 100644
index 000000000000..68d8f5e750ed
--- /dev/null
+++ b/tests/unit/AddUnreportedExpenseSearchTest.ts
@@ -0,0 +1,190 @@
+import {convertToDisplayString} from '@libs/CurrencyUtils';
+import tokenizedSearch from '@libs/tokenizedSearch';
+import {getAmount, getCurrency, getDescription, getMerchant} from '@libs/TransactionUtils';
+import CONST from '@src/CONST';
+import type Transaction from '@src/types/onyx/Transaction';
+
+// Mock the dependencies
+jest.mock('@libs/CurrencyUtils');
+jest.mock('@libs/TransactionUtils');
+
+const mockConvertToDisplayString = convertToDisplayString as jest.MockedFunction;
+const mockGetMerchant = getMerchant as jest.MockedFunction;
+const mockGetDescription = getDescription as jest.MockedFunction;
+const mockGetAmount = getAmount as jest.MockedFunction;
+const mockGetCurrency = getCurrency as jest.MockedFunction;
+
+describe('AddUnreportedExpense Search Functionality', () => {
+ const mockTransaction1: Partial = {
+ transactionID: '1',
+ merchant: 'Starbucks',
+ comment: {comment: 'Coffee meeting'},
+ amount: 500, // $5.00
+ currency: 'USD',
+ };
+
+ const mockTransaction2: Partial = {
+ transactionID: '2',
+ merchant: 'Uber',
+ comment: {comment: 'Taxi to airport'},
+ amount: 2500, // $25.00
+ currency: 'USD',
+ };
+
+ const mockTransaction3: Partial = {
+ transactionID: '3',
+ merchant: 'Hotel California',
+ comment: {comment: 'Business trip accommodation'},
+ amount: 15000, // $150.00
+ currency: 'USD',
+ };
+
+ const transactions = [mockTransaction1, mockTransaction2, mockTransaction3] as Transaction[];
+
+ beforeEach(() => {
+ // Setup mocks
+ mockGetMerchant.mockImplementation((transaction) => {
+ if (transaction?.transactionID === '1') {
+ return 'Starbucks';
+ }
+ if (transaction?.transactionID === '2') {
+ return 'Uber';
+ }
+ if (transaction?.transactionID === '3') {
+ return 'Hotel California';
+ }
+ return '';
+ });
+
+ mockGetDescription.mockImplementation((transaction) => {
+ if (transaction?.transactionID === '1') {
+ return 'Coffee meeting';
+ }
+ if (transaction?.transactionID === '2') {
+ return 'Taxi to airport';
+ }
+ if (transaction?.transactionID === '3') {
+ return 'Business trip accommodation';
+ }
+ return '';
+ });
+
+ mockGetAmount.mockImplementation((transaction) => {
+ if (transaction?.transactionID === '1') {
+ return 500;
+ }
+ if (transaction?.transactionID === '2') {
+ return 2500;
+ }
+ if (transaction?.transactionID === '3') {
+ return 15000;
+ }
+ return 0;
+ });
+
+ mockGetCurrency.mockImplementation(() => 'USD');
+
+ mockConvertToDisplayString.mockImplementation((amount) => {
+ if (amount === 500) {
+ return '$5.00';
+ }
+ if (amount === 2500) {
+ return '$25.00';
+ }
+ if (amount === 15000) {
+ return '$150.00';
+ }
+ return '$0.00';
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const getSearchableFields = (transaction: Transaction) => {
+ const searchableFields: string[] = [];
+
+ // Add merchant to searchable fields
+ const merchant = getMerchant(transaction);
+ if (merchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT) {
+ searchableFields.push(merchant);
+ }
+
+ // Add description to searchable fields
+ const description = getDescription(transaction);
+ if (description.trim()) {
+ searchableFields.push(description);
+ }
+
+ // Add formatted amount to searchable fields
+ const amount = getAmount(transaction);
+ const currency = getCurrency(transaction);
+ const formattedAmount = convertToDisplayString(amount, currency);
+ searchableFields.push(formattedAmount);
+
+ return searchableFields;
+ };
+
+ it('should search by merchant name', () => {
+ const searchTerm = 'Starbucks';
+ const result = tokenizedSearch(transactions, searchTerm, getSearchableFields);
+
+ expect(result).toHaveLength(1);
+ expect(result.at(0)?.transactionID).toBe('1');
+ });
+
+ it('should search by description', () => {
+ const searchTerm = 'Coffee meeting';
+ const result = tokenizedSearch(transactions, searchTerm, getSearchableFields);
+
+ expect(result).toHaveLength(1);
+ expect(result.at(0)?.transactionID).toBe('1');
+ });
+
+ it('should search by amount', () => {
+ const searchTerm = '$25.00';
+ const result = tokenizedSearch(transactions, searchTerm, getSearchableFields);
+
+ expect(result).toHaveLength(1);
+ expect(result.at(0)?.transactionID).toBe('2');
+ });
+
+ it('should search by partial terms', () => {
+ const searchTerm = 'Hotel';
+ const result = tokenizedSearch(transactions, searchTerm, getSearchableFields);
+
+ expect(result).toHaveLength(1);
+ expect(result.at(0)?.transactionID).toBe('3');
+ });
+
+ it('should search across multiple fields', () => {
+ const searchTerm = 'trip';
+ const result = tokenizedSearch(transactions, searchTerm, getSearchableFields);
+
+ expect(result).toHaveLength(1);
+ expect(result.at(0)?.transactionID).toBe('3');
+ });
+
+ it('should return all transactions when search term is empty', () => {
+ const searchTerm = '';
+ const result = tokenizedSearch(transactions, searchTerm, getSearchableFields);
+
+ expect(result).toHaveLength(3);
+ });
+
+ it('should return no results for non-matching search term', () => {
+ const searchTerm = 'nonexistent';
+ const result = tokenizedSearch(transactions, searchTerm, getSearchableFields);
+
+ expect(result).toHaveLength(0);
+ });
+
+ it('should handle case-insensitive search', () => {
+ const searchTerm = 'STARBUCKS';
+ const result = tokenizedSearch(transactions, searchTerm, getSearchableFields);
+
+ expect(result).toHaveLength(1);
+ expect(result.at(0)?.transactionID).toBe('1');
+ });
+});