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'); + }); +});