From 2e86466c20d5a5abfb870d03155e72474502f56e Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Wed, 10 Sep 2025 23:12:33 +0800 Subject: [PATCH 1/6] feat: add search bar --- .../SelectionList/BaseSelectionList.tsx | 2 + src/components/SelectionList/types.ts | 3 ++ src/languages/de.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/pages/AddUnreportedExpense.tsx | 41 ++++++++++++++++--- 13 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 6fe6451d38fc..e031887c9c22 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -146,6 +146,7 @@ function BaseSelectionList({ shouldUseDefaultRightHandSideCheckmark, selectedItems = [], isSelected, + textInputIcon, canShowProductTrainingTooltip, renderScrollComponent, ref, @@ -737,6 +738,7 @@ function BaseSelectionList({ value={textInputValue} placeholder={textInputPlaceholder} autoCorrect={autoCorrect} + icon={textInputIcon} maxLength={textInputMaxLength} onChangeText={onChangeText} inputMode={inputMode} diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 072186485206..1c81735543f4 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -895,6 +895,9 @@ type SelectionListProps = Partial & { /** Whether to hide the keyboard when scrolling a list */ shouldHideKeyboardOnScroll?: boolean; + /** Icon to display on the left side of TextInput */ + textInputIcon?: IconAsset; + /** Reference to the outer element */ ref?: ForwardedRef; diff --git a/src/languages/de.ts b/src/languages/de.ts index 06d49c112248..ea4f223dd9b1 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1103,6 +1103,7 @@ const translations = { canceled: 'Abgebrochen', posted: 'Gepostet', deleteReceipt: 'Beleg löschen', + findMerchant: 'Händler 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 f31b72030a89..5f6a7c2b5aaa 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1090,6 +1090,7 @@ const translations = { canceled: 'Canceled', posted: 'Posted', deleteReceipt: 'Delete receipt', + findMerchant: 'Find merchant', 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 2037c8624065..22302bf05595 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1088,6 +1088,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...', + findMerchant: 'Buscar comerciante', 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 912a0742e4bc..09c565041597 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1105,6 +1105,7 @@ const translations = { canceled: 'Annulé', posted: 'Publié', deleteReceipt: 'Supprimer le reçu', + findMerchant: 'Trouver un commerçant', 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 468560516745..05cd68598d3e 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1100,6 +1100,7 @@ const translations = { canceled: 'Annullato', posted: 'Pubblicato', deleteReceipt: 'Elimina ricevuta', + findMerchant: 'Trova commerciante', 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 d1f3ec4e340c..22752c4b3b9d 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1102,6 +1102,7 @@ const translations = { canceled: 'キャンセルされました', posted: '投稿済み', deleteReceipt: '領収書を削除', + findMerchant: '加盟店を検索', 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 64a88b05ec66..39f9898cde9b 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1101,6 +1101,7 @@ const translations = { canceled: 'Geannuleerd', posted: 'Geplaatst', deleteReceipt: 'Verwijder bonnetje', + findMerchant: 'Handelaar 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 8e736d50b9b7..dfddcde89a75 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1099,6 +1099,7 @@ const translations = { canceled: 'Anulowano', posted: 'Opublikowano', deleteReceipt: 'Usuń paragon', + findMerchant: 'Znajdź sprzedawcę', 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 b0a547a67863..5cb06ed7fa39 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1102,6 +1102,7 @@ const translations = { canceled: 'Cancelado', posted: 'Publicado', deleteReceipt: 'Excluir recibo', + findMerchant: 'Encontrar comerciante', 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 6ecf2ac23e34..9c666894c92b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1093,6 +1093,7 @@ const translations = { canceled: '已取消', posted: '已发布', deleteReceipt: '删除收据', + findMerchant: '查找商户', 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 20cf2cb16715..355ced1802ae 100644 --- a/src/pages/AddUnreportedExpense.tsx +++ b/src/pages/AddUnreportedExpense.tsx @@ -4,12 +4,14 @@ import type {OnyxCollection} from 'react-native-onyx'; import EmptyStateComponent from '@components/EmptyStateComponent'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; import LottieAnimations from '@components/LottieAnimations'; import {useSession} from '@components/OnyxListItemProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import type {ListItem, SectionListDataType, SelectionListHandle} from '@components/SelectionList/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'; @@ -22,7 +24,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, getMerchant, isPerDiemRequest} from '@libs/TransactionUtils'; import Navigation from '@navigation/Navigation'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; import {convertBulkTrackedExpensesToIOU, startMoneyRequest} from '@userActions/IOU'; @@ -44,6 +47,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}); @@ -97,11 +101,31 @@ 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) => [getMerchant(transaction)]); + }, [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 ( Date: Sat, 13 Sep 2025 15:53:26 +0800 Subject: [PATCH 2/6] fix: search issue --- src/pages/AddUnreportedExpense.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/pages/AddUnreportedExpense.tsx b/src/pages/AddUnreportedExpense.tsx index 355ced1802ae..f087ef1a4e90 100644 --- a/src/pages/AddUnreportedExpense.tsx +++ b/src/pages/AddUnreportedExpense.tsx @@ -106,7 +106,14 @@ function AddUnreportedExpense({route}: AddUnreportedExpensePageType) { return transactions; } - return tokenizedSearch(transactions, debouncedSearchValue, (transaction) => [getMerchant(transaction)]); + return tokenizedSearch(transactions, debouncedSearchValue, (transaction) => { + const merchant = getMerchant(transaction); + // Don't include transactions with placeholder merchant value in search + if (merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT) { + return []; + } + return [merchant]; + }); }, [transactions, debouncedSearchValue]); const sections: Array> = useMemo(() => createUnreportedExpenseSections(filteredTransactions), [filteredTransactions]); From fd80c6d6f0e8685812dc56635fd2fa25f2d2ddf4 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Thu, 25 Sep 2025 19:22:23 +0800 Subject: [PATCH 3/6] fix: selection issue --- src/pages/AddUnreportedExpense.tsx | 1 + src/pages/UnreportedExpenseListItem.tsx | 6 ++---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/pages/AddUnreportedExpense.tsx b/src/pages/AddUnreportedExpense.tsx index f087ef1a4e90..12bbcc68f73a 100644 --- a/src/pages/AddUnreportedExpense.tsx +++ b/src/pages/AddUnreportedExpense.tsx @@ -225,6 +225,7 @@ function AddUnreportedExpense({route}: AddUnreportedExpensePageType) { return newIds; }); }} + isSelected={(item) => selectedIds.has(item.transactionID)} shouldShowTextInput={shouldShowTextInput} textInputValue={searchValue} textInputLabel={shouldShowTextInput ? translate('iou.findMerchant') : undefined} diff --git a/src/pages/UnreportedExpenseListItem.tsx b/src/pages/UnreportedExpenseListItem.tsx index 317472385c17..0831fcc604ec 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]; @@ -53,7 +53,6 @@ function UnreportedExpenseListItem({ ref={pressableRef} onPress={() => { onSelectRow(item); - setIsSelected((val) => !val); }} disabled={isDisabled && !isSelected} accessibilityLabel={item.text ?? ''} @@ -78,7 +77,6 @@ function UnreportedExpenseListItem({ taxAmountColumnSize={CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL} onCheckboxPress={() => { onSelectRow(item); - setIsSelected((val) => !val); }} shouldShowCheckbox style={styles.p3} From 30c7aaf2910008a7667b4205deac3bc2fcaeca5b Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Sun, 28 Sep 2025 23:07:59 +0800 Subject: [PATCH 4/6] chore: add icons --- src/components/SelectionListWithSections/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SelectionListWithSections/index.tsx b/src/components/SelectionListWithSections/index.tsx index 703fc393327e..c50bbaf076b2 100644 --- a/src/components/SelectionListWithSections/index.tsx +++ b/src/components/SelectionListWithSections/index.tsx @@ -6,7 +6,7 @@ import CONST from '@src/CONST'; import BaseSelectionList from './BaseSelectionListWithSections'; import type {ListItem, SelectionListProps} from './types'; -function SelectionListWithSections({onScroll, shouldHideKeyboardOnScroll = true, textInputIcon, ref, ...props}: SelectionListProps) { +function SelectionListWithSections({onScroll, shouldHideKeyboardOnScroll = true, ref, ...props}: SelectionListProps) { const [isScreenTouched, setIsScreenTouched] = useState(false); const touchStart = () => setIsScreenTouched(true); From 95d06d8dae9b594810767c068a5edd0aa268a8b8 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Sun, 5 Oct 2025 21:48:58 +0800 Subject: [PATCH 5/6] feat: new requirements --- .../BaseSelectionListWithSections.tsx | 2 - .../SelectionListWithSections/types.ts | 3 - src/languages/de.ts | 2 +- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- src/pages/AddUnreportedExpense.tsx | 27 ++- tests/unit/AddUnreportedExpenseSearchTest.ts | 166 ++++++++++++++++++ 14 files changed, 195 insertions(+), 23 deletions(-) create mode 100644 tests/unit/AddUnreportedExpenseSearchTest.ts diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 43bea0d66aac..68de014f5a9a 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -143,7 +143,6 @@ function BaseSelectionListWithSections({ addOfflineIndicatorBottomSafeAreaPadding, fixedNumItemsForLoader, loaderSpeed, - textInputIcon, errorText, shouldUseDefaultRightHandSideCheckmark, selectedItems = getEmptyArray(), @@ -742,7 +741,6 @@ function BaseSelectionListWithSections({ inputMode={inputMode} selectTextOnFocus spellCheck={false} - icon={textInputIcon} iconLeft={textInputIconLeft} onSubmitEditing={selectFocusedOption} blurOnSubmit={!!flattenedSections.allOptions.length} diff --git a/src/components/SelectionListWithSections/types.ts b/src/components/SelectionListWithSections/types.ts index 6fb5f66a203e..d89984a74fd9 100644 --- a/src/components/SelectionListWithSections/types.ts +++ b/src/components/SelectionListWithSections/types.ts @@ -908,9 +908,6 @@ type SelectionListProps = Partial & { /** Reference to the outer element */ ref?: ForwardedRef; - /** Icon to display on the left side of TextInput */ - textInputIcon?: IconAsset; - /** Custom scroll component to use instead of the default ScrollView */ renderScrollComponent?: (props: ScrollViewProps) => ReactElement>; } & TRightHandSideComponent; diff --git a/src/languages/de.ts b/src/languages/de.ts index 50d0a6e44361..d3fc20db9b32 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1134,7 +1134,7 @@ const translations = { canceled: 'Abgebrochen', posted: 'Gepostet', deleteReceipt: 'Beleg löschen', - findMerchant: 'Händler finden', + 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 558983adecc1..22402f2e2d7e 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1118,7 +1118,7 @@ const translations = { canceled: 'Canceled', posted: 'Posted', deleteReceipt: 'Delete receipt', - findMerchant: 'Find merchant', + 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 89f680866b7f..76a137877afc 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1111,7 +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...', - findMerchant: 'Buscar comerciante', + 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 6b15726f63b0..36439ba79889 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1132,7 +1132,7 @@ const translations = { canceled: 'Annulé', posted: 'Publié', deleteReceipt: 'Supprimer le reçu', - findMerchant: 'Trouver un commerçant', + 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 5f05513fb886..1ae72b03b6e7 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1128,7 +1128,7 @@ const translations = { canceled: 'Annullato', posted: 'Pubblicato', deleteReceipt: 'Elimina ricevuta', - findMerchant: 'Trova commerciante', + 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 f0d6b74182bf..98b726415b97 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1130,7 +1130,7 @@ const translations = { canceled: 'キャンセルされました', posted: '投稿済み', deleteReceipt: '領収書を削除', - findMerchant: '加盟店を検索', + 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 101cbfcb149c..56028da88f48 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1128,7 +1128,7 @@ const translations = { canceled: 'Geannuleerd', posted: 'Geplaatst', deleteReceipt: 'Verwijder bonnetje', - findMerchant: 'Handelaar zoeken', + 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 33c7fa34318a..fb553e36eb4b 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1127,7 +1127,7 @@ const translations = { canceled: 'Anulowano', posted: 'Opublikowano', deleteReceipt: 'Usuń paragon', - findMerchant: 'Znajdź sprzedawcę', + 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 8118b147b930..4b7056f0ef67 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1129,7 +1129,7 @@ const translations = { canceled: 'Cancelado', posted: 'Publicado', deleteReceipt: 'Excluir recibo', - findMerchant: 'Encontrar comerciante', + 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 82df9a4b9a11..203d764fb0ee 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1120,7 +1120,7 @@ const translations = { canceled: '已取消', posted: '已发布', deleteReceipt: '删除收据', - findMerchant: '查找商户', + 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 015f9d0e7a55..c7688b59b016 100644 --- a/src/pages/AddUnreportedExpense.tsx +++ b/src/pages/AddUnreportedExpense.tsx @@ -4,7 +4,6 @@ import type {OnyxCollection} from 'react-native-onyx'; import EmptyStateComponent from '@components/EmptyStateComponent'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import * as Expensicons from '@components/Icon/Expensicons'; import LottieAnimations from '@components/LottieAnimations'; import {useSession} from '@components/OnyxListItemProvider'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -18,6 +17,7 @@ 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'; @@ -26,7 +26,7 @@ import {canSubmitPerDiemExpenseFromWorkspace, getPerDiemCustomUnit} from '@libs/ import {isIOUReport} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; -import {createUnreportedExpenseSections, getMerchant, isPerDiemRequest} from '@libs/TransactionUtils'; +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'; @@ -116,12 +116,24 @@ function AddUnreportedExpense({route}: AddUnreportedExpensePageType) { } return tokenizedSearch(transactions, debouncedSearchValue, (transaction) => { + const searchableFields: string[] = []; + const merchant = getMerchant(transaction); - // Don't include transactions with placeholder merchant value in search - if (merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT) { - return []; + if (merchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT) { + searchableFields.push(merchant); + } + + const description = getDescription(transaction); + if (description.trim()) { + searchableFields.push(description); } - return [merchant]; + + const amount = getAmount(transaction); + const currency = getCurrency(transaction); + const formattedAmount = convertToDisplayString(amount, currency); + searchableFields.push(formattedAmount); + + return searchableFields; }); }, [transactions, debouncedSearchValue]); @@ -237,10 +249,9 @@ function AddUnreportedExpense({route}: AddUnreportedExpensePageType) { isSelected={(item) => selectedIds.has(item.transactionID)} shouldShowTextInput={shouldShowTextInput} textInputValue={searchValue} - textInputLabel={shouldShowTextInput ? translate('iou.findMerchant') : undefined} + textInputLabel={shouldShowTextInput ? translate('iou.findExpense') : undefined} onChangeText={setSearchValue} headerMessage={headerMessage} - textInputIcon={Expensicons.MagnifyingGlass} canSelectMultiple sections={sections} ListItem={UnreportedExpenseListItem} diff --git a/tests/unit/AddUnreportedExpenseSearchTest.ts b/tests/unit/AddUnreportedExpenseSearchTest.ts new file mode 100644 index 000000000000..cb24660e42ee --- /dev/null +++ b/tests/unit/AddUnreportedExpenseSearchTest.ts @@ -0,0 +1,166 @@ +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[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[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[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[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[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[0].transactionID).toBe('1'); + }); +}); From 0c18a7b5a862e693107ff2bf7ef0fb33501081a5 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Sun, 5 Oct 2025 22:03:24 +0800 Subject: [PATCH 6/6] fix: lint --- tests/unit/AddUnreportedExpenseSearchTest.ts | 60 ++++++++++++++------ 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/tests/unit/AddUnreportedExpenseSearchTest.ts b/tests/unit/AddUnreportedExpenseSearchTest.ts index cb24660e42ee..68d8f5e750ed 100644 --- a/tests/unit/AddUnreportedExpenseSearchTest.ts +++ b/tests/unit/AddUnreportedExpenseSearchTest.ts @@ -44,32 +44,56 @@ describe('AddUnreportedExpense Search Functionality', () => { 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'; + 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'; + 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; + 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'; + if (amount === 500) { + return '$5.00'; + } + if (amount === 2500) { + return '$25.00'; + } + if (amount === 15000) { + return '$150.00'; + } return '$0.00'; }); }); @@ -107,7 +131,7 @@ describe('AddUnreportedExpense Search Functionality', () => { const result = tokenizedSearch(transactions, searchTerm, getSearchableFields); expect(result).toHaveLength(1); - expect(result[0].transactionID).toBe('1'); + expect(result.at(0)?.transactionID).toBe('1'); }); it('should search by description', () => { @@ -115,7 +139,7 @@ describe('AddUnreportedExpense Search Functionality', () => { const result = tokenizedSearch(transactions, searchTerm, getSearchableFields); expect(result).toHaveLength(1); - expect(result[0].transactionID).toBe('1'); + expect(result.at(0)?.transactionID).toBe('1'); }); it('should search by amount', () => { @@ -123,7 +147,7 @@ describe('AddUnreportedExpense Search Functionality', () => { const result = tokenizedSearch(transactions, searchTerm, getSearchableFields); expect(result).toHaveLength(1); - expect(result[0].transactionID).toBe('2'); + expect(result.at(0)?.transactionID).toBe('2'); }); it('should search by partial terms', () => { @@ -131,7 +155,7 @@ describe('AddUnreportedExpense Search Functionality', () => { const result = tokenizedSearch(transactions, searchTerm, getSearchableFields); expect(result).toHaveLength(1); - expect(result[0].transactionID).toBe('3'); + expect(result.at(0)?.transactionID).toBe('3'); }); it('should search across multiple fields', () => { @@ -139,7 +163,7 @@ describe('AddUnreportedExpense Search Functionality', () => { const result = tokenizedSearch(transactions, searchTerm, getSearchableFields); expect(result).toHaveLength(1); - expect(result[0].transactionID).toBe('3'); + expect(result.at(0)?.transactionID).toBe('3'); }); it('should return all transactions when search term is empty', () => { @@ -161,6 +185,6 @@ describe('AddUnreportedExpense Search Functionality', () => { const result = tokenizedSearch(transactions, searchTerm, getSearchableFields); expect(result).toHaveLength(1); - expect(result[0].transactionID).toBe('1'); + expect(result.at(0)?.transactionID).toBe('1'); }); });