diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3bc77c33513e..caf7ef92073a 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6827,6 +6827,7 @@ const CONST = { FROM: 'from', CARD: 'card', WITHDRAWAL_ID: 'withdrawal-id', + MERCHANT: 'merchant', }, get TYPE_CUSTOM_COLUMNS() { return { @@ -6903,6 +6904,11 @@ const CONST = { EXPENSES: this.TABLE_COLUMNS.GROUP_EXPENSES, TOTAL: this.TABLE_COLUMNS.GROUP_TOTAL, }, + MERCHANT: { + MERCHANT: this.TABLE_COLUMNS.GROUP_MERCHANT, + EXPENSES: this.TABLE_COLUMNS.GROUP_EXPENSES, + TOTAL: this.TABLE_COLUMNS.GROUP_TOTAL, + }, }; }, get TYPE_DEFAULT_COLUMNS() { @@ -6944,6 +6950,7 @@ const CONST = { this.TABLE_COLUMNS.GROUP_EXPENSES, this.TABLE_COLUMNS.GROUP_TOTAL, ], + MERCHANT: [this.TABLE_COLUMNS.GROUP_MERCHANT, this.TABLE_COLUMNS.GROUP_EXPENSES, this.TABLE_COLUMNS.GROUP_TOTAL], }; }, BOOLEAN: { @@ -7039,6 +7046,7 @@ const CONST = { GROUP_BANK_ACCOUNT: 'groupBankAccount', GROUP_WITHDRAWN: 'groupWithdrawn', GROUP_WITHDRAWAL_ID: 'groupWithdrawalID', + GROUP_MERCHANT: 'groupMerchant', }, SYNTAX_OPERATORS: { AND: 'and', @@ -7219,6 +7227,7 @@ const CONST = { [this.TABLE_COLUMNS.GROUP_BANK_ACCOUNT]: 'group-bank-account', [this.TABLE_COLUMNS.GROUP_WITHDRAWN]: 'group-withdrawn', [this.TABLE_COLUMNS.GROUP_WITHDRAWAL_ID]: 'group-withdrawal-id', + [this.TABLE_COLUMNS.GROUP_MERCHANT]: 'group-merchant', }; }, NOT_MODIFIER: 'Not', @@ -7260,6 +7269,7 @@ const CONST = { UNAPPROVED_CARD: 'unapprovedCard', RECONCILIATION: 'reconciliation', TOP_SPENDERS: 'topSpenders', + TOP_MERCHANTS: 'topMerchants', }, GROUP_PREFIX: 'group_', ANIMATION: { diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 8f1b59636da5..c5d8fbe655b1 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -122,7 +122,8 @@ type SearchCustomColumnIds = | ValueOf | ValueOf | ValueOf - | ValueOf; + | ValueOf + | ValueOf; type SearchContextData = { currentSearchHash: number; diff --git a/src/components/SelectionListWithSections/Search/MerchantListItemHeader.tsx b/src/components/SelectionListWithSections/Search/MerchantListItemHeader.tsx new file mode 100644 index 000000000000..ad6a3b26c20d --- /dev/null +++ b/src/components/SelectionListWithSections/Search/MerchantListItemHeader.tsx @@ -0,0 +1,169 @@ +import React from 'react'; +import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import type {SearchColumnType} from '@components/Search/types'; +import type {ListItem, TransactionMerchantGroupListItemType} from '@components/SelectionListWithSections/types'; +import TextWithTooltip from '@components/TextWithTooltip'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import ExpandCollapseArrowButton from './ExpandCollapseArrowButton'; +import TextCell from './TextCell'; +import TotalCell from './TotalCell'; + +type MerchantListItemHeaderProps = { + /** The merchant group currently being looked at */ + merchant: TransactionMerchantGroupListItemType; + + /** Callback to fire when a checkbox is pressed */ + onCheckboxPress?: (item: TItem) => void; + + /** Whether this section items disabled for selection */ + isDisabled?: boolean | null; + + /** Whether selecting multiple transactions at once is allowed */ + canSelectMultiple: boolean | undefined; + + /** Whether all transactions are selected */ + isSelectAllChecked?: boolean; + + /** Whether only some transactions are selected */ + isIndeterminate?: boolean; + + /** Callback for when the down arrow is clicked */ + onDownArrowClick?: () => void; + + /** Whether the down arrow is expanded */ + isExpanded?: boolean; + + /** The visible columns for the header */ + columns?: SearchColumnType[]; +}; + +function MerchantListItemHeader({ + merchant: merchantItem, + onCheckboxPress, + isDisabled, + canSelectMultiple, + isSelectAllChecked, + isIndeterminate, + isExpanded, + onDownArrowClick, + columns, +}: MerchantListItemHeaderProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const theme = useTheme(); + const {isLargeScreenWidth} = useResponsiveLayout(); + const {translate} = useLocalize(); + const formattedMerchant = merchantItem.formattedMerchant ?? merchantItem.merchant; + + const columnComponents = { + [CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT]: ( + + + + + + ), + [CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES]: ( + + + + ), + [CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL]: ( + + + + ), + }; + + return ( + + + + {!!canSelectMultiple && ( + onCheckboxPress?.(merchantItem as unknown as TItem)} + isChecked={isSelectAllChecked} + isIndeterminate={isIndeterminate} + disabled={!!isDisabled || merchantItem.isDisabledCheckbox} + accessibilityLabel={translate('common.select')} + style={isLargeScreenWidth && styles.mr1} + /> + )} + {!isLargeScreenWidth && ( + + + + + + + + + )} + {isLargeScreenWidth && ( + <> + + + + + {columns?.map((column) => columnComponents[column as keyof typeof columnComponents])} + + )} + + {!isLargeScreenWidth && ( + + + {!!onDownArrowClick && ( + + )} + + )} + + + ); +} + +MerchantListItemHeader.displayName = 'MerchantListItemHeader'; + +export default MerchantListItemHeader; diff --git a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx index bf5d45394495..fd6ff6b16c16 100644 --- a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx @@ -17,6 +17,7 @@ import type { TransactionGroupListItemType, TransactionListItemType, TransactionMemberGroupListItemType, + TransactionMerchantGroupListItemType, TransactionReportGroupListItemType, TransactionWithdrawalIDGroupListItemType, } from '@components/SelectionListWithSections/types'; @@ -40,6 +41,7 @@ import {isActionLoadingSetSelector} from '@src/selectors/ReportMetaData'; import type {ReportAction, ReportActions} from '@src/types/onyx'; import CardListItemHeader from './CardListItemHeader'; import MemberListItemHeader from './MemberListItemHeader'; +import MerchantListItemHeader from './MerchantListItemHeader'; import ReportListItemHeader from './ReportListItemHeader'; import TransactionGroupListExpandedItem from './TransactionGroupListExpanded'; import WithdrawalIDListItemHeader from './WithdrawalIDListItemHeader'; @@ -195,8 +197,11 @@ function TransactionGroupListItem({ setIsExpanded(!isExpanded); if (isExpanded) { setTransactionsVisibleLimit(CONST.TRANSACTION.RESULTS_PAGE_SIZE); + } else { + // Fetch transactions when expanding the group + searchTransactions(); } - }, [isExpanded]); + }, [isExpanded, searchTransactions]); const onPress = useCallback(() => { if (isExpenseReportType || transactions.length === 0) { @@ -280,6 +285,19 @@ function TransactionGroupListItem({ isExpanded={isExpanded} /> ), + [CONST.SEARCH.GROUP_BY.MERCHANT]: ( + + ), }; if (searchType === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { diff --git a/src/components/SelectionListWithSections/SearchTableHeader.tsx b/src/components/SelectionListWithSections/SearchTableHeader.tsx index 42b7cf9f8129..1426f396a5f8 100644 --- a/src/components/SelectionListWithSections/SearchTableHeader.tsx +++ b/src/components/SelectionListWithSections/SearchTableHeader.tsx @@ -22,6 +22,7 @@ type SearchHeaderIcons = { Profile?: IconAsset; CreditCard?: IconAsset; Bank?: IconAsset; + Receipt?: IconAsset; }; const getExpenseHeaders = (groupBy?: SearchGroupBy): SearchColumnConfig[] => [ @@ -337,6 +338,27 @@ const getTransactionGroupHeaders = (groupBy: SearchGroupBy, icons: SearchHeaderI translationKey: 'common.total', }, ]; + case CONST.SEARCH.GROUP_BY.MERCHANT: + return [ + { + columnName: CONST.SEARCH.TABLE_COLUMNS.AVATAR, + translationKey: undefined, + icon: icons.Receipt, + isColumnSortable: false, + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT, + translationKey: 'common.merchant', + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES, + translationKey: 'common.expenses', + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL, + translationKey: 'common.total', + }, + ]; default: return []; } @@ -407,8 +429,11 @@ function SearchTableHeader({ const {isSmallScreenWidth, isMediumScreenWidth} = useResponsiveLayout(); const displayNarrowVersion = isMediumScreenWidth || isSmallScreenWidth; + // Convert MERCHANT sortBy to GROUP_MERCHANT for merchant grouping so the sort arrow appears on the correct column + const displaySortBy = groupBy === CONST.SEARCH.GROUP_BY.MERCHANT && sortBy === CONST.SEARCH.TABLE_COLUMNS.MERCHANT ? CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT : sortBy; + // Only load Profile icon when it's needed for EXPENSE_REPORT type or grouped transactions - const icons = useMemoizedLazyExpensifyIcons(type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || !!groupBy ? ['Profile', 'Bank', 'CreditCard'] : []) satisfies SearchHeaderIcons; + const icons = useMemoizedLazyExpensifyIcons(type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || !!groupBy ? ['Profile', 'Bank', 'CreditCard', 'Receipt'] : []) satisfies SearchHeaderIcons; const shouldShowColumn = useCallback( (columnName: SortableColumnName) => { @@ -469,7 +494,7 @@ function SearchTableHeader({ amountColumnSize={isAmountColumnWide ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL} taxAmountColumnSize={isTaxAmountColumnWide ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL} shouldShowSorting={shouldShowSorting} - sortBy={sortBy} + sortBy={displaySortBy} sortOrder={sortOrder} // In GroupBy views, disable flex expansion for Total columns so Expenses column gets more space shouldRemoveTotalColumnFlex={!!groupBy && !isExpenseReportView} @@ -479,7 +504,9 @@ function SearchTableHeader({ if (columnName === CONST.SEARCH.TABLE_COLUMNS.COMMENTS) { return; } - onSortPress(columnName, order); + // Convert GROUP_MERCHANT to MERCHANT for sorting to avoid crashes + const sortColumn = columnName === CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT ? CONST.SEARCH.TABLE_COLUMNS.MERCHANT : columnName; + onSortPress(sortColumn, order); }} /> ); diff --git a/src/components/SelectionListWithSections/types.ts b/src/components/SelectionListWithSections/types.ts index 15464aebe076..5e2fae527edf 100644 --- a/src/components/SelectionListWithSections/types.ts +++ b/src/components/SelectionListWithSections/types.ts @@ -28,7 +28,7 @@ import type CONST from '@src/CONST'; import type {PersonalDetails, PersonalDetailsList, Policy, Report, ReportAction, SearchResults, TransactionViolation, TransactionViolations} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {SearchCardGroup, SearchDataTypes, SearchMemberGroup, SearchTask, SearchTransactionAction, SearchWithdrawalIDGroup} from '@src/types/onyx/SearchResults'; +import type {SearchCardGroup, SearchDataTypes, SearchMemberGroup, SearchMerchantGroup, SearchTask, SearchTransactionAction, SearchWithdrawalIDGroup} from '@src/types/onyx/SearchResults'; import type {ReceiptErrors} from '@src/types/onyx/Transaction'; import type Transaction from '@src/types/onyx/Transaction'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; @@ -489,6 +489,11 @@ type TransactionWithdrawalIDGroupListItemType = TransactionGroupListItemType & { formattedWithdrawalID?: string; }; +type TransactionMerchantGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.MERCHANT} & SearchMerchantGroup & { + /** Final and formatted "merchant" value used for displaying and sorting */ + formattedMerchant?: string; + }; + type ListItemProps = CommonListItemProps & { /** The section list item */ item: TItem; @@ -1128,6 +1133,7 @@ export type { TransactionGroupListItemType, TransactionReportGroupListItemType, TransactionMemberGroupListItemType, + TransactionMerchantGroupListItemType, TransactionCardGroupListItemType, TransactionWithdrawalIDGroupListItemType, Section, diff --git a/src/languages/de.ts b/src/languages/de.ts index 336bf871e906..49304e3268ba 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6888,6 +6888,7 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard [CONST.SEARCH.GROUP_BY.FROM]: 'Von', [CONST.SEARCH.GROUP_BY.CARD]: 'Karte', [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'Auszahlungs-ID', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Händler', }, feed: 'Feed', withdrawalType: { @@ -6927,6 +6928,7 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard allMatchingItemsSelected: 'Alle passenden Elemente ausgewählt', }, topSpenders: 'Top-Ausgaben', + topMerchants: 'Top-Händler', }, genericErrorPage: { title: 'Oh je, etwas ist schiefgelaufen!', diff --git a/src/languages/en.ts b/src/languages/en.ts index 200e8335566f..9460bd108c0c 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6708,6 +6708,7 @@ const translations = { unapprovedCard: 'Unapproved card', reconciliation: 'Reconciliation', topSpenders: 'Top spenders', + topMerchants: 'Top merchants', saveSearch: 'Save search', deleteSavedSearch: 'Delete saved search', deleteSavedSearchConfirm: 'Are you sure you want to delete this search?', @@ -6772,6 +6773,7 @@ const translations = { [CONST.SEARCH.GROUP_BY.FROM]: 'From', [CONST.SEARCH.GROUP_BY.CARD]: 'Card', [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'Withdrawal ID', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Merchant', }, feed: 'Feed', withdrawalType: { diff --git a/src/languages/es.ts b/src/languages/es.ts index a988d3c80d67..b8a79e4d8f1f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6458,6 +6458,7 @@ ${amount} para ${merchant} - ${date}`, unapprovedCard: 'Tarjeta no aprobada', reconciliation: 'Conciliación', topSpenders: 'Mayores gastadores', + topMerchants: 'Principales comercios', saveSearch: 'Guardar búsqueda', savedSearchesMenuItemTitle: 'Guardadas', searchName: 'Nombre de la búsqueda', @@ -6520,6 +6521,7 @@ ${amount} para ${merchant} - ${date}`, [CONST.SEARCH.GROUP_BY.FROM]: 'De', [CONST.SEARCH.GROUP_BY.CARD]: 'Tarjeta', [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'ID de retiro', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Comerciante', }, feed: 'Feed', withdrawalType: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c80f7a1d5879..24c71d33a67e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6900,6 +6900,7 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin [CONST.SEARCH.GROUP_BY.FROM]: 'De', [CONST.SEARCH.GROUP_BY.CARD]: 'Carte', [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'ID de retrait', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Marchand', }, feed: 'Flux', withdrawalType: { @@ -6939,6 +6940,7 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin allMatchingItemsSelected: 'Tous les éléments correspondants sont sélectionnés', }, topSpenders: 'Plus gros dépensiers', + topMerchants: 'Principaux marchands', }, genericErrorPage: { title: 'Oh oh, quelque chose s’est mal passé !', diff --git a/src/languages/it.ts b/src/languages/it.ts index c466864413ba..c62e42da30a8 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6879,6 +6879,7 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori [CONST.SEARCH.GROUP_BY.FROM]: 'Da', [CONST.SEARCH.GROUP_BY.CARD]: 'Carta', [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'ID prelievo', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Esercente', }, feed: 'Feed', withdrawalType: { @@ -6918,6 +6919,7 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori allMatchingItemsSelected: 'Tutti gli elementi corrispondenti selezionati', }, topSpenders: 'Maggiori spenditori', + topMerchants: 'Principali esercenti', }, genericErrorPage: { title: 'Uh-oh, qualcosa è andato storto!', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index cbe74fca2668..689cad184311 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6820,6 +6820,7 @@ ${reportName} [CONST.SEARCH.GROUP_BY.FROM]: '差出人', [CONST.SEARCH.GROUP_BY.CARD]: 'カード', [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: '出金ID', + [CONST.SEARCH.GROUP_BY.MERCHANT]: '加盟店', }, feed: 'フィード', withdrawalType: { @@ -6859,6 +6860,7 @@ ${reportName} allMatchingItemsSelected: '一致する項目をすべて選択済み', }, topSpenders: 'トップ支出者', + topMerchants: 'トップ加盟店', }, genericErrorPage: { title: 'おっと、問題が発生しました!', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d07a6f98c087..6736a9850d73 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6860,6 +6860,7 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten [CONST.SEARCH.GROUP_BY.FROM]: 'Van', [CONST.SEARCH.GROUP_BY.CARD]: 'Kaart', [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'Opname-ID', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Handelaar', }, feed: 'Feed', withdrawalType: { @@ -6899,6 +6900,7 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten allMatchingItemsSelected: 'Alle overeenkomende items geselecteerd', }, topSpenders: 'Grootste uitgaven', + topMerchants: 'Top handelaren', }, genericErrorPage: { title: 'O jee, er is iets misgegaan!', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 08e2d8bb294c..0a08fb1bc823 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6850,6 +6850,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i [CONST.SEARCH.GROUP_BY.FROM]: 'Od', [CONST.SEARCH.GROUP_BY.CARD]: 'Karta', [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'ID wypłaty', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Kupiec', }, feed: 'Kanał', withdrawalType: { @@ -6889,6 +6890,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i allMatchingItemsSelected: 'Wybrano wszystkie pasujące elementy', }, topSpenders: 'Najwięksi wydający', + topMerchants: 'Najlepsi handlowcy', }, genericErrorPage: { title: 'Ups, coś poszło nie tak!', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index cfcc64ebc274..5af49cfd16d5 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6852,6 +6852,7 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe [CONST.SEARCH.GROUP_BY.FROM]: 'De', [CONST.SEARCH.GROUP_BY.CARD]: 'Cartão', [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'ID de saque', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Comerciante', }, feed: 'Feed', withdrawalType: { @@ -6891,6 +6892,7 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe allMatchingItemsSelected: 'Todos os itens correspondentes selecionados', }, topSpenders: 'Maiores gastadores', + topMerchants: 'Principais comerciantes', }, genericErrorPage: { title: 'Opa, algo deu errado!', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index c38247687c9a..a488f7d14d04 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6637,6 +6637,8 @@ ${reportName} unapprovedCash: '未批准的现金', unapprovedCard: '未批准的卡片', reconciliation: '对账', + topSpenders: '消费最多者', + topMerchants: '消费最多的商家', saveSearch: '保存搜索', deleteSavedSearch: '删除已保存的搜索', deleteSavedSearchConfirm: '您确定要删除此搜索吗?', @@ -6701,6 +6703,7 @@ ${reportName} [CONST.SEARCH.GROUP_BY.FROM]: '从', [CONST.SEARCH.GROUP_BY.CARD]: '卡片', [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: '提款 ID', + [CONST.SEARCH.GROUP_BY.MERCHANT]: '商户', }, feed: '动态', withdrawalType: { @@ -6739,7 +6742,6 @@ ${reportName} selectAllMatchingItems: '选择所有匹配的项目', allMatchingItemsSelected: '已选择所有匹配的项目', }, - topSpenders: '最高支出者', }, genericErrorPage: { title: '哎呀,出错了!', diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 22123ee77f50..aea25a524505 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -35,6 +35,7 @@ import type { TransactionGroupListItemType, TransactionListItemType, TransactionMemberGroupListItemType, + TransactionMerchantGroupListItemType, TransactionReportGroupListItemType, TransactionWithdrawalIDGroupListItemType, } from '@components/SelectionListWithSections/types'; @@ -54,6 +55,7 @@ import type { SearchCardGroup, SearchDataTypes, SearchMemberGroup, + SearchMerchantGroup, SearchTask, SearchTransactionAction, SearchWithdrawalIDGroup, @@ -145,6 +147,7 @@ type ExpenseReportSorting = ColumnSortMapping; type TransactionMemberGroupSorting = ColumnSortMapping; type TransactionCardGroupSorting = ColumnSortMapping; type TransactionWithdrawalIDGroupSorting = ColumnSortMapping; +type TransactionMerchantGroupSorting = ColumnSortMapping; type GetReportSectionsParams = { data: OnyxTypes.SearchResults['data']; @@ -229,6 +232,14 @@ const transactionWithdrawalIDGroupColumnNamesToSortingProperty: TransactionWithd [CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL]: 'total' as const, }; +const transactionMerchantGroupColumnNamesToSortingProperty: TransactionMerchantGroupSorting = { + [CONST.SEARCH.TABLE_COLUMNS.AVATAR]: null, + [CONST.SEARCH.TABLE_COLUMNS.MERCHANT]: 'formattedMerchant' as const, + [CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT]: 'formattedMerchant' as const, + [CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES]: 'count' as const, + [CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL]: 'total' as const, +}; + const expenseStatusActionMapping = { [CONST.SEARCH.STATUS.EXPENSE.DRAFTS]: (expenseReport?: OnyxTypes.Report) => expenseReport?.stateNum === CONST.REPORT.STATE_NUM.OPEN && expenseReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN, @@ -630,6 +641,32 @@ function getSuggestedSearches( return this.searchQueryJSON?.similarSearchHash ?? CONST.DEFAULT_NUMBER_ID; }, }, + [CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS]: { + key: CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS, + translationPath: 'search.topMerchants', + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + icon: Expensicons.Receipt, + searchQuery: buildQueryStringFromFilterFormValues( + { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + dateOn: CONST.SEARCH.DATE_PRESETS.LAST_MONTH, + }, + { + sortBy: CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + }, + ), + get searchQueryJSON() { + return buildSearchQueryJSON(this.searchQuery); + }, + get hash() { + return this.searchQueryJSON?.hash ?? CONST.DEFAULT_NUMBER_ID; + }, + get similarSearchHash() { + return this.searchQueryJSON?.similarSearchHash ?? CONST.DEFAULT_NUMBER_ID; + }, + }, }; } @@ -652,6 +689,7 @@ function getSuggestedSearchesVisibility( let shouldShowUnapprovedCardSuggestion = false; let shouldShowReconciliationSuggestion = false; let shouldShowTopSpendersSuggestion = false; + let shouldShowTopMerchantsSuggestion = false; const hasCardFeed = Object.values(cardFeedsByPolicy ?? {}).some((feeds) => feeds.length > 0); @@ -688,6 +726,7 @@ function getSuggestedSearchesVisibility( const isEligibleForReconciliationSuggestion = isPaidPolicy && isAdmin && ((isPaymentEnabled && hasVBBA && hasReimburser) || isECardEnabled); const isAuditor = policy.role === CONST.POLICY.ROLE.AUDITOR; const isEligibleForTopSpendersSuggestion = isPaidPolicy && (isAdmin || isAuditor || isApprover); + const isEligibleForTopMerchantsSuggestion = isPaidPolicy; shouldShowSubmitSuggestion ||= isEligibleForSubmitSuggestion; shouldShowPaySuggestion ||= isEligibleForPaySuggestion; @@ -698,6 +737,7 @@ function getSuggestedSearchesVisibility( shouldShowUnapprovedCardSuggestion ||= isEligibleForUnapprovedCardSuggestion; shouldShowReconciliationSuggestion ||= isEligibleForReconciliationSuggestion; shouldShowTopSpendersSuggestion ||= isEligibleForTopSpendersSuggestion; + shouldShowTopMerchantsSuggestion ||= isEligibleForTopMerchantsSuggestion; // We don't need to check the rest of the policies if we already determined that all suggestions should be displayed return ( @@ -709,7 +749,8 @@ function getSuggestedSearchesVisibility( shouldShowUnapprovedCashSuggestion && shouldShowUnapprovedCardSuggestion && shouldShowReconciliationSuggestion && - shouldShowTopSpendersSuggestion + shouldShowTopSpendersSuggestion && + shouldShowTopMerchantsSuggestion ); }); @@ -726,6 +767,7 @@ function getSuggestedSearchesVisibility( [CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CARD]: shouldShowUnapprovedCardSuggestion, [CONST.SEARCH.SEARCH_KEYS.RECONCILIATION]: shouldShowReconciliationSuggestion, [CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS]: shouldShowTopSpendersSuggestion, + [CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS]: shouldShowTopMerchantsSuggestion, }; } @@ -865,6 +907,13 @@ function isTransactionWithdrawalIDGroupListItemType(item: ListItem): item is Tra return isTransactionGroupListItemType(item) && 'groupedBy' in item && item.groupedBy === CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID; } +/** + * Type guard that checks if something is a TransactionMerchantGroupListItemType + */ +function isTransactionMerchantGroupListItemType(item: ListItem): item is TransactionMerchantGroupListItemType { + return isTransactionGroupListItemType(item) && 'groupedBy' in item && item.groupedBy === CONST.SEARCH.GROUP_BY.MERCHANT; +} + /** * Type guard that checks if something is a TransactionListItemType */ @@ -2066,6 +2115,41 @@ function getWithdrawalIDSections(data: OnyxTypes.SearchResults['data'], queryJSO return [withdrawalIDSectionsValues, withdrawalIDSectionsValues.length]; } +/** + * @private + * Organizes data into List Sections grouped by merchant for display, for the TransactionMerchantGroupListItemType of Search Results. + * + * Do not use directly, use only via `getSections()` facade. + */ +function getMerchantSections(data: OnyxTypes.SearchResults['data'], queryJSON: SearchQueryJSON | undefined): [TransactionMerchantGroupListItemType[], number] { + const merchantSections: Record = {}; + + for (const key in data) { + if (isGroupEntry(key)) { + const merchantGroup = data[key] as SearchMerchantGroup; + let transactionsQueryJSON: SearchQueryJSON | undefined; + if (queryJSON && merchantGroup.merchant) { + const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT); + newFlatFilters.push({key: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: merchantGroup.merchant}]}); + const newQueryJSON: SearchQueryJSON = {...queryJSON, groupBy: undefined, flatFilters: newFlatFilters}; + const newQuery = buildSearchQueryString(newQueryJSON); + transactionsQueryJSON = buildSearchQueryJSON(newQuery); + } + + merchantSections[key] = { + groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, + transactions: [], + transactionsQueryJSON, + ...merchantGroup, + formattedMerchant: merchantGroup.merchant || '', + }; + } + } + + const merchantSectionsValues = Object.values(merchantSections); + return [merchantSectionsValues, merchantSectionsValues.length]; +} + /** * Returns the appropriate list item component based on the type and status of the search data. */ @@ -2139,6 +2223,8 @@ function getSections({ return getCardSections(data, queryJSON, translate, cardFeeds); case CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID: return getWithdrawalIDSections(data, queryJSON); + case CONST.SEARCH.GROUP_BY.MERCHANT: + return getMerchantSections(data, queryJSON); } } @@ -2178,6 +2264,8 @@ function getSortedSections( return getSortedCardData(data as TransactionCardGroupListItemType[], localeCompare, sortBy, sortOrder); case CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID: return getSortedWithdrawalIDData(data as TransactionWithdrawalIDGroupListItemType[], localeCompare, sortBy, sortOrder); + case CONST.SEARCH.GROUP_BY.MERCHANT: + return getSortedMerchantData(data as TransactionMerchantGroupListItemType[], localeCompare, sortBy, sortOrder); } } @@ -2534,6 +2622,21 @@ function getSortedWithdrawalIDData(data: TransactionWithdrawalIDGroupListItemTyp return getSortedData(data, localeCompare, transactionWithdrawalIDGroupColumnNamesToSortingProperty, (a, b) => localeCompare(a.debitPosted, b.debitPosted), sortBy, sortOrder); } +/** + * @private + * Sorts merchant sections based on a specified column and sort order. + */ +function getSortedMerchantData(data: TransactionMerchantGroupListItemType[], localeCompare: LocaleContextProps['localeCompare'], sortBy?: SearchColumnType, sortOrder?: SortOrder) { + return getSortedData( + data, + localeCompare, + transactionMerchantGroupColumnNamesToSortingProperty, + (a, b) => localeCompare(a.formattedMerchant ?? '', b.formattedMerchant ?? ''), + sortBy, + sortOrder, + ); +} + /** * @private * Sorts report actions sections based on a specified column and sort order. @@ -2604,6 +2707,8 @@ function getCustomColumns(value?: SearchDataTypes | SearchGroupBy): SearchCustom return Object.values(CONST.SEARCH.GROUP_CUSTOM_COLUMNS.FROM); case CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID: return Object.values(CONST.SEARCH.GROUP_CUSTOM_COLUMNS.WITHDRAWAL_ID); + case CONST.SEARCH.GROUP_BY.MERCHANT: + return Object.values(CONST.SEARCH.GROUP_CUSTOM_COLUMNS.MERCHANT); default: return []; } @@ -2629,12 +2734,14 @@ function getCustomColumnDefault(value?: SearchDataTypes | SearchGroupBy): Search return CONST.SEARCH.GROUP_DEFAULT_COLUMNS.FROM; case CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID: return CONST.SEARCH.GROUP_DEFAULT_COLUMNS.WITHDRAWAL_ID; + case CONST.SEARCH.GROUP_BY.MERCHANT: + return CONST.SEARCH.GROUP_DEFAULT_COLUMNS.MERCHANT; default: return []; } } -function getSearchColumnTranslationKey(columnId: SearchCustomColumnIds): TranslationPaths { +function getSearchColumnTranslationKey(columnId: SearchCustomColumnIds): TranslationPaths | undefined { // eslint-disable-next-line default-case switch (columnId) { case CONST.SEARCH.TABLE_COLUMNS.DATE: @@ -2713,6 +2820,8 @@ function getSearchColumnTranslationKey(columnId: SearchCustomColumnIds): Transla return 'search.filters.feed'; case CONST.SEARCH.TABLE_COLUMNS.EXPORTED_TO: return 'search.exportedTo'; + case CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT: + return 'common.merchant'; } } @@ -2943,6 +3052,16 @@ function createTypeMenuSections( }); } + if (suggestedSearchesVisibility[CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS]) { + insightsSection.menuItems.push({ + ...suggestedSearches[CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS], + emptyState: { + title: 'search.searchResults.emptyResults.title', + subtitle: 'search.searchResults.emptyResults.subtitle', + }, + }); + } + if (insightsSection.menuItems.length > 0) { typeMenuSections.push(insightsSection); } @@ -3176,12 +3295,14 @@ function getColumnsToShow( [CONST.SEARCH.GROUP_BY.CARD]: CONST.SEARCH.GROUP_CUSTOM_COLUMNS.CARD, [CONST.SEARCH.GROUP_BY.FROM]: CONST.SEARCH.GROUP_CUSTOM_COLUMNS.FROM, [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: CONST.SEARCH.GROUP_CUSTOM_COLUMNS.WITHDRAWAL_ID, + [CONST.SEARCH.GROUP_BY.MERCHANT]: CONST.SEARCH.GROUP_CUSTOM_COLUMNS.MERCHANT, }[groupBy]; const defaultCustomColumns = { [CONST.SEARCH.GROUP_BY.CARD]: CONST.SEARCH.GROUP_DEFAULT_COLUMNS.CARD, [CONST.SEARCH.GROUP_BY.FROM]: CONST.SEARCH.GROUP_DEFAULT_COLUMNS.FROM, [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: CONST.SEARCH.GROUP_DEFAULT_COLUMNS.WITHDRAWAL_ID, + [CONST.SEARCH.GROUP_BY.MERCHANT]: CONST.SEARCH.GROUP_DEFAULT_COLUMNS.MERCHANT, }[groupBy]; const filteredVisibleColumns = visibleColumns.filter((column) => Object.values(customColumns).includes(column as ValueOf)); @@ -3237,6 +3358,23 @@ function getColumnsToShow( return result; } + + if (groupBy === CONST.SEARCH.GROUP_BY.MERCHANT) { + const requiredColumns = new Set([CONST.SEARCH.TABLE_COLUMNS.AVATAR, CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT]); + const result: SearchColumnType[] = []; + + for (const col of requiredColumns) { + if (!columnsToShow.includes(col as SearchCustomColumnIds)) { + result.push(col); + } + } + + for (const col of columnsToShow) { + result.push(col); + } + + return result; + } } const columns: ColumnVisibility = isExpenseReportView @@ -3519,6 +3657,7 @@ export { isTransactionMemberGroupListItemType, isTransactionCardGroupListItemType, isTransactionWithdrawalIDGroupListItemType, + isTransactionMerchantGroupListItemType, isSearchResultsEmpty, isTransactionListItemType, isReportActionListItemType, diff --git a/src/pages/Search/SearchColumnsPage.tsx b/src/pages/Search/SearchColumnsPage.tsx index a0e4b181d35d..b691b6b74a36 100644 --- a/src/pages/Search/SearchColumnsPage.tsx +++ b/src/pages/Search/SearchColumnsPage.tsx @@ -61,6 +61,7 @@ function SearchColumnsPage() { CONST.SEARCH.TABLE_COLUMNS.GROUP_CARD, CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWAL_ID, CONST.SEARCH.TABLE_COLUMNS.GROUP_FROM, + CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT, ]); const sortColumns = (columnsToSort: ColumnItem[]): ColumnItem[] => { @@ -68,8 +69,10 @@ function SearchColumnsPage() { const unselected = columnsToSort .filter((col) => !col.isSelected) .sort((a, b) => { - const textA = translate(getSearchColumnTranslationKey(a.value)); - const textB = translate(getSearchColumnTranslationKey(b.value)); + const keyA = getSearchColumnTranslationKey(a.value); + const keyB = getSearchColumnTranslationKey(b.value); + const textA = keyA ? translate(keyA) : ''; + const textB = keyB ? translate(keyB) : ''; return localeCompare(textA, textB); }); return [...selected, ...unselected]; @@ -100,8 +103,9 @@ function SearchColumnsPage() { const isRequired = requiredColumns.has(columnId); const isEffectivelySelected = isRequired || isSelected; const isDragDisabled = !isEffectivelySelected; + const translationKey = getSearchColumnTranslationKey(columnId); return { - text: translate(getSearchColumnTranslationKey(columnId)), + text: translationKey ? translate(translationKey) : '', value: columnId, keyForList: columnId, isSelected: isEffectivelySelected, @@ -147,8 +151,10 @@ function SearchColumnsPage() { const selected = prevColumns.filter((col) => col.isSelected); const unselected = prevColumns.filter((col) => !col.isSelected && col.columnId !== updatedColumnId); const unselectedSorted = unselected.sort((a, b) => { - const textA = translate(getSearchColumnTranslationKey(a.columnId)); - const textB = translate(getSearchColumnTranslationKey(b.columnId)); + const keyA = getSearchColumnTranslationKey(a.columnId); + const keyB = getSearchColumnTranslationKey(b.columnId); + const textA = keyA ? translate(keyA) : ''; + const textB = keyB ? translate(keyB) : ''; return localeCompare(textA, textB); }); return [...selected, {columnId: updatedColumnId, isSelected: true}, ...unselectedSorted]; diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index c711db8944e3..8c426d664ab4 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -172,6 +172,21 @@ type SearchWithdrawalIDGroup = { state: number; }; +/** Model of merchant grouped search result */ +type SearchMerchantGroup = { + /** Merchant name */ + merchant: string; + + /** Number of transactions */ + count: number; + + /** Total value of transactions */ + total: number; + + /** Currency of total value */ + currency: string; +}; + /** Model of search results */ type SearchResults = { /** Current search results state */ @@ -186,7 +201,7 @@ type SearchResults = { PrefixedRecord & PrefixedRecord & PrefixedRecord & - PrefixedRecord; + PrefixedRecord; /** Whether search data is being fetched from server */ isLoading?: boolean; @@ -197,4 +212,15 @@ type SearchResults = { export default SearchResults; -export type {ListItemType, ListItemDataType, SearchTask, SearchTransactionAction, SearchDataTypes, SearchResultsInfo, SearchMemberGroup, SearchCardGroup, SearchWithdrawalIDGroup}; +export type { + ListItemType, + ListItemDataType, + SearchTask, + SearchTransactionAction, + SearchDataTypes, + SearchResultsInfo, + SearchMemberGroup, + SearchCardGroup, + SearchWithdrawalIDGroup, + SearchMerchantGroup, +}; diff --git a/tests/unit/Search/MerchantListItemHeaderTest.tsx b/tests/unit/Search/MerchantListItemHeaderTest.tsx new file mode 100644 index 000000000000..169524d16a8d --- /dev/null +++ b/tests/unit/Search/MerchantListItemHeaderTest.tsx @@ -0,0 +1,205 @@ +import {render, screen} from '@testing-library/react-native'; +import React from 'react'; +import MerchantListItemHeader from '@components/SelectionListWithSections/Search/MerchantListItemHeader'; +import type {TransactionMerchantGroupListItemType} from '@components/SelectionListWithSections/types'; +import CONST from '@src/CONST'; + +jest.mock('@components/Checkbox', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, react/function-component-definition -- test mock + const MockCheckbox = ({onPress, isChecked, disabled, accessibilityLabel}: any) => { + // eslint-disable-next-line no-restricted-imports, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access -- using RN primitives in test + const {Pressable, Text} = require('react-native'); + return ( + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- test mock + + {isChecked ? 'checked' : 'unchecked'} + + ); + }; + return MockCheckbox; +}); + +jest.mock('@components/Icon', () => () => null); + +jest.mock('@components/TextWithTooltip', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, react/function-component-definition -- test mock + const MockTextWithTooltip = ({text}: any) => { + // eslint-disable-next-line no-restricted-imports, @typescript-eslint/no-unsafe-assignment -- test mock + const {Text} = require('react-native'); + return {text}; + }; + return MockTextWithTooltip; +}); + +jest.mock('@components/SelectionListWithSections/Search/TextCell', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, react/function-component-definition -- test mock + const MockTextCell = ({text}: any) => { + // eslint-disable-next-line no-restricted-imports, @typescript-eslint/no-unsafe-assignment -- test mock + const {Text} = require('react-native'); + return {text}; + }; + return MockTextCell; +}); + +jest.mock('@components/SelectionListWithSections/Search/TotalCell', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, react/function-component-definition -- test mock + const MockTotalCell = ({total}: any) => { + // eslint-disable-next-line no-restricted-imports, @typescript-eslint/no-unsafe-assignment -- test mock + const {Text} = require('react-native'); + return {`$${(total / 100).toFixed(2)}`}; + }; + return MockTotalCell; +}); + +jest.mock('@components/SelectionListWithSections/Search/ExpandCollapseArrowButton', () => () => null); + +jest.mock('@hooks/useLocalize', () => () => ({ + translate: (key: string) => key, +})); + +jest.mock('@hooks/useResponsiveLayout', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- CJS interop + __esModule: true, + default: jest.fn(() => ({ + isLargeScreenWidth: false, + isSmallScreenWidth: false, + isMediumScreenWidth: false, + shouldUseNarrowLayout: false, + })), +})); + +jest.mock('@hooks/useTheme', () => () => ({ + icon: '#000000', +})); + +jest.mock('@hooks/useThemeStyles', () => () => ({})); + +jest.mock('@hooks/useStyleUtils', () => () => ({ + getReportTableColumnStyles: () => ({}), + getIconWidthAndHeightStyle: () => ({width: 36, height: 36}), + getWidthAndHeightStyle: () => ({}), +})); + +describe('MerchantListItemHeader', () => { + const mockMerchant: TransactionMerchantGroupListItemType = { + merchant: 'Test Merchant', + formattedMerchant: 'Test Merchant', + total: 10000, + currency: 'USD', + count: 5, + keyForList: 'test-merchant', + transactions: [], + groupedBy: 'merchant', + }; + + it('should render merchant name', () => { + render( + , + ); + + expect(screen.getByText('Test Merchant')).toBeTruthy(); + }); + + it('should render checkbox when canSelectMultiple is true', () => { + render( + , + ); + + expect(screen.getByLabelText('common.select')).toBeTruthy(); + }); + + it('should not render checkbox when canSelectMultiple is false', () => { + render( + , + ); + + expect(screen.queryByLabelText('common.select')).toBeNull(); + }); + + it('should use formattedMerchant when available', () => { + const merchantWithFormatted: TransactionMerchantGroupListItemType = { + ...mockMerchant, + merchant: 'Raw Merchant', + formattedMerchant: 'Formatted Merchant', + }; + + render( + , + ); + + expect(screen.getByText('Formatted Merchant')).toBeTruthy(); + }); + + it('should handle disabled state', () => { + render( + , + ); + + const checkbox = screen.getByLabelText('common.select'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- test props check + expect(checkbox.props.accessibilityState?.disabled).toBe(true); + }); + + it('should handle checked state', () => { + render( + , + ); + + const checkbox = screen.getByLabelText('common.select'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- test props check + expect(checkbox.props.accessibilityState?.checked).toBe(true); + }); + + it('should render expense count in large screen columns', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access -- test mock + const mockUseResponsiveLayout = jest.requireMock('@hooks/useResponsiveLayout').default; + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access -- test mock + mockUseResponsiveLayout.mockReturnValueOnce({ + isLargeScreenWidth: true, + isSmallScreenWidth: false, + isMediumScreenWidth: false, + shouldUseNarrowLayout: false, + }); + + const columns = [CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES]; + + render( + , + ); + expect(screen.getByText('5')).toBeTruthy(); + }); +}); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index dd885cd8b4ef..9898de251ecb 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -3325,4 +3325,114 @@ describe('SearchUIUtils', () => { expect(transactionThread).toBeTruthy(); }); }); + + describe('getSearchColumnTranslationKey', () => { + it('should return correct translation key for DATE column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.DATE)).toBe('common.date'); + }); + + it('should return correct translation key for SUBMITTED column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.SUBMITTED)).toBe('common.submitted'); + }); + + it('should return correct translation key for APPROVED column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.APPROVED)).toBe('search.filters.approved'); + }); + + it('should return correct translation key for POSTED column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.POSTED)).toBe('search.filters.posted'); + }); + + it('should return correct translation key for EXPORTED column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.EXPORTED)).toBe('search.filters.exported'); + }); + + it('should return correct translation key for MERCHANT column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.MERCHANT)).toBe('common.merchant'); + }); + + it('should return correct translation key for DESCRIPTION column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION)).toBe('common.description'); + }); + + it('should return correct translation key for FROM column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.FROM)).toBe('common.from'); + }); + + it('should return correct translation key for TO column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.TO)).toBe('common.to'); + }); + + it('should return correct translation key for CATEGORY column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.CATEGORY)).toBe('common.category'); + }); + + it('should return correct translation key for RECEIPT column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.RECEIPT)).toBe('common.receipt'); + }); + + it('should return correct translation key for TAG column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.TAG)).toBe('common.tag'); + }); + + it('should return correct translation key for ORIGINAL_AMOUNT column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT)).toBe('common.originalAmount'); + }); + + it('should return correct translation key for REIMBURSABLE column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE)).toBe('common.reimbursable'); + }); + + it('should return correct translation key for BILLABLE column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.BILLABLE)).toBe('common.billable'); + }); + + it('should return correct translation key for ACTION column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.ACTION)).toBe('common.action'); + }); + + it('should return correct translation key for TITLE column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.TITLE)).toBe('common.title'); + }); + + it('should return correct translation key for STATUS column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.STATUS)).toBe('common.status'); + }); + + it('should return correct translation key for EXCHANGE_RATE column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE)).toBe('common.exchangeRate'); + }); + + it('should return correct translation key for POLICY_NAME column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.POLICY_NAME)).toBe('workspace.common.workspace'); + }); + + it('should return correct translation key for CARD column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.CARD)).toBe('common.card'); + }); + + it('should return correct translation key for REIMBURSABLE_TOTAL column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE_TOTAL)).toBe('common.reimbursableTotal'); + }); + + it('should return correct translation key for NON_REIMBURSABLE_TOTAL column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.NON_REIMBURSABLE_TOTAL)).toBe('common.nonReimbursableTotal'); + }); + + it('should return correct translation key for TAX_AMOUNT column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT)).toBe('common.tax'); + }); + + it('should return correct translation key for TAX_RATE column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.TAX_RATE)).toBe('iou.taxRate'); + }); + + it('should return correct translation key for REPORT_ID column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.REPORT_ID)).toBe('common.longReportID'); + }); + + it('should return correct translation key for TOTAL_AMOUNT column', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT)).toBe('iou.amount'); + }); + }); });