diff --git a/assets/images/basket.svg b/assets/images/basket.svg new file mode 100644 index 0000000000000..61eb391683a81 --- /dev/null +++ b/assets/images/basket.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 22e530eaaa26e..411ce7eb0c68c 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6882,6 +6882,7 @@ const CONST = { CARD: 'card', WITHDRAWAL_ID: 'withdrawal-id', CATEGORY: 'category', + MERCHANT: 'merchant', TAG: 'tag', MONTH: 'month', }, @@ -6965,6 +6966,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, + }, TAG: { TAG: this.TABLE_COLUMNS.GROUP_TAG, EXPENSES: this.TABLE_COLUMNS.GROUP_EXPENSES, @@ -7017,6 +7023,7 @@ const CONST = { this.TABLE_COLUMNS.GROUP_TOTAL, ], CATEGORY: [this.TABLE_COLUMNS.GROUP_CATEGORY, 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], TAG: [this.TABLE_COLUMNS.GROUP_TAG, this.TABLE_COLUMNS.GROUP_EXPENSES, this.TABLE_COLUMNS.GROUP_TOTAL], MONTH: [this.TABLE_COLUMNS.GROUP_MONTH, this.TABLE_COLUMNS.GROUP_EXPENSES, this.TABLE_COLUMNS.GROUP_TOTAL], }; @@ -7115,6 +7122,7 @@ const CONST = { GROUP_WITHDRAWN: 'groupWithdrawn', GROUP_WITHDRAWAL_ID: 'groupWithdrawalID', GROUP_CATEGORY: 'groupCategory', + GROUP_MERCHANT: 'groupMerchant', GROUP_TAG: 'groupTag', GROUP_MONTH: 'groupmonth', }, @@ -7198,6 +7206,7 @@ const CONST = { TAG_EMPTY_VALUE: 'none', CATEGORY_EMPTY_VALUE: 'none', CATEGORY_DEFAULT_VALUE: 'Uncategorized', + MERCHANT_EMPTY_VALUE: 'none', SEARCH_ROUTER_ITEM_TYPE: { CONTEXTUAL_SUGGESTION: 'contextualSuggestion', AUTOCOMPLETE_SUGGESTION: 'autocompleteSuggestion', @@ -7305,6 +7314,7 @@ const CONST = { [this.TABLE_COLUMNS.GROUP_WITHDRAWN]: 'group-withdrawn', [this.TABLE_COLUMNS.GROUP_WITHDRAWAL_ID]: 'group-withdrawal-id', [this.TABLE_COLUMNS.GROUP_CATEGORY]: 'group-category', + [this.TABLE_COLUMNS.GROUP_MERCHANT]: 'group-merchant', [this.TABLE_COLUMNS.GROUP_TAG]: 'group-tag', }; }, @@ -7349,6 +7359,7 @@ const CONST = { RECONCILIATION: 'reconciliation', TOP_SPENDERS: 'topSpenders', TOP_CATEGORIES: 'topCategories', + TOP_MERCHANTS: 'topMerchants', }, GROUP_PREFIX: 'group_', ANIMATION: { diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index f41804d1e3acf..e302fbb3d07b9 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -3,6 +3,7 @@ import Apple from '@assets/images/apple.svg'; import AttachmentNotFound from '@assets/images/attachment-not-found.svg'; import FallbackAvatar from '@assets/images/avatars/fallback-avatar.svg'; import Bank from '@assets/images/bank.svg'; +import Basket from '@assets/images/basket.svg'; import Bell from '@assets/images/bell.svg'; import Bill from '@assets/images/bill.svg'; import boltSlash from '@assets/images/bolt-slash.svg'; @@ -166,8 +167,9 @@ export { Apple, AttachmentNotFound, Bank, - Bill, + Basket, Bell, + Bill, Bolt, Bug, Building, diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index e51dac50bbc55..c14487ce667b3 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -20,6 +20,7 @@ import NotificationsAvatar from '@assets/images/avatars/notifications-avatar.svg import ActiveRoomAvatar from '@assets/images/avatars/room.svg'; import BackArrow from '@assets/images/back-left.svg'; import Bank from '@assets/images/bank.svg'; +import Basket from '@assets/images/basket.svg'; import Bed from '@assets/images/bed.svg'; import Bell from '@assets/images/bell.svg'; import Bill from '@assets/images/bill.svg'; @@ -256,6 +257,7 @@ const Expensicons = { AttachmentNotFound, BackArrow, Bank, + Basket, CircularArrowBackwards, Bill, BillComSquare, diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 3cecb2cdab92a..0bb6e50b0a5c7 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -29,6 +29,7 @@ import type { TransactionCategoryGroupListItemType, TransactionGroupListItemType, TransactionListItemType, + TransactionMerchantGroupListItemType, TransactionMonthGroupListItemType, } from '@components/SelectionListWithSections/types'; import Text from '@components/Text'; @@ -159,6 +160,9 @@ function isTransactionMatchWithGroupItem(transaction: Transaction, groupItem: Se if (groupBy === CONST.SEARCH.GROUP_BY.CATEGORY) { return (transaction.category ?? '') === ((groupItem as TransactionCategoryGroupListItemType).category ?? ''); } + if (groupBy === CONST.SEARCH.GROUP_BY.MERCHANT) { + return (transaction.merchant ?? '') === ((groupItem as TransactionMerchantGroupListItemType).merchant ?? ''); + } if (groupBy === CONST.SEARCH.GROUP_BY.MONTH) { const monthGroup = groupItem as TransactionMonthGroupListItemType; const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 6034026dc50f8..29bfe8e014df9 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -57,6 +57,7 @@ import { isTransactionGroupListItemType, isTransactionListItemType, isTransactionMemberGroupListItemType, + isTransactionMerchantGroupListItemType, isTransactionMonthGroupListItemType, isTransactionTagGroupListItemType, isTransactionWithdrawalIDGroupListItemType, @@ -840,6 +841,20 @@ function Search({ return; } + if (isTransactionMerchantGroupListItemType(item)) { + const merchantValue = item.merchant === '' ? CONST.SEARCH.MERCHANT_EMPTY_VALUE : item.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: merchantValue}]}); + const newQueryJSON: SearchQueryJSON = {...queryJSON, groupBy: undefined, flatFilters: newFlatFilters}; + const newQuery = buildSearchQueryString(newQueryJSON); + const newQueryJSONWithHash = buildSearchQueryJSON(newQuery); + if (!newQueryJSONWithHash) { + return; + } + handleSearch({queryJSON: newQueryJSONWithHash, searchKey, offset: 0, shouldCalculateTotals: false, isLoading: false}); + return; + } + if (isTransactionTagGroupListItemType(item)) { const tagValue = item.tag === '' || item.tag === '(untagged)' ? CONST.SEARCH.TAG_EMPTY_VALUE : item.tag; const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG); diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index cd2ebebd90f12..0019a764dc943 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -125,6 +125,7 @@ type SearchCustomColumnIds = | ValueOf | ValueOf | ValueOf + | ValueOf | ValueOf | ValueOf; diff --git a/src/components/SelectionListWithSections/Search/BaseListItemHeader.tsx b/src/components/SelectionListWithSections/Search/BaseListItemHeader.tsx new file mode 100644 index 0000000000000..576f06511391a --- /dev/null +++ b/src/components/SelectionListWithSections/Search/BaseListItemHeader.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; +import type {SearchColumnType} from '@components/Search/types'; +import type {ListItem, TransactionGroupListItemType} 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 useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import ExpandCollapseArrowButton from './ExpandCollapseArrowButton'; +import TextCell from './TextCell'; +import TotalCell from './TotalCell'; + +/** Base group item type that includes common fields used by simple text-based group headers */ +type BaseGroupListItemType = TransactionGroupListItemType & { + /** Number of transactions in the group */ + count: number; + + /** Total value of transactions */ + total: number; + + /** Currency of total value */ + currency: string; +}; + +/** Supported group column keys for the base header */ +type GroupColumnKey = + | typeof CONST.SEARCH.TABLE_COLUMNS.GROUP_CATEGORY + | typeof CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT + | typeof CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG + | typeof CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH; + +/** Supported column style keys for sizing */ +type ColumnStyleKey = + | typeof CONST.SEARCH.TABLE_COLUMNS.CATEGORY + | typeof CONST.SEARCH.TABLE_COLUMNS.MERCHANT + | typeof CONST.SEARCH.TABLE_COLUMNS.TAG + | typeof CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH; + +type BaseListItemHeaderProps = { + /** The group item being rendered */ + item: BaseGroupListItemType; + + /** The display name to show for this group */ + displayName: string; + + /** The column key for the group name column (e.g., GROUP_CATEGORY, GROUP_MERCHANT) */ + groupColumnKey: GroupColumnKey; + + /** The column style key for sizing (e.g., CATEGORY, MERCHANT) */ + columnStyleKey: ColumnStyleKey; + + /** 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 BaseListItemHeader({ + item, + displayName, + groupColumnKey, + columnStyleKey, + onCheckboxPress, + isDisabled, + canSelectMultiple, + isSelectAllChecked, + isIndeterminate, + isExpanded, + onDownArrowClick, + columns, +}: BaseListItemHeaderProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {isLargeScreenWidth} = useResponsiveLayout(); + const {translate} = useLocalize(); + + const columnComponents = { + [groupColumnKey]: ( + + + + + + ), + [CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES]: ( + + + + ), + [CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL]: ( + + + + ), + }; + + return ( + + + + {!!canSelectMultiple && ( + onCheckboxPress?.(item as unknown as TItem)} + isChecked={isSelectAllChecked} + isIndeterminate={isIndeterminate} + disabled={!!isDisabled || item.isDisabledCheckbox} + accessibilityLabel={translate('common.select')} + style={isLargeScreenWidth && styles.mr1} + /> + )} + {!isLargeScreenWidth && ( + + + + + + )} + {isLargeScreenWidth && columns?.map((column) => columnComponents[column as keyof typeof columnComponents])} + + {!isLargeScreenWidth && ( + + + {!!onDownArrowClick && ( + + )} + + )} + + + ); +} + +export default BaseListItemHeader; +export type {BaseListItemHeaderProps, BaseGroupListItemType}; diff --git a/src/components/SelectionListWithSections/Search/CategoryListItemHeader.tsx b/src/components/SelectionListWithSections/Search/CategoryListItemHeader.tsx index ed15962e8f86b..b0903fb26d85c 100644 --- a/src/components/SelectionListWithSections/Search/CategoryListItemHeader.tsx +++ b/src/components/SelectionListWithSections/Search/CategoryListItemHeader.tsx @@ -1,45 +1,13 @@ import React from 'react'; -import {View} from 'react-native'; -import Checkbox from '@components/Checkbox'; -import type {SearchColumnType} from '@components/Search/types'; import type {ListItem, TransactionCategoryGroupListItemType} 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 useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import ExpandCollapseArrowButton from './ExpandCollapseArrowButton'; -import TextCell from './TextCell'; -import TotalCell from './TotalCell'; +import type {BaseListItemHeaderProps} from './BaseListItemHeader'; +import BaseListItemHeader from './BaseListItemHeader'; -type CategoryListItemHeaderProps = { +type CategoryListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { /** The category currently being looked at */ category: TransactionCategoryGroupListItemType; - - /** 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 CategoryListItemHeader({ @@ -53,91 +21,27 @@ function CategoryListItemHeader({ onDownArrowClick, columns, }: CategoryListItemHeaderProps) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {isLargeScreenWidth} = useResponsiveLayout(); const {translate} = useLocalize(); + // formattedCategory is pre-decoded in SearchUIUtils, just translate empty values const rawCategory = categoryItem.formattedCategory ?? categoryItem.category; const categoryName = !rawCategory || rawCategory === CONST.SEARCH.CATEGORY_EMPTY_VALUE ? translate('reportLayout.uncategorized') : rawCategory; - const columnComponents = { - [CONST.SEARCH.TABLE_COLUMNS.GROUP_CATEGORY]: ( - - - - - - ), - [CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES]: ( - - - - ), - [CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL]: ( - - - - ), - }; - return ( - - - - {!!canSelectMultiple && ( - onCheckboxPress?.(categoryItem as unknown as TItem)} - isChecked={isSelectAllChecked} - isIndeterminate={isIndeterminate} - disabled={!!isDisabled || categoryItem.isDisabledCheckbox} - accessibilityLabel={translate('common.select')} - style={isLargeScreenWidth && styles.mr1} - /> - )} - {!isLargeScreenWidth && ( - - - - - - )} - {isLargeScreenWidth && columns?.map((column) => columnComponents[column as keyof typeof columnComponents])} - - {!isLargeScreenWidth && ( - - - {!!onDownArrowClick && ( - - )} - - )} - - + ); } diff --git a/src/components/SelectionListWithSections/Search/MerchantListItemHeader.tsx b/src/components/SelectionListWithSections/Search/MerchantListItemHeader.tsx new file mode 100644 index 0000000000000..dcb239f7d7195 --- /dev/null +++ b/src/components/SelectionListWithSections/Search/MerchantListItemHeader.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import type {ListItem, TransactionMerchantGroupListItemType} from '@components/SelectionListWithSections/types'; +import CONST from '@src/CONST'; +import type {BaseListItemHeaderProps} from './BaseListItemHeader'; +import BaseListItemHeader from './BaseListItemHeader'; + +type MerchantListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { + /** The merchant currently being looked at */ + merchant: TransactionMerchantGroupListItemType; +}; + +function MerchantListItemHeader({ + merchant: merchantItem, + onCheckboxPress, + isDisabled, + canSelectMultiple, + isSelectAllChecked, + isIndeterminate, + isExpanded, + onDownArrowClick, + columns, +}: MerchantListItemHeaderProps) { + // formattedMerchant is already translated to "No merchant" for empty values in SearchUIUtils + const merchantName = merchantItem.formattedMerchant ?? merchantItem.merchant ?? ''; + + return ( + + ); +} + +export default MerchantListItemHeader; diff --git a/src/components/SelectionListWithSections/Search/MonthListItemHeader.tsx b/src/components/SelectionListWithSections/Search/MonthListItemHeader.tsx index 8b0a2aa9f1170..aab0f9f65f8a3 100644 --- a/src/components/SelectionListWithSections/Search/MonthListItemHeader.tsx +++ b/src/components/SelectionListWithSections/Search/MonthListItemHeader.tsx @@ -1,45 +1,12 @@ import React from 'react'; -import {View} from 'react-native'; -import Checkbox from '@components/Checkbox'; -import type {SearchColumnType} from '@components/Search/types'; import type {ListItem, TransactionMonthGroupListItemType} 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 useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import ExpandCollapseArrowButton from './ExpandCollapseArrowButton'; -import TextCell from './TextCell'; -import TotalCell from './TotalCell'; +import type {BaseListItemHeaderProps} from './BaseListItemHeader'; +import BaseListItemHeader from './BaseListItemHeader'; -type MonthListItemHeaderProps = { +type MonthListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { /** The month group currently being looked at */ month: TransactionMonthGroupListItemType; - - /** 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 MonthListItemHeader({ @@ -53,89 +20,23 @@ function MonthListItemHeader({ onDownArrowClick, columns, }: MonthListItemHeaderProps) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {isLargeScreenWidth} = useResponsiveLayout(); - const {translate} = useLocalize(); const monthName = monthItem.formattedMonth; - const columnComponents = { - [CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH]: ( - - - - - - ), - [CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES]: ( - - - - ), - [CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL]: ( - - - - ), - }; - return ( - - - - {!!canSelectMultiple && ( - onCheckboxPress?.(monthItem as unknown as TItem)} - isChecked={isSelectAllChecked} - isIndeterminate={isIndeterminate} - disabled={!!isDisabled || monthItem.isDisabledCheckbox} - accessibilityLabel={translate('common.select')} - style={isLargeScreenWidth && styles.mr1} - /> - )} - {!isLargeScreenWidth && ( - - - - - - )} - {isLargeScreenWidth && columns?.map((column) => columnComponents[column as keyof typeof columnComponents])} - - {!isLargeScreenWidth && ( - - - {!!onDownArrowClick && ( - - )} - - )} - - + ); } diff --git a/src/components/SelectionListWithSections/Search/TagListItemHeader.tsx b/src/components/SelectionListWithSections/Search/TagListItemHeader.tsx index cdd6bf743c2af..64840b9cf6174 100644 --- a/src/components/SelectionListWithSections/Search/TagListItemHeader.tsx +++ b/src/components/SelectionListWithSections/Search/TagListItemHeader.tsx @@ -1,45 +1,12 @@ import React from 'react'; -import {View} from 'react-native'; -import Checkbox from '@components/Checkbox'; -import type {SearchColumnType} from '@components/Search/types'; import type {ListItem, TransactionTagGroupListItemType} 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 useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import ExpandCollapseArrowButton from './ExpandCollapseArrowButton'; -import TextCell from './TextCell'; -import TotalCell from './TotalCell'; +import type {BaseListItemHeaderProps} from './BaseListItemHeader'; +import BaseListItemHeader from './BaseListItemHeader'; -type TagListItemHeaderProps = { +type TagListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { /** The tag currently being looked at */ tag: TransactionTagGroupListItemType; - - /** 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 TagListItemHeader({ @@ -53,91 +20,24 @@ function TagListItemHeader({ onDownArrowClick, columns, }: TagListItemHeaderProps) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {isLargeScreenWidth} = useResponsiveLayout(); - const {translate} = useLocalize(); - // formattedTag is already translated to "No tag" for empty values in SearchUIUtils const tagName = tagItem.formattedTag ?? tagItem.tag ?? ''; - const columnComponents = { - [CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG]: ( - - - - - - ), - [CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES]: ( - - - - ), - [CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL]: ( - - - - ), - }; - return ( - - - - {!!canSelectMultiple && ( - onCheckboxPress?.(tagItem as unknown as TItem)} - isChecked={isSelectAllChecked} - isIndeterminate={isIndeterminate} - disabled={!!isDisabled || tagItem.isDisabledCheckbox} - accessibilityLabel={translate('common.select')} - style={isLargeScreenWidth && styles.mr1} - /> - )} - {!isLargeScreenWidth && ( - - - - - - )} - {isLargeScreenWidth && columns?.map((column) => columnComponents[column as keyof typeof columnComponents])} - - {!isLargeScreenWidth && ( - - - {!!onDownArrowClick && ( - - )} - - )} - - + ); } diff --git a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx index ef132f27f54dc..e6c8dd5daaadf 100644 --- a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx @@ -18,6 +18,7 @@ import type { TransactionGroupListItemType, TransactionListItemType, TransactionMemberGroupListItemType, + TransactionMerchantGroupListItemType, TransactionMonthGroupListItemType, TransactionReportGroupListItemType, TransactionTagGroupListItemType, @@ -44,6 +45,7 @@ import type {ReportAction, ReportActions} from '@src/types/onyx'; import CardListItemHeader from './CardListItemHeader'; import CategoryListItemHeader from './CategoryListItemHeader'; import MemberListItemHeader from './MemberListItemHeader'; +import MerchantListItemHeader from './MerchantListItemHeader'; import MonthListItemHeader from './MonthListItemHeader'; import ReportListItemHeader from './ReportListItemHeader'; import TagListItemHeader from './TagListItemHeader'; @@ -300,6 +302,19 @@ function TransactionGroupListItem({ isExpanded={isExpanded} /> ), + [CONST.SEARCH.GROUP_BY.MERCHANT]: ( + + ), [CONST.SEARCH.GROUP_BY.TAG]: ( !]/; var peg$r1 = /^[:=]/; @@ -384,31 +385,32 @@ function peg$parse(input, options) { var peg$e87 = peg$literalExpectation("group-withdrawn", true); var peg$e88 = peg$literalExpectation("group-withdrawal-id", true); var peg$e89 = peg$literalExpectation("group-category", true); - var peg$e90 = peg$literalExpectation("group-month", true); - var peg$e91 = peg$otherExpectation("operator"); - var peg$e92 = peg$classExpectation([":", "="], false, false); - var peg$e93 = peg$literalExpectation("!=", false); - var peg$e94 = peg$literalExpectation(">=", false); - var peg$e95 = peg$literalExpectation(">", false); - var peg$e96 = peg$literalExpectation("<=", false); - var peg$e97 = peg$literalExpectation("<", false); - var peg$e98 = peg$otherExpectation("word"); - var peg$e99 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e100 = peg$otherExpectation("whitespace"); - var peg$e101 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); - var peg$e102 = peg$otherExpectation("quote"); - var peg$e103 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e104 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); - var peg$e105 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); - var peg$e106 = peg$literalExpectation("\u201C", false); - var peg$e107 = peg$literalExpectation("\u201D", false); - var peg$e108 = peg$literalExpectation("\"", false); - var peg$e109 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e110 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e111 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); - var peg$e112 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); - var peg$e113 = peg$classExpectation([","], false, false); - var peg$e114 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); + var peg$e90 = peg$literalExpectation("group-merchant", true); + var peg$e91 = peg$literalExpectation("group-month", true); + var peg$e92 = peg$otherExpectation("operator"); + var peg$e93 = peg$classExpectation([":", "="], false, false); + var peg$e94 = peg$literalExpectation("!=", false); + var peg$e95 = peg$literalExpectation(">=", false); + var peg$e96 = peg$literalExpectation(">", false); + var peg$e97 = peg$literalExpectation("<=", false); + var peg$e98 = peg$literalExpectation("<", false); + var peg$e99 = peg$otherExpectation("word"); + var peg$e100 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e101 = peg$otherExpectation("whitespace"); + var peg$e102 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); + var peg$e103 = peg$otherExpectation("quote"); + var peg$e104 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e105 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); + var peg$e106 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); + var peg$e107 = peg$literalExpectation("\u201C", false); + var peg$e108 = peg$literalExpectation("\u201D", false); + var peg$e109 = peg$literalExpectation("\"", false); + var peg$e110 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e111 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e112 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); + var peg$e113 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); + var peg$e114 = peg$classExpectation([","], false, false); + var peg$e115 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); var peg$f0 = function(ranges) { return { autocomplete, ranges }; }; var peg$f1 = function(filters) { return filters.filter(Boolean).flat(); }; @@ -547,33 +549,34 @@ function peg$parse(input, options) { var peg$f76 = function() { return "groupWithdrawn"; }; var peg$f77 = function() { return "groupWithdrawalID"; }; var peg$f78 = function() { return "groupCategory"; }; - var peg$f79 = function() { return "groupMonth"; }; - var peg$f80 = function() { return "eq"; }; - var peg$f81 = function() { return "neq"; }; - var peg$f82 = function() { return "gte"; }; - var peg$f83 = function() { return "gt"; }; - var peg$f84 = function() { return "lte"; }; - var peg$f85 = function() { return "lt"; }; - var peg$f86 = function(o) { + var peg$f79 = function() { return "groupMerchant"; }; + var peg$f80 = function() { return "groupMonth"; }; + var peg$f81 = function() { return "eq"; }; + var peg$f82 = function() { return "neq"; }; + var peg$f83 = function() { return "gte"; }; + var peg$f84 = function() { return "gt"; }; + var peg$f85 = function() { return "lte"; }; + var peg$f86 = function() { return "lt"; }; + var peg$f87 = function(o) { if (nameOperator) { expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found } isColumnsContext = false; return o; }; - var peg$f87 = function(chars) { return chars.join("").trim(); }; - var peg$f88 = function() { + var peg$f88 = function(chars) { return chars.join("").trim(); }; + var peg$f89 = function() { isColumnsContext = false; return "and"; }; - var peg$f89 = function() { return expectingNestedQuote; }; - var peg$f90 = function(start, inner, end) { //handle no-breaking space + var peg$f90 = function() { return expectingNestedQuote; }; + var peg$f91 = function(start, inner, end) { //handle no-breaking space return [...start, '"', ...inner, '"', ...end].join(""); }; - var peg$f91 = function(start) {return "“"}; - var peg$f92 = function(start) {return "”"}; - var peg$f93 = function(start) {return "\""}; - var peg$f94 = function(start, inner, end) { + var peg$f92 = function(start) {return "“"}; + var peg$f93 = function(start) {return "”"}; + var peg$f94 = function(start) {return "\""}; + var peg$f95 = function(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); }; var peg$currPos = options.peg$currPos | 0; @@ -2375,7 +2378,10 @@ function peg$parse(input, options) { if (s0 === peg$FAILED) { s0 = peg$parsegroupCategory(); if (s0 === peg$FAILED) { - s0 = peg$parsegroupMonth(); + s0 = peg$parsegroupMerchant(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupMonth(); + } } } } @@ -3261,13 +3267,13 @@ function peg$parse(input, options) { return s0; } - function peg$parsegroupMonth() { + function peg$parsegroupMerchant() { var s0, s1, s2, s3; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 11); + s1 = input.substr(peg$currPos, 14); if (s1.toLowerCase() === peg$c87) { - peg$currPos += 11; + peg$currPos += 14; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e90); } @@ -3298,6 +3304,43 @@ function peg$parse(input, options) { return s0; } + function peg$parsegroupMonth() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 11); + if (s1.toLowerCase() === peg$c88) { + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e91); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f80(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + function peg$parseoperator() { var s0, s1; @@ -3308,81 +3351,81 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e92); } + if (peg$silentFails === 0) { peg$fail(peg$e93); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f80(); + s1 = peg$f81(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c88) { - s1 = peg$c88; + if (input.substr(peg$currPos, 2) === peg$c89) { + s1 = peg$c89; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e93); } + if (peg$silentFails === 0) { peg$fail(peg$e94); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f81(); + s1 = peg$f82(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c89) { - s1 = peg$c89; + if (input.substr(peg$currPos, 2) === peg$c90) { + s1 = peg$c90; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e94); } + if (peg$silentFails === 0) { peg$fail(peg$e95); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f82(); + s1 = peg$f83(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 62) { - s1 = peg$c90; + s1 = peg$c91; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e95); } + if (peg$silentFails === 0) { peg$fail(peg$e96); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f83(); + s1 = peg$f84(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c91) { - s1 = peg$c91; + if (input.substr(peg$currPos, 2) === peg$c92) { + s1 = peg$c92; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e96); } + if (peg$silentFails === 0) { peg$fail(peg$e97); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f84(); + s1 = peg$f85(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 60) { - s1 = peg$c92; + s1 = peg$c93; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e97); } + if (peg$silentFails === 0) { peg$fail(peg$e98); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f85(); + s1 = peg$f86(); } s0 = s1; } @@ -3393,7 +3436,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e91); } + if (peg$silentFails === 0) { peg$fail(peg$e92); } } return s0; @@ -3406,7 +3449,7 @@ function peg$parse(input, options) { s1 = peg$parseoperator(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f86(s1); + s1 = peg$f87(s1); } s0 = s1; @@ -3424,7 +3467,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e99); } + if (peg$silentFails === 0) { peg$fail(peg$e100); } } if (s2 !== peg$FAILED) { while (s2 !== peg$FAILED) { @@ -3434,7 +3477,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e99); } + if (peg$silentFails === 0) { peg$fail(peg$e100); } } } } else { @@ -3442,13 +3485,13 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f87(s1); + s1 = peg$f88(s1); } s0 = s1; peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e98); } + if (peg$silentFails === 0) { peg$fail(peg$e99); } } return s0; @@ -3460,7 +3503,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f88(); + s1 = peg$f89(); s0 = s1; return s0; @@ -3476,7 +3519,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e102); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -3485,12 +3528,12 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e102); } } } peg$silentFails--; s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e100); } + if (peg$silentFails === 0) { peg$fail(peg$e101); } return s0; } @@ -3500,7 +3543,7 @@ function peg$parse(input, options) { s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f89(); + s1 = peg$f90(); if (s1) { s1 = undefined; } else { @@ -3537,7 +3580,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e103); } + if (peg$silentFails === 0) { peg$fail(peg$e104); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -3546,7 +3589,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e103); } + if (peg$silentFails === 0) { peg$fail(peg$e104); } } } s2 = input.charAt(peg$currPos); @@ -3554,7 +3597,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } if (s2 !== peg$FAILED) { s3 = []; @@ -3563,7 +3606,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -3572,7 +3615,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } } s4 = input.charAt(peg$currPos); @@ -3580,7 +3623,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } if (s4 !== peg$FAILED) { s5 = []; @@ -3589,7 +3632,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e99); } + if (peg$silentFails === 0) { peg$fail(peg$e100); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -3598,11 +3641,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e99); } + if (peg$silentFails === 0) { peg$fail(peg$e100); } } } peg$savedPos = s0; - s0 = peg$f90(s1, s3, s5); + s0 = peg$f91(s1, s3, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3614,7 +3657,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e102); } + if (peg$silentFails === 0) { peg$fail(peg$e103); } } return s0; @@ -3631,7 +3674,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e103); } + if (peg$silentFails === 0) { peg$fail(peg$e104); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -3640,7 +3683,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e103); } + if (peg$silentFails === 0) { peg$fail(peg$e104); } } } s2 = input.charAt(peg$currPos); @@ -3648,7 +3691,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } if (s2 !== peg$FAILED) { s3 = []; @@ -3657,7 +3700,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -3673,15 +3716,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c93; + s6 = peg$c94; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } + if (peg$silentFails === 0) { peg$fail(peg$e107); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f91(s1); + s4 = peg$f92(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3704,15 +3747,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c94; + s6 = peg$c95; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f92(s1); + s4 = peg$f93(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3735,15 +3778,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c95; + s6 = peg$c96; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e108); } + if (peg$silentFails === 0) { peg$fail(peg$e109); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f93(s1); + s4 = peg$f94(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3762,7 +3805,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -3778,15 +3821,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c93; + s6 = peg$c94; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } + if (peg$silentFails === 0) { peg$fail(peg$e107); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f91(s1); + s4 = peg$f92(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3809,15 +3852,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c94; + s6 = peg$c95; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f92(s1); + s4 = peg$f93(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3840,15 +3883,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c95; + s6 = peg$c96; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e108); } + if (peg$silentFails === 0) { peg$fail(peg$e109); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f93(s1); + s4 = peg$f94(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3864,7 +3907,7 @@ function peg$parse(input, options) { s4 = peg$parseclosingQuote(); if (s4 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f94(s1, s3, s4); + s0 = peg$f95(s1, s3, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3876,7 +3919,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e102); } + if (peg$silentFails === 0) { peg$fail(peg$e103); } } return s0; @@ -3891,7 +3934,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3929,7 +3972,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e110); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -3938,7 +3981,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e110); } } } s2 = []; @@ -3947,7 +3990,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e111); } } while (s3 !== peg$FAILED) { s2.push(s3); @@ -3956,7 +3999,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e111); } } } s3 = []; @@ -3965,7 +4008,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -3974,7 +4017,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } } s4 = peg$parseoperator(); @@ -3993,7 +4036,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e112); } + if (peg$silentFails === 0) { peg$fail(peg$e113); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4002,7 +4045,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e112); } + if (peg$silentFails === 0) { peg$fail(peg$e113); } } } s2 = peg$currPos; @@ -4049,7 +4092,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e113); } + if (peg$silentFails === 0) { peg$fail(peg$e114); } } } } @@ -4065,7 +4108,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e114); } + if (peg$silentFails === 0) { peg$fail(peg$e115); } } if (s0 === peg$FAILED) { s0 = peg$currPos; diff --git a/src/libs/SearchParser/baseRules.peggy b/src/libs/SearchParser/baseRules.peggy index 1a8c6d48e905a..3faa65fa027ef 100644 --- a/src/libs/SearchParser/baseRules.peggy +++ b/src/libs/SearchParser/baseRules.peggy @@ -114,6 +114,7 @@ columnsValues / groupWithdrawn / groupWithdrawalId / groupCategory + / groupMerchant / groupMonth perDiem = "per-diem"i &wordBoundary { return "perDiem"; } @@ -139,6 +140,7 @@ groupBankAccount = "group-bank-account"i &wordBoundary { return "gr groupWithdrawn = "group-withdrawn"i &wordBoundary { return "groupWithdrawn"; } groupWithdrawalId = "group-withdrawal-id"i &wordBoundary { return "groupWithdrawalID"; } groupCategory = "group-category"i &wordBoundary { return "groupCategory"; } +groupMerchant = "group-merchant"i &wordBoundary { return "groupMerchant"; } groupMonth = "group-month"i &wordBoundary { return "groupMonth"; } diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js index dd66d002472e6..ecdc1984eca9b 100644 --- a/src/libs/SearchParser/searchParser.js +++ b/src/libs/SearchParser/searchParser.js @@ -271,15 +271,16 @@ function peg$parse(input, options) { var peg$c84 = "group-withdrawn"; var peg$c85 = "group-withdrawal-id"; var peg$c86 = "group-category"; - var peg$c87 = "group-month"; - var peg$c88 = "!="; - var peg$c89 = ">="; - var peg$c90 = ">"; - var peg$c91 = "<="; - var peg$c92 = "<"; - var peg$c93 = "\u201C"; - var peg$c94 = "\u201D"; - var peg$c95 = "\""; + var peg$c87 = "group-merchant"; + var peg$c88 = "group-month"; + var peg$c89 = "!="; + var peg$c90 = ">="; + var peg$c91 = ">"; + var peg$c92 = "<="; + var peg$c93 = "<"; + var peg$c94 = "\u201C"; + var peg$c95 = "\u201D"; + var peg$c96 = "\""; var peg$r0 = /^[^ \t\r\n\xA0]/; var peg$r1 = /^[ \t\r\n\xA0,:=<>!]/; @@ -388,31 +389,32 @@ function peg$parse(input, options) { var peg$e89 = peg$literalExpectation("group-withdrawn", true); var peg$e90 = peg$literalExpectation("group-withdrawal-id", true); var peg$e91 = peg$literalExpectation("group-category", true); - var peg$e92 = peg$literalExpectation("group-month", true); - var peg$e93 = peg$otherExpectation("operator"); - var peg$e94 = peg$classExpectation([":", "="], false, false); - var peg$e95 = peg$literalExpectation("!=", false); - var peg$e96 = peg$literalExpectation(">=", false); - var peg$e97 = peg$literalExpectation(">", false); - var peg$e98 = peg$literalExpectation("<=", false); - var peg$e99 = peg$literalExpectation("<", false); - var peg$e100 = peg$otherExpectation("word"); - var peg$e101 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e102 = peg$otherExpectation("whitespace"); - var peg$e103 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); - var peg$e104 = peg$otherExpectation("quote"); - var peg$e105 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e106 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); - var peg$e107 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); - var peg$e108 = peg$literalExpectation("\u201C", false); - var peg$e109 = peg$literalExpectation("\u201D", false); - var peg$e110 = peg$literalExpectation("\"", false); - var peg$e111 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e112 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e113 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); - var peg$e114 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); - var peg$e115 = peg$classExpectation([","], false, false); - var peg$e116 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); + var peg$e92 = peg$literalExpectation("group-merchant", true); + var peg$e93 = peg$literalExpectation("group-month", true); + var peg$e94 = peg$otherExpectation("operator"); + var peg$e95 = peg$classExpectation([":", "="], false, false); + var peg$e96 = peg$literalExpectation("!=", false); + var peg$e97 = peg$literalExpectation(">=", false); + var peg$e98 = peg$literalExpectation(">", false); + var peg$e99 = peg$literalExpectation("<=", false); + var peg$e100 = peg$literalExpectation("<", false); + var peg$e101 = peg$otherExpectation("word"); + var peg$e102 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e103 = peg$otherExpectation("whitespace"); + var peg$e104 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); + var peg$e105 = peg$otherExpectation("quote"); + var peg$e106 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e107 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); + var peg$e108 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); + var peg$e109 = peg$literalExpectation("\u201C", false); + var peg$e110 = peg$literalExpectation("\u201D", false); + var peg$e111 = peg$literalExpectation("\"", false); + var peg$e112 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e113 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e114 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); + var peg$e115 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); + var peg$e116 = peg$classExpectation([","], false, false); + var peg$e117 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); var peg$f0 = function(filters) { return applyDefaults(filters); }; var peg$f1 = function(head, tail) { @@ -574,33 +576,34 @@ function peg$parse(input, options) { var peg$f77 = function() { return "groupWithdrawn"; }; var peg$f78 = function() { return "groupWithdrawalID"; }; var peg$f79 = function() { return "groupCategory"; }; - var peg$f80 = function() { return "groupMonth"; }; - var peg$f81 = function() { return "eq"; }; - var peg$f82 = function() { return "neq"; }; - var peg$f83 = function() { return "gte"; }; - var peg$f84 = function() { return "gt"; }; - var peg$f85 = function() { return "lte"; }; - var peg$f86 = function() { return "lt"; }; - var peg$f87 = function(o) { + var peg$f80 = function() { return "groupMerchant"; }; + var peg$f81 = function() { return "groupMonth"; }; + var peg$f82 = function() { return "eq"; }; + var peg$f83 = function() { return "neq"; }; + var peg$f84 = function() { return "gte"; }; + var peg$f85 = function() { return "gt"; }; + var peg$f86 = function() { return "lte"; }; + var peg$f87 = function() { return "lt"; }; + var peg$f88 = function(o) { if (nameOperator) { expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found } isColumnsContext = false; return o; }; - var peg$f88 = function(chars) { return chars.join("").trim(); }; - var peg$f89 = function() { + var peg$f89 = function(chars) { return chars.join("").trim(); }; + var peg$f90 = function() { isColumnsContext = false; return "and"; }; - var peg$f90 = function() { return expectingNestedQuote; }; - var peg$f91 = function(start, inner, end) { //handle no-breaking space + var peg$f91 = function() { return expectingNestedQuote; }; + var peg$f92 = function(start, inner, end) { //handle no-breaking space return [...start, '"', ...inner, '"', ...end].join(""); }; - var peg$f92 = function(start) {return "“"}; - var peg$f93 = function(start) {return "”"}; - var peg$f94 = function(start) {return "\""}; - var peg$f95 = function(start, inner, end) { + var peg$f93 = function(start) {return "“"}; + var peg$f94 = function(start) {return "”"}; + var peg$f95 = function(start) {return "\""}; + var peg$f96 = function(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); }; var peg$currPos = options.peg$currPos | 0; @@ -2562,7 +2565,10 @@ function peg$parse(input, options) { if (s0 === peg$FAILED) { s0 = peg$parsegroupCategory(); if (s0 === peg$FAILED) { - s0 = peg$parsegroupMonth(); + s0 = peg$parsegroupMerchant(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupMonth(); + } } } } @@ -3448,13 +3454,13 @@ function peg$parse(input, options) { return s0; } - function peg$parsegroupMonth() { + function peg$parsegroupMerchant() { var s0, s1, s2, s3; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 11); + s1 = input.substr(peg$currPos, 14); if (s1.toLowerCase() === peg$c87) { - peg$currPos += 11; + peg$currPos += 14; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e92); } @@ -3485,6 +3491,43 @@ function peg$parse(input, options) { return s0; } + function peg$parsegroupMonth() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 11); + if (s1.toLowerCase() === peg$c88) { + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e93); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f81(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + function peg$parseoperator() { var s0, s1; @@ -3495,81 +3538,81 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e94); } + if (peg$silentFails === 0) { peg$fail(peg$e95); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f81(); + s1 = peg$f82(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c88) { - s1 = peg$c88; + if (input.substr(peg$currPos, 2) === peg$c89) { + s1 = peg$c89; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e95); } + if (peg$silentFails === 0) { peg$fail(peg$e96); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f82(); + s1 = peg$f83(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c89) { - s1 = peg$c89; + if (input.substr(peg$currPos, 2) === peg$c90) { + s1 = peg$c90; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e96); } + if (peg$silentFails === 0) { peg$fail(peg$e97); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f83(); + s1 = peg$f84(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 62) { - s1 = peg$c90; + s1 = peg$c91; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e97); } + if (peg$silentFails === 0) { peg$fail(peg$e98); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f84(); + s1 = peg$f85(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c91) { - s1 = peg$c91; + if (input.substr(peg$currPos, 2) === peg$c92) { + s1 = peg$c92; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e98); } + if (peg$silentFails === 0) { peg$fail(peg$e99); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f85(); + s1 = peg$f86(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 60) { - s1 = peg$c92; + s1 = peg$c93; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e99); } + if (peg$silentFails === 0) { peg$fail(peg$e100); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f86(); + s1 = peg$f87(); } s0 = s1; } @@ -3580,7 +3623,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e93); } + if (peg$silentFails === 0) { peg$fail(peg$e94); } } return s0; @@ -3593,7 +3636,7 @@ function peg$parse(input, options) { s1 = peg$parseoperator(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f87(s1); + s1 = peg$f88(s1); } s0 = s1; @@ -3611,7 +3654,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e102); } } if (s2 !== peg$FAILED) { while (s2 !== peg$FAILED) { @@ -3621,7 +3664,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e102); } } } } else { @@ -3629,13 +3672,13 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f88(s1); + s1 = peg$f89(s1); } s0 = s1; peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e100); } + if (peg$silentFails === 0) { peg$fail(peg$e101); } } return s0; @@ -3647,7 +3690,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f89(); + s1 = peg$f90(); s0 = s1; return s0; @@ -3663,7 +3706,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e103); } + if (peg$silentFails === 0) { peg$fail(peg$e104); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -3672,12 +3715,12 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e103); } + if (peg$silentFails === 0) { peg$fail(peg$e104); } } } peg$silentFails--; s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e102); } + if (peg$silentFails === 0) { peg$fail(peg$e103); } return s0; } @@ -3687,7 +3730,7 @@ function peg$parse(input, options) { s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f90(); + s1 = peg$f91(); if (s1) { s1 = undefined; } else { @@ -3724,7 +3767,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -3733,7 +3776,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } } s2 = input.charAt(peg$currPos); @@ -3741,7 +3784,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } + if (peg$silentFails === 0) { peg$fail(peg$e107); } } if (s2 !== peg$FAILED) { s3 = []; @@ -3750,7 +3793,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -3759,7 +3802,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } } s4 = input.charAt(peg$currPos); @@ -3767,7 +3810,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } + if (peg$silentFails === 0) { peg$fail(peg$e107); } } if (s4 !== peg$FAILED) { s5 = []; @@ -3776,7 +3819,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e102); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -3785,11 +3828,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e102); } } } peg$savedPos = s0; - s0 = peg$f91(s1, s3, s5); + s0 = peg$f92(s1, s3, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3801,7 +3844,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } return s0; @@ -3818,7 +3861,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -3827,7 +3870,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } } s2 = input.charAt(peg$currPos); @@ -3835,7 +3878,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } + if (peg$silentFails === 0) { peg$fail(peg$e107); } } if (s2 !== peg$FAILED) { s3 = []; @@ -3844,7 +3887,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -3860,15 +3903,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c93; + s6 = peg$c94; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e108); } + if (peg$silentFails === 0) { peg$fail(peg$e109); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f92(s1); + s4 = peg$f93(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3891,15 +3934,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c94; + s6 = peg$c95; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e110); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f93(s1); + s4 = peg$f94(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3922,15 +3965,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c95; + s6 = peg$c96; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e111); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f94(s1); + s4 = peg$f95(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3949,7 +3992,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -3965,15 +4008,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c93; + s6 = peg$c94; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e108); } + if (peg$silentFails === 0) { peg$fail(peg$e109); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f92(s1); + s4 = peg$f93(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3996,15 +4039,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c94; + s6 = peg$c95; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e110); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f93(s1); + s4 = peg$f94(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4027,15 +4070,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c95; + s6 = peg$c96; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e111); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f94(s1); + s4 = peg$f95(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4051,7 +4094,7 @@ function peg$parse(input, options) { s4 = peg$parseclosingQuote(); if (s4 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f95(s1, s3, s4); + s0 = peg$f96(s1, s3, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4063,7 +4106,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } return s0; @@ -4078,7 +4121,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } + if (peg$silentFails === 0) { peg$fail(peg$e107); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4116,7 +4159,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4125,7 +4168,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } } s2 = []; @@ -4134,7 +4177,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e112); } + if (peg$silentFails === 0) { peg$fail(peg$e113); } } while (s3 !== peg$FAILED) { s2.push(s3); @@ -4143,7 +4186,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e112); } + if (peg$silentFails === 0) { peg$fail(peg$e113); } } } s3 = []; @@ -4152,7 +4195,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e113); } + if (peg$silentFails === 0) { peg$fail(peg$e114); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -4161,7 +4204,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e113); } + if (peg$silentFails === 0) { peg$fail(peg$e114); } } } s4 = peg$parseoperator(); @@ -4180,7 +4223,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e114); } + if (peg$silentFails === 0) { peg$fail(peg$e115); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4189,7 +4232,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e114); } + if (peg$silentFails === 0) { peg$fail(peg$e115); } } } s2 = peg$currPos; @@ -4236,7 +4279,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e115); } + if (peg$silentFails === 0) { peg$fail(peg$e116); } } } } @@ -4252,7 +4295,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e116); } + if (peg$silentFails === 0) { peg$fail(peg$e117); } } if (s0 === peg$FAILED) { s0 = peg$currPos; @@ -4282,6 +4325,7 @@ function peg$parse(input, options) { card: "card", "withdrawal-id": "withdrawn", category: "category", + merchant: "groupMerchant", month: "groupmonth", }; @@ -4290,6 +4334,7 @@ function peg$parse(input, options) { card: "asc", "withdrawal-id": "desc", category: "asc", + merchant: "asc", month: "desc" }; diff --git a/src/libs/SearchParser/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy index 9dc46092f4cff..b6f264ebf8af9 100644 --- a/src/libs/SearchParser/searchParser.peggy +++ b/src/libs/SearchParser/searchParser.peggy @@ -33,6 +33,7 @@ card: "card", "withdrawal-id": "withdrawn", category: "category", + merchant: "groupMerchant", month: "groupmonth", }; @@ -41,6 +42,7 @@ card: "asc", "withdrawal-id": "desc", category: "asc", + merchant: "asc", month: "desc" }; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 1bff7d6166e4d..2c29ae4c4e6e7 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines */ +// TODO: Remove this disable once SearchUIUtils is refactored (see dedicated refactor issue) import {format} from 'date-fns'; import type {TextStyle, ViewStyle} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -37,6 +39,7 @@ import type { TransactionGroupListItemType, TransactionListItemType, TransactionMemberGroupListItemType, + TransactionMerchantGroupListItemType, TransactionMonthGroupListItemType, TransactionReportGroupListItemType, TransactionTagGroupListItemType, @@ -59,6 +62,7 @@ import type { SearchCategoryGroup, SearchDataTypes, SearchMemberGroup, + SearchMerchantGroup, SearchTagGroup, SearchTask, SearchTransactionAction, @@ -153,6 +157,7 @@ type TransactionMemberGroupSorting = ColumnSortMapping; type TransactionWithdrawalIDGroupSorting = ColumnSortMapping; type TransactionCategoryGroupSorting = ColumnSortMapping; +type TransactionMerchantGroupSorting = ColumnSortMapping; type TransactionTagGroupSorting = ColumnSortMapping; type TransactionMonthGroupSorting = ColumnSortMapping; @@ -257,10 +262,15 @@ const transactionTagGroupColumnNamesToSortingProperty: TransactionTagGroupSortin ...transactionGroupBaseSortingProperties, }; +const transactionMerchantGroupColumnNamesToSortingProperty: TransactionMerchantGroupSorting = { + [CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT]: 'formattedMerchant' as const, + [CONST.SEARCH.TABLE_COLUMNS.MERCHANT]: 'formattedMerchant' as const, + ...transactionGroupBaseSortingProperties, +}; + const transactionMonthGroupColumnNamesToSortingProperty: TransactionMonthGroupSorting = { [CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH]: 'sortKey' as const, - [CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES]: 'count' as const, - [CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL]: 'total' as const, + ...transactionGroupBaseSortingProperties, }; const expenseStatusActionMapping = { @@ -365,7 +375,7 @@ type SearchTypeMenuItem = { key: SearchKey; translationPath: TranslationPaths; type: SearchDataTypes; - icon?: IconAsset | Extract; + icon?: IconAsset | Extract; searchQuery: string; searchQueryJSON: SearchQueryJSON | undefined; hash: number; @@ -410,12 +420,12 @@ type GetSectionsParams = { }; /** - * Creates a top search menu item with common structure for TOP_SPENDERS and TOP_CATEGORIES + * Creates a top search menu item with common structure for TOP_SPENDERS, TOP_CATEGORIES, and TOP_MERCHANTS */ function createTopSearchMenuItem( key: SearchKey, translationPath: TranslationPaths, - icon: IconAsset | Extract, + icon: IconAsset | Extract, groupBy: ValueOf, limit?: number, ): SearchTypeMenuItem { @@ -684,14 +694,21 @@ function getSuggestedSearches( return this.searchQueryJSON?.similarSearchHash ?? CONST.DEFAULT_NUMBER_ID; }, }, - [CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS]: createTopSearchMenuItem(CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS, 'search.topSpenders', Expensicons.User, CONST.SEARCH.GROUP_BY.FROM), + [CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS]: createTopSearchMenuItem(CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS, 'search.topSpenders', 'User', CONST.SEARCH.GROUP_BY.FROM), [CONST.SEARCH.SEARCH_KEYS.TOP_CATEGORIES]: createTopSearchMenuItem( CONST.SEARCH.SEARCH_KEYS.TOP_CATEGORIES, 'search.topCategories', - Expensicons.Folder, + 'Folder', CONST.SEARCH.GROUP_BY.CATEGORY, CONST.SEARCH.TOP_SEARCH_LIMIT, ), + [CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS]: createTopSearchMenuItem( + CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS, + 'search.topMerchants', + 'Basket', + CONST.SEARCH.GROUP_BY.MERCHANT, + CONST.SEARCH.TOP_SEARCH_LIMIT, + ), }; } @@ -715,6 +732,7 @@ function getSuggestedSearchesVisibility( let shouldShowReconciliationSuggestion = false; let shouldShowTopSpendersSuggestion = false; let shouldShowTopCategoriesSuggestion = false; + let shouldShowTopMerchantsSuggestion = false; const hasCardFeed = Object.values(cardFeedsByPolicy ?? {}).some((feeds) => feeds.length > 0); @@ -752,6 +770,7 @@ function getSuggestedSearchesVisibility( const isAuditor = policy.role === CONST.POLICY.ROLE.AUDITOR; const isEligibleForTopSpendersSuggestion = isPaidPolicy && (isAdmin || isAuditor || isApprover); const isEligibleForTopCategoriesSuggestion = isPaidPolicy; + const isEligibleForTopMerchantsSuggestion = isPaidPolicy; shouldShowSubmitSuggestion ||= isEligibleForSubmitSuggestion; shouldShowPaySuggestion ||= isEligibleForPaySuggestion; @@ -763,6 +782,7 @@ function getSuggestedSearchesVisibility( shouldShowReconciliationSuggestion ||= isEligibleForReconciliationSuggestion; shouldShowTopSpendersSuggestion ||= isEligibleForTopSpendersSuggestion; shouldShowTopCategoriesSuggestion ||= isEligibleForTopCategoriesSuggestion; + shouldShowTopMerchantsSuggestion ||= isEligibleForTopMerchantsSuggestion; // We don't need to check the rest of the policies if we already determined that all suggestions should be displayed return ( @@ -775,7 +795,8 @@ function getSuggestedSearchesVisibility( shouldShowUnapprovedCardSuggestion && shouldShowReconciliationSuggestion && shouldShowTopSpendersSuggestion && - shouldShowTopCategoriesSuggestion + shouldShowTopCategoriesSuggestion && + shouldShowTopMerchantsSuggestion ); }); @@ -793,6 +814,7 @@ function getSuggestedSearchesVisibility( [CONST.SEARCH.SEARCH_KEYS.RECONCILIATION]: shouldShowReconciliationSuggestion, [CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS]: shouldShowTopSpendersSuggestion, [CONST.SEARCH.SEARCH_KEYS.TOP_CATEGORIES]: shouldShowTopCategoriesSuggestion, + [CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS]: shouldShowTopMerchantsSuggestion, }; } @@ -939,6 +961,13 @@ function isTransactionCategoryGroupListItemType(item: ListItem): item is Transac return isTransactionGroupListItemType(item) && 'groupedBy' in item && item.groupedBy === CONST.SEARCH.GROUP_BY.CATEGORY; } +/** + * 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 TransactionTagGroupListItemType */ @@ -2217,6 +2246,64 @@ function getCategorySections(data: OnyxTypes.SearchResults['data'], queryJSON: S return [categorySectionsValues, categorySectionsValues.length]; } +/** + * @private + * Organizes data into List Sections grouped by merchant for display, for the TransactionGroupListItemType of Search Results. + * + * Do not use directly, use only via `getSections()` facade. + */ +function getMerchantSections(data: OnyxTypes.SearchResults['data'], queryJSON: SearchQueryJSON | undefined, translate: LocalizedTranslate): [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 !== undefined) { + // Normalize empty merchant to MERCHANT_EMPTY_VALUE to avoid invalid query like "merchant:" + const merchantValue = merchantGroup.merchant === '' ? CONST.SEARCH.MERCHANT_EMPTY_VALUE : 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: merchantValue}]}); + + const newQueryJSON: SearchQueryJSON = {...queryJSON, groupBy: undefined, flatFilters: newFlatFilters}; + + const newQuery = buildSearchQueryString(newQueryJSON); + + transactionsQueryJSON = buildSearchQueryJSON(newQuery); + } + + // Format the merchant name - use translated "No merchant" for empty values so it sorts alphabetically + // Handle all known empty merchant values: + // - Empty string or falsy + // - MERCHANT_EMPTY_VALUE ('none') - used in search queries + // - DEFAULT_MERCHANT ('Expense') - system default for expenses without merchant + // - PARTIAL_TRANSACTION_MERCHANT ('(none)') - used for partial/incomplete transactions + // - UNKNOWN_MERCHANT ('Unknown Merchant') - used when merchant cannot be determined + const rawMerchant = merchantGroup.merchant; + const isEmptyMerchant = + !rawMerchant || + rawMerchant === CONST.SEARCH.MERCHANT_EMPTY_VALUE || + rawMerchant === CONST.TRANSACTION.DEFAULT_MERCHANT || + rawMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || + rawMerchant === CONST.TRANSACTION.UNKNOWN_MERCHANT; + const formattedMerchant = isEmptyMerchant ? translate('search.noMerchant') : rawMerchant; + + merchantSections[key] = { + groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, + transactions: [], + transactionsQueryJSON, + ...merchantGroup, + formattedMerchant, + }; + } + } + + const merchantSectionsValues = Object.values(merchantSections); + return [merchantSectionsValues, merchantSectionsValues.length]; +} + /** * @private * Organizes data into List Sections grouped by tag for display, for the TransactionGroupListItemType of Search Results. @@ -2390,6 +2477,8 @@ function getSections({ return getWithdrawalIDSections(data, queryJSON); case CONST.SEARCH.GROUP_BY.CATEGORY: return getCategorySections(data, queryJSON); + case CONST.SEARCH.GROUP_BY.MERCHANT: + return getMerchantSections(data, queryJSON, translate); case CONST.SEARCH.GROUP_BY.TAG: return getTagSections(data, queryJSON, translate); case CONST.SEARCH.GROUP_BY.MONTH: @@ -2435,6 +2524,8 @@ function getSortedSections( return getSortedWithdrawalIDData(data as TransactionWithdrawalIDGroupListItemType[], localeCompare, sortBy, sortOrder); case CONST.SEARCH.GROUP_BY.CATEGORY: return getSortedCategoryData(data as TransactionCategoryGroupListItemType[], localeCompare, sortBy, sortOrder); + case CONST.SEARCH.GROUP_BY.MERCHANT: + return getSortedMerchantData(data as TransactionMerchantGroupListItemType[], localeCompare, sortBy, sortOrder); case CONST.SEARCH.GROUP_BY.TAG: return getSortedTagData(data as TransactionTagGroupListItemType[], localeCompare, sortBy, sortOrder); case CONST.SEARCH.GROUP_BY.MONTH: @@ -2810,6 +2901,21 @@ function getSortedCategoryData(data: TransactionCategoryGroupListItemType[], loc ); } +/** + * @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 tag sections based on a specified column and sort order. @@ -2879,6 +2985,8 @@ function getCustomColumns(value?: SearchDataTypes | SearchGroupBy): SearchCustom return Object.values(CONST.SEARCH.GROUP_CUSTOM_COLUMNS.WITHDRAWAL_ID); case CONST.SEARCH.GROUP_BY.CATEGORY: return Object.values(CONST.SEARCH.GROUP_CUSTOM_COLUMNS.CATEGORY); + case CONST.SEARCH.GROUP_BY.MERCHANT: + return Object.values(CONST.SEARCH.GROUP_CUSTOM_COLUMNS.MERCHANT); case CONST.SEARCH.GROUP_BY.TAG: return Object.values(CONST.SEARCH.GROUP_CUSTOM_COLUMNS.TAG); case CONST.SEARCH.GROUP_BY.MONTH: @@ -2910,6 +3018,8 @@ function getCustomColumnDefault(value?: SearchDataTypes | SearchGroupBy): Search return CONST.SEARCH.GROUP_DEFAULT_COLUMNS.WITHDRAWAL_ID; case CONST.SEARCH.GROUP_BY.CATEGORY: return CONST.SEARCH.GROUP_DEFAULT_COLUMNS.CATEGORY; + case CONST.SEARCH.GROUP_BY.MERCHANT: + return CONST.SEARCH.GROUP_DEFAULT_COLUMNS.MERCHANT; case CONST.SEARCH.GROUP_BY.TAG: return CONST.SEARCH.GROUP_DEFAULT_COLUMNS.TAG; case CONST.SEARCH.GROUP_BY.MONTH: @@ -3000,6 +3110,8 @@ function getSearchColumnTranslationKey(columnId: SearchCustomColumnIds): Transla return 'search.filters.feed'; case CONST.SEARCH.TABLE_COLUMNS.GROUP_CATEGORY: return 'common.category'; + case CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT: + return 'common.merchant'; case CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG: return 'common.tag'; case CONST.SEARCH.TABLE_COLUMNS.EXPORTED_TO: @@ -3229,7 +3341,7 @@ function createTypeMenuSections( menuItems: [], }; - const insightsSearchKeys = [CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS, CONST.SEARCH.SEARCH_KEYS.TOP_CATEGORIES]; + const insightsSearchKeys = [CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS, CONST.SEARCH.SEARCH_KEYS.TOP_CATEGORIES, CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS]; for (const key of insightsSearchKeys) { if (!suggestedSearchesVisibility[key]) { @@ -3479,6 +3591,7 @@ function getColumnsToShow( [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.CATEGORY]: CONST.SEARCH.GROUP_CUSTOM_COLUMNS.CATEGORY, + [CONST.SEARCH.GROUP_BY.MERCHANT]: CONST.SEARCH.GROUP_CUSTOM_COLUMNS.MERCHANT, [CONST.SEARCH.GROUP_BY.TAG]: CONST.SEARCH.GROUP_CUSTOM_COLUMNS.TAG, [CONST.SEARCH.GROUP_BY.MONTH]: CONST.SEARCH.GROUP_CUSTOM_COLUMNS.MONTH, }[groupBy]; @@ -3488,6 +3601,7 @@ function getColumnsToShow( [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.CATEGORY]: CONST.SEARCH.GROUP_DEFAULT_COLUMNS.CATEGORY, + [CONST.SEARCH.GROUP_BY.MERCHANT]: CONST.SEARCH.GROUP_DEFAULT_COLUMNS.MERCHANT, [CONST.SEARCH.GROUP_BY.TAG]: CONST.SEARCH.GROUP_DEFAULT_COLUMNS.TAG, [CONST.SEARCH.GROUP_BY.MONTH]: CONST.SEARCH.GROUP_DEFAULT_COLUMNS.MONTH, }[groupBy]; @@ -3563,6 +3677,23 @@ function getColumnsToShow( return result; } + if (groupBy === CONST.SEARCH.GROUP_BY.MERCHANT) { + const requiredColumns = new Set([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; + } + if (groupBy === CONST.SEARCH.GROUP_BY.TAG) { const requiredColumns = new Set([CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG]); const result: SearchColumnType[] = []; @@ -3879,6 +4010,7 @@ export { isTransactionCardGroupListItemType, isTransactionWithdrawalIDGroupListItemType, isTransactionCategoryGroupListItemType, + isTransactionMerchantGroupListItemType, isTransactionTagGroupListItemType, isTransactionMonthGroupListItemType, isSearchResultsEmpty, diff --git a/src/pages/Search/SearchColumnsPage.tsx b/src/pages/Search/SearchColumnsPage.tsx index dae521facb507..405aea75a7cb1 100644 --- a/src/pages/Search/SearchColumnsPage.tsx +++ b/src/pages/Search/SearchColumnsPage.tsx @@ -62,6 +62,7 @@ function SearchColumnsPage() { CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWAL_ID, CONST.SEARCH.TABLE_COLUMNS.GROUP_FROM, CONST.SEARCH.TABLE_COLUMNS.GROUP_CATEGORY, + CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT, CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG, CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH, ]); diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index 6d3c8458056c8..102a347013216 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -59,6 +59,7 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { !!typeMenuSections.find((section) => section.translationPath === 'search.savedSearchesMenuItemTitle') && isFocused, ); const expensifyIcons = useMemoizedLazyExpensifyIcons([ + 'Basket', 'Bookmark', 'Pencil', 'Receipt', @@ -68,6 +69,8 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { 'MoneyHourglass', 'CreditCardHourglass', 'Bank', + 'User', + 'Folder', ] as const); const {showDeleteModal} = useDeleteSavedSearch(); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index d0ab06c426a34..1a7da00fcb4bc 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -187,6 +187,21 @@ type SearchCategoryGroup = { currency: string; }; +/** 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 tag grouped search result */ type SearchTagGroup = { /** Tag name */ @@ -234,7 +249,10 @@ type SearchResults = { PrefixedRecord & PrefixedRecord & PrefixedRecord & - PrefixedRecord; + PrefixedRecord< + typeof CONST.SEARCH.GROUP_PREFIX, + SearchMemberGroup | SearchCardGroup | SearchWithdrawalIDGroup | SearchCategoryGroup | SearchMerchantGroup | SearchTagGroup | SearchMonthGroup + >; /** Whether search data is being fetched from server */ isLoading?: boolean; @@ -256,6 +274,7 @@ export type { SearchCardGroup, SearchWithdrawalIDGroup, SearchCategoryGroup, + SearchMerchantGroup, SearchTagGroup, SearchMonthGroup, }; diff --git a/tests/ui/MerchantListItemHeaderTest.tsx b/tests/ui/MerchantListItemHeaderTest.tsx new file mode 100644 index 0000000000000..a026788513d2a --- /dev/null +++ b/tests/ui/MerchantListItemHeaderTest.tsx @@ -0,0 +1,367 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {SearchContext} from '@components/Search/SearchContext'; +import type {SearchColumnType} from '@components/Search/types'; +import MerchantListItemHeader from '@components/SelectionListWithSections/Search/MerchantListItemHeader'; +import type {TransactionMerchantGroupListItemType} from '@components/SelectionListWithSections/types'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@components/ConfirmedRoute.tsx'); +jest.mock('@libs/Navigation/Navigation'); + +// Mock useResponsiveLayout to control screen size in tests +jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); +const mockedUseResponsiveLayout = useResponsiveLayout as jest.MockedFunction; + +// Mock search context with all required SearchContextProps fields +const mockSearchContext = { + currentSearchHash: 12345, + currentSearchKey: undefined, + currentSearchQueryJSON: undefined, + currentSearchResults: undefined, + selectedReports: [], + selectedTransactionIDs: [], + selectedTransactions: {}, + isOnSearch: false, + shouldTurnOffSelectionMode: false, + shouldResetSearchQuery: false, + lastSearchType: undefined, + areAllMatchingItemsSelected: false, + showSelectAllMatchingItems: false, + shouldShowFiltersBarLoading: false, + shouldUseLiveData: false, + setLastSearchType: jest.fn(), + setCurrentSearchHashAndKey: jest.fn(), + setCurrentSearchQueryJSON: jest.fn(), + setSelectedTransactions: jest.fn(), + removeTransaction: jest.fn(), + clearSelectedTransactions: jest.fn(), + setShouldShowFiltersBarLoading: jest.fn(), + shouldShowSelectAllMatchingItems: jest.fn(), + selectAllMatchingItems: jest.fn(), + setShouldResetSearchQuery: jest.fn(), +}; + +const createMerchantListItem = (merchant: string, options: Partial = {}): TransactionMerchantGroupListItemType => ({ + merchant, + formattedMerchant: options.formattedMerchant ?? merchant, + count: options.count ?? 5, + currency: options.currency ?? 'USD', + total: options.total ?? 250, + groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, + transactions: [], + transactionsQueryJSON: undefined, + keyForList: `merchant-${merchant}`, + ...options, +}); + +// Helper function to wrap component with context +const renderMerchantListItemHeader = ( + merchantItem: TransactionMerchantGroupListItemType, + props: Partial<{ + onCheckboxPress: jest.Mock; + isDisabled: boolean; + canSelectMultiple: boolean; + isSelectAllChecked: boolean; + isIndeterminate: boolean; + onDownArrowClick: jest.Mock; + isExpanded: boolean; + columns: SearchColumnType[]; + }> = {}, +) => { + return render( + + + + + , + ); +}; + +describe('MerchantListItemHeader', () => { + beforeAll(() => + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }), + ); + + beforeEach(() => { + // Default to small screen (mobile) layout + mockedUseResponsiveLayout.mockReturnValue({ + isLargeScreenWidth: false, + shouldUseNarrowLayout: true, + isSmallScreenWidth: true, + isMediumScreenWidth: false, + isExtraSmallScreenWidth: false, + isExtraSmallScreenHeight: false, + isExtraLargeScreenWidth: false, + isSmallScreen: true, + isInNarrowPaneModal: false, + onboardingIsMediumOrLargerScreenWidth: false, + }); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + jest.clearAllMocks(); + }); + + describe('Merchant name display', () => { + it('should display the merchant name from formattedMerchant', async () => { + const merchantItem = createMerchantListItem('Starbucks', {formattedMerchant: 'Starbucks'}); + renderMerchantListItemHeader(merchantItem); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('Starbucks')).toBeOnTheScreen(); + }); + + it('should display merchant name when formattedMerchant has different value', async () => { + // formattedMerchant contains the formatted version + const merchantItem = createMerchantListItem('starbucks_coffee', { + formattedMerchant: 'Starbucks Coffee', + }); + renderMerchantListItemHeader(merchantItem); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('Starbucks Coffee')).toBeOnTheScreen(); + }); + + it('should display "No merchant" for empty merchant string', async () => { + // formattedMerchant is set to "No merchant" by getMerchantSections for empty merchants + const merchantItem = createMerchantListItem('', {formattedMerchant: 'No merchant'}); + renderMerchantListItemHeader(merchantItem); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('No merchant')).toBeOnTheScreen(); + }); + + it('should display "No merchant" when merchant is undefined', async () => { + // formattedMerchant is set to "No merchant" by getMerchantSections for empty merchants + const merchantItem = createMerchantListItem('', {formattedMerchant: 'No merchant'}); + renderMerchantListItemHeader(merchantItem); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('No merchant')).toBeOnTheScreen(); + }); + + it('should display "No merchant" when both merchant and formattedMerchant are empty', async () => { + // formattedMerchant is set to "No merchant" by getMerchantSections for empty merchants + const merchantItem = createMerchantListItem('', {merchant: '', formattedMerchant: 'No merchant'}); + renderMerchantListItemHeader(merchantItem); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('No merchant')).toBeOnTheScreen(); + }); + }); + + describe('Checkbox functionality', () => { + it('should render checkbox when canSelectMultiple is true', async () => { + const merchantItem = createMerchantListItem('Starbucks'); + renderMerchantListItemHeader(merchantItem, {canSelectMultiple: true}); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByRole('checkbox')).toBeOnTheScreen(); + }); + + it('should not render checkbox when canSelectMultiple is false', async () => { + const merchantItem = createMerchantListItem('Starbucks'); + renderMerchantListItemHeader(merchantItem, {canSelectMultiple: false}); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByRole('checkbox')).not.toBeOnTheScreen(); + }); + + it('should call onCheckboxPress when checkbox is pressed', async () => { + const onCheckboxPress = jest.fn(); + const merchantItem = createMerchantListItem('Starbucks'); + renderMerchantListItemHeader(merchantItem, {canSelectMultiple: true, onCheckboxPress}); + await waitForBatchedUpdatesWithAct(); + + const checkbox = screen.getByRole('checkbox'); + fireEvent.press(checkbox); + + expect(onCheckboxPress).toHaveBeenCalledWith(merchantItem); + }); + + it('should show checkbox as checked when isSelectAllChecked is true', async () => { + const merchantItem = createMerchantListItem('Starbucks'); + renderMerchantListItemHeader(merchantItem, {canSelectMultiple: true, isSelectAllChecked: true}); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByRole('checkbox')).toBeChecked(); + }); + }); + + describe('Total and count display', () => { + it('should display the total amount', async () => { + const merchantItem = createMerchantListItem('Starbucks', {total: 50000, currency: 'USD'}); + renderMerchantListItemHeader(merchantItem); + await waitForBatchedUpdatesWithAct(); + + // TotalCell formats the amount, so we check for the formatted version + // $500.00 is 50000 cents + expect(screen.getByText('$500.00')).toBeOnTheScreen(); + }); + + it('should display the total amount with different currencies', async () => { + const merchantItem = createMerchantListItem('Starbucks', {total: 10000, currency: 'EUR'}); + renderMerchantListItemHeader(merchantItem); + await waitForBatchedUpdatesWithAct(); + + // Should display EUR formatted amount + expect(screen.getByTestId('TotalCell')).toBeOnTheScreen(); + }); + }); + + describe('Disabled state', () => { + it('should render checkbox with disabled styling when isDisabled is true', async () => { + const onCheckboxPress = jest.fn(); + const merchantItem = createMerchantListItem('Starbucks'); + renderMerchantListItemHeader(merchantItem, {canSelectMultiple: true, isDisabled: true, onCheckboxPress}); + await waitForBatchedUpdatesWithAct(); + + const checkbox = screen.getByRole('checkbox'); + // The checkbox should still be rendered + expect(checkbox).toBeOnTheScreen(); + }); + + it('should render checkbox with disabled styling when isDisabledCheckbox is true on merchant item', async () => { + const onCheckboxPress = jest.fn(); + const merchantItem = createMerchantListItem('Starbucks', {isDisabledCheckbox: true}); + renderMerchantListItemHeader(merchantItem, {canSelectMultiple: true, onCheckboxPress}); + await waitForBatchedUpdatesWithAct(); + + const checkbox = screen.getByRole('checkbox'); + // The checkbox should still be rendered + expect(checkbox).toBeOnTheScreen(); + }); + }); + + describe('Large screen layout', () => { + beforeEach(() => { + mockedUseResponsiveLayout.mockReturnValue({ + isLargeScreenWidth: true, + shouldUseNarrowLayout: false, + isSmallScreenWidth: false, + isMediumScreenWidth: false, + isExtraSmallScreenWidth: false, + isExtraSmallScreenHeight: false, + isExtraLargeScreenWidth: true, + isSmallScreen: false, + isInNarrowPaneModal: false, + onboardingIsMediumOrLargerScreenWidth: true, + }); + }); + + it('should render column components on large screen', async () => { + const merchantItem = createMerchantListItem('Starbucks', {count: 5, total: 25000}); + renderMerchantListItemHeader(merchantItem, { + columns: [CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT, CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES, CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL], + }); + await waitForBatchedUpdatesWithAct(); + + // Should display merchant name, expense count, and total + expect(screen.getByText('Starbucks')).toBeOnTheScreen(); + expect(screen.getByText('5')).toBeOnTheScreen(); + expect(screen.getByText('$250.00')).toBeOnTheScreen(); + }); + + it('should render checkbox on large screen when canSelectMultiple is true', async () => { + const merchantItem = createMerchantListItem('Starbucks'); + renderMerchantListItemHeader(merchantItem, {canSelectMultiple: true}); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByRole('checkbox')).toBeOnTheScreen(); + }); + }); + + describe('Expand/Collapse functionality', () => { + it('should render expand/collapse button when onDownArrowClick is provided', async () => { + const onDownArrowClick = jest.fn(); + const merchantItem = createMerchantListItem('Starbucks'); + renderMerchantListItemHeader(merchantItem, {onDownArrowClick, isExpanded: false}); + await waitForBatchedUpdatesWithAct(); + + // The expand/collapse button should be rendered with "Expand" label when not expanded + const expandButton = screen.getByLabelText('Expand'); + expect(expandButton).toBeOnTheScreen(); + }); + + it('should call onDownArrowClick when expand/collapse button is pressed', async () => { + const onDownArrowClick = jest.fn(); + const merchantItem = createMerchantListItem('Starbucks'); + renderMerchantListItemHeader(merchantItem, {onDownArrowClick, isExpanded: false}); + await waitForBatchedUpdatesWithAct(); + + const expandButton = screen.getByLabelText('Expand'); + fireEvent.press(expandButton); + + expect(onDownArrowClick).toHaveBeenCalled(); + }); + + it('should show "Collapse" label when isExpanded is true', async () => { + const onDownArrowClick = jest.fn(); + const merchantItem = createMerchantListItem('Starbucks'); + renderMerchantListItemHeader(merchantItem, {onDownArrowClick, isExpanded: true}); + await waitForBatchedUpdatesWithAct(); + + const collapseButton = screen.getByLabelText('Collapse'); + expect(collapseButton).toBeOnTheScreen(); + }); + + it('should not render expand/collapse button when onDownArrowClick is not provided', async () => { + const merchantItem = createMerchantListItem('Starbucks'); + renderMerchantListItemHeader(merchantItem); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByLabelText('Expand')).not.toBeOnTheScreen(); + expect(screen.queryByLabelText('Collapse')).not.toBeOnTheScreen(); + }); + }); + + describe('Special merchant names', () => { + it('should handle merchants with special characters', async () => { + const merchantItem = createMerchantListItem("McDonald's & Co.", {formattedMerchant: "McDonald's & Co."}); + renderMerchantListItemHeader(merchantItem); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText("McDonald's & Co.")).toBeOnTheScreen(); + }); + + it('should handle merchants with Unicode characters', async () => { + const merchantItem = createMerchantListItem('カフェ東京', {formattedMerchant: 'カフェ東京'}); + renderMerchantListItemHeader(merchantItem); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('カフェ東京')).toBeOnTheScreen(); + }); + + it('should handle merchants with emoji', async () => { + const merchantItem = createMerchantListItem('Coffee ☕', {formattedMerchant: 'Coffee ☕'}); + renderMerchantListItemHeader(merchantItem); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('Coffee ☕')).toBeOnTheScreen(); + }); + }); +}); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 193191d0c30b9..b75ef222f9750 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -12,6 +12,7 @@ import type { TransactionGroupListItemType, TransactionListItemType, TransactionMemberGroupListItemType, + TransactionMerchantGroupListItemType, TransactionMonthGroupListItemType, TransactionReportGroupListItemType, TransactionTagGroupListItemType, @@ -1725,6 +1726,64 @@ const transactionCategoryGroupListItemsSorted: TransactionCategoryGroupListItemT }, ]; +// Merchant test data - backend uses hash-based keys (group_), not merchant names +const merchantName1 = 'Starbucks'; +const merchantName2 = 'Whole Foods'; +const merchantHash1 = '1234567890'; +const merchantHash2 = '9876543210'; + +const searchResultsGroupByMerchant: OnyxTypes.SearchResults = { + data: { + personalDetailsList: {}, + [`${CONST.SEARCH.GROUP_PREFIX}${merchantHash1}` as const]: { + merchant: merchantName1, + count: 7, + currency: 'USD', + total: 350, + }, + [`${CONST.SEARCH.GROUP_PREFIX}${merchantHash2}` as const]: { + merchant: merchantName2, + count: 4, + currency: 'USD', + total: 120, + }, + }, + search: { + count: 11, + currency: 'USD', + hasMoreResults: false, + hasResults: true, + offset: 0, + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + total: 470, + isLoading: false, + type: 'expense', + }, +}; + +const transactionMerchantGroupListItems: TransactionMerchantGroupListItemType[] = [ + { + merchant: merchantName1, + count: 7, + currency: 'USD', + total: 350, + groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, + formattedMerchant: merchantName1, + transactions: [], + transactionsQueryJSON: undefined, + }, + { + merchant: merchantName2, + count: 4, + currency: 'USD', + total: 120, + groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, + formattedMerchant: merchantName2, + transactions: [], + transactionsQueryJSON: undefined, + }, +]; + const tagName1 = 'Project A'; const tagName2 = 'Project B'; @@ -1780,6 +1839,29 @@ const transactionTagGroupListItems: TransactionTagGroupListItemType[] = [ }, ]; +const transactionMerchantGroupListItemsSorted: TransactionMerchantGroupListItemType[] = [ + { + merchant: merchantName1, + count: 7, + currency: 'USD', + total: 350, + groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, + formattedMerchant: merchantName1, + transactions: [], + transactionsQueryJSON: undefined, + }, + { + merchant: merchantName2, + count: 4, + currency: 'USD', + total: 120, + groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, + formattedMerchant: merchantName2, + transactions: [], + transactionsQueryJSON: undefined, + }, +]; + const searchResultsGroupByMonth: OnyxTypes.SearchResults = { data: { personalDetailsList: {}, @@ -2190,6 +2272,14 @@ describe('SearchUIUtils', () => { it('should return TransactionGroupListItem when type is INVOICE and groupBy is member', () => { expect(SearchUIUtils.getListItem(CONST.SEARCH.DATA_TYPES.INVOICE, CONST.SEARCH.STATUS.EXPENSE.ALL, CONST.SEARCH.GROUP_BY.FROM)).toStrictEqual(TransactionGroupListItem); }); + + it('should return TransactionGroupListItem when type is EXPENSE and groupBy is category', () => { + expect(SearchUIUtils.getListItem(CONST.SEARCH.DATA_TYPES.EXPENSE, CONST.SEARCH.STATUS.EXPENSE.ALL, CONST.SEARCH.GROUP_BY.CATEGORY)).toStrictEqual(TransactionGroupListItem); + }); + + it('should return TransactionGroupListItem when type is EXPENSE and groupBy is merchant', () => { + expect(SearchUIUtils.getListItem(CONST.SEARCH.DATA_TYPES.EXPENSE, CONST.SEARCH.STATUS.EXPENSE.ALL, CONST.SEARCH.GROUP_BY.MERCHANT)).toStrictEqual(TransactionGroupListItem); + }); }); describe('Test getSections', () => { @@ -2779,6 +2869,394 @@ describe('SearchUIUtils', () => { expect(quotesItem?.formattedCategory).toBe('"Special" Category'); }); + // Merchant groupBy tests + it('should return getMerchantSections result when type is EXPENSE and groupBy is merchant', () => { + expect( + SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: searchResultsGroupByMerchant.data, + currentAccountID: 2074551, + currentUserEmail: '', + translate: translateLocal, + formatPhoneNumber, + bankAccountList: {}, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + })[0], + ).toStrictEqual(transactionMerchantGroupListItems); + }); + + it('should handle empty merchant values correctly', () => { + // Backend uses hash-based keys (group_), not merchant names + const dataWithEmptyMerchant: OnyxTypes.SearchResults['data'] = { + personalDetailsList: {}, + [`${CONST.SEARCH.GROUP_PREFIX}111111111` as const]: { + merchant: '', + count: 2, + currency: 'USD', + total: 50, + }, + [`${CONST.SEARCH.GROUP_PREFIX}222222222` as const]: { + merchant: 'Starbucks', + count: 1, + currency: 'USD', + total: 25, + }, + }; + + const [result] = SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: dataWithEmptyMerchant, + currentAccountID: 2074551, + currentUserEmail: '', + translate: translateLocal, + formatPhoneNumber, + bankAccountList: {}, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + }) as [TransactionMerchantGroupListItemType[], number]; + + expect(result).toHaveLength(2); + expect(result.some((item) => item.merchant === '')).toBe(true); + expect(result.some((item) => item.merchant === 'Starbucks')).toBe(true); + }); + + it('should normalize empty merchant to MERCHANT_EMPTY_VALUE in transactionsQueryJSON', () => { + // Backend uses hash-based keys (group_), not merchant names + const dataWithEmptyMerchant: OnyxTypes.SearchResults['data'] = { + personalDetailsList: {}, + [`${CONST.SEARCH.GROUP_PREFIX}333333333` as const]: { + merchant: '', + count: 2, + currency: 'USD', + total: 50, + }, + }; + + const [result] = SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: dataWithEmptyMerchant, + currentAccountID: 2074551, + currentUserEmail: '', + translate: translateLocal, + formatPhoneNumber, + bankAccountList: {}, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + queryJSON: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: '', + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: CONST.SEARCH.VIEW.TABLE, + hash: 12345, + flatFilters: [], + inputQuery: 'type:expense groupBy:merchant', + recentSearchHash: 12345, + similarSearchHash: 12345, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE, + right: CONST.SEARCH.DATA_TYPES.EXPENSE, + }, + }, + }) as [TransactionMerchantGroupListItemType[], number]; + + expect(result).toHaveLength(1); + const emptyMerchantItem = result.find((item) => item.merchant === ''); + expect(emptyMerchantItem?.transactionsQueryJSON).toBeDefined(); + // The query should use 'none' (MERCHANT_EMPTY_VALUE) instead of empty string + expect(emptyMerchantItem?.transactionsQueryJSON?.inputQuery).toContain(CONST.SEARCH.MERCHANT_EMPTY_VALUE); + }); + + it('should treat DEFAULT_MERCHANT "Expense" as empty merchant and display "No merchant"', () => { + // Backend uses hash-based keys (group_), not merchant names + const dataWithDefaultMerchant: OnyxTypes.SearchResults['data'] = { + personalDetailsList: {}, + [`${CONST.SEARCH.GROUP_PREFIX}444444444` as const]: { + merchant: CONST.TRANSACTION.DEFAULT_MERCHANT, // 'Expense' + count: 1, + currency: 'USD', + total: 25, + }, + }; + + const [result] = SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: dataWithDefaultMerchant, + currentAccountID: 2074551, + currentUserEmail: '', + translate: translateLocal, + formatPhoneNumber, + bankAccountList: {}, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + queryJSON: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: '', + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: CONST.SEARCH.VIEW.TABLE, + hash: 12345, + flatFilters: [], + inputQuery: 'type:expense groupBy:merchant', + recentSearchHash: 12345, + similarSearchHash: 12345, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE, + right: CONST.SEARCH.DATA_TYPES.EXPENSE, + }, + }, + }) as [TransactionMerchantGroupListItemType[], number]; + + expect(result).toHaveLength(1); + // The merchant field keeps the original value for query purposes + expect(result.at(0)?.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT); + // But formattedMerchant should be "No merchant" for display + expect(result.at(0)?.formattedMerchant).toBe('No merchant'); + }); + + it('should treat PARTIAL_TRANSACTION_MERCHANT "(none)" as empty merchant and display "No merchant"', () => { + // Backend uses hash-based keys (group_), not merchant names + const dataWithPartialMerchant: OnyxTypes.SearchResults['data'] = { + personalDetailsList: {}, + [`${CONST.SEARCH.GROUP_PREFIX}555555550` as const]: { + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, // '(none)' + count: 1, + currency: 'USD', + total: 25, + }, + }; + + const [result] = SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: dataWithPartialMerchant, + currentAccountID: 2074551, + currentUserEmail: '', + translate: translateLocal, + formatPhoneNumber, + bankAccountList: {}, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + queryJSON: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: '', + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: CONST.SEARCH.VIEW.TABLE, + hash: 12345, + flatFilters: [], + inputQuery: 'type:expense groupBy:merchant', + recentSearchHash: 12345, + similarSearchHash: 12345, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE, + right: CONST.SEARCH.DATA_TYPES.EXPENSE, + }, + }, + }) as [TransactionMerchantGroupListItemType[], number]; + + expect(result).toHaveLength(1); + // The merchant field keeps the original value for query purposes + expect(result.at(0)?.merchant).toBe(CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT); + // But formattedMerchant should be "No merchant" for display + expect(result.at(0)?.formattedMerchant).toBe('No merchant'); + }); + + it('should treat UNKNOWN_MERCHANT "Unknown Merchant" as empty merchant and display "No merchant"', () => { + // Backend uses hash-based keys (group_), not merchant names + const dataWithUnknownMerchant: OnyxTypes.SearchResults['data'] = { + personalDetailsList: {}, + [`${CONST.SEARCH.GROUP_PREFIX}666666660` as const]: { + merchant: CONST.TRANSACTION.UNKNOWN_MERCHANT, // 'Unknown Merchant' + count: 1, + currency: 'USD', + total: 25, + }, + }; + + const [result] = SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: dataWithUnknownMerchant, + currentAccountID: 2074551, + currentUserEmail: '', + translate: translateLocal, + formatPhoneNumber, + bankAccountList: {}, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + queryJSON: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: '', + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: CONST.SEARCH.VIEW.TABLE, + hash: 12345, + flatFilters: [], + inputQuery: 'type:expense groupBy:merchant', + recentSearchHash: 12345, + similarSearchHash: 12345, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE, + right: CONST.SEARCH.DATA_TYPES.EXPENSE, + }, + }, + }) as [TransactionMerchantGroupListItemType[], number]; + + expect(result).toHaveLength(1); + // The merchant field keeps the original value for query purposes + expect(result.at(0)?.merchant).toBe(CONST.TRANSACTION.UNKNOWN_MERCHANT); + // But formattedMerchant should be "No merchant" for display + expect(result.at(0)?.formattedMerchant).toBe('No merchant'); + }); + + it('should return isTransactionMerchantGroupListItemType true for merchant group items', () => { + const merchantItem: TransactionMerchantGroupListItemType = { + merchant: 'Starbucks', + count: 5, + currency: 'USD', + total: 250, + groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, + formattedMerchant: 'Starbucks', + transactions: [], + transactionsQueryJSON: undefined, + }; + + expect(SearchUIUtils.isTransactionMerchantGroupListItemType(merchantItem)).toBe(true); + }); + + it('should return isTransactionMerchantGroupListItemType false for non-merchant group items', () => { + const categoryItem: TransactionCategoryGroupListItemType = { + category: 'Travel', + count: 3, + currency: 'USD', + total: 100, + groupedBy: CONST.SEARCH.GROUP_BY.CATEGORY, + formattedCategory: 'Travel', + transactions: [], + transactionsQueryJSON: undefined, + }; + + expect(SearchUIUtils.isTransactionMerchantGroupListItemType(categoryItem)).toBe(false); + }); + + it('should generate transactionsQueryJSON with valid hash for merchant sections', () => { + const [result] = SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: searchResultsGroupByMerchant.data, + currentAccountID: 2074551, + currentUserEmail: '', + translate: translateLocal, + formatPhoneNumber, + bankAccountList: {}, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + queryJSON: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: '', + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: CONST.SEARCH.VIEW.TABLE, + hash: 12345, + flatFilters: [], + inputQuery: 'type:expense groupBy:merchant', + recentSearchHash: 12345, + similarSearchHash: 12345, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE, + right: CONST.SEARCH.DATA_TYPES.EXPENSE, + }, + }, + }) as [TransactionMerchantGroupListItemType[], number]; + + // Each merchant section should have a transactionsQueryJSON with a hash + for (const item of result) { + expect(item.transactionsQueryJSON).toBeDefined(); + expect(item.transactionsQueryJSON?.hash).toBeDefined(); + expect(typeof item.transactionsQueryJSON?.hash).toBe('number'); + } + }); + + it('should handle Unicode characters in merchant names', () => { + // Backend uses hash-based keys (group_), not merchant names + const dataWithUnicode: OnyxTypes.SearchResults['data'] = { + personalDetailsList: {}, + [`${CONST.SEARCH.GROUP_PREFIX}555555555` as const]: { + merchant: 'カフェ東京', + count: 3, + currency: 'JPY', + total: 50000, + }, + [`${CONST.SEARCH.GROUP_PREFIX}666666666` as const]: { + merchant: '北京饭店', + count: 2, + currency: 'CNY', + total: 1000, + }, + [`${CONST.SEARCH.GROUP_PREFIX}777777777` as const]: { + merchant: 'Coffee ☕', + count: 1, + currency: 'USD', + total: 500, + }, + }; + + const [result] = SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: dataWithUnicode, + currentAccountID: 2074551, + currentUserEmail: '', + translate: translateLocal, + formatPhoneNumber, + bankAccountList: {}, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + }) as [TransactionMerchantGroupListItemType[], number]; + + expect(result).toHaveLength(3); + expect(result.some((item) => item.merchant === 'カフェ東京')).toBe(true); + expect(result.some((item) => item.merchant === '北京饭店')).toBe(true); + expect(result.some((item) => item.merchant === 'Coffee ☕')).toBe(true); + }); + + it('should handle special characters in merchant names', () => { + // Backend uses hash-based keys (group_), not merchant names + const dataWithSpecialChars: OnyxTypes.SearchResults['data'] = { + personalDetailsList: {}, + [`${CONST.SEARCH.GROUP_PREFIX}888888888` as const]: { + merchant: "McDonald's & Co.", + count: 5, + currency: 'USD', + total: 2500, + }, + [`${CONST.SEARCH.GROUP_PREFIX}999999999` as const]: { + merchant: 'Walmart (Express)', + count: 2, + currency: 'USD', + total: 1000, + }, + [`${CONST.SEARCH.GROUP_PREFIX}101010101` as const]: { + merchant: '"Best" Coffee', + count: 1, + currency: 'USD', + total: 300, + }, + }; + + const [result] = SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: dataWithSpecialChars, + currentAccountID: 2074551, + currentUserEmail: '', + translate: translateLocal, + formatPhoneNumber, + bankAccountList: {}, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + }) as [TransactionMerchantGroupListItemType[], number]; + + expect(result).toHaveLength(3); + expect(result.some((item) => item.merchant === "McDonald's & Co.")).toBe(true); + expect(result.some((item) => item.merchant === 'Walmart (Express)')).toBe(true); + expect(result.some((item) => item.merchant === '"Best" Coffee')).toBe(true); + }); + + // Tag groupBy tests it('should return getTagSections result when type is EXPENSE and groupBy is tag', () => { expect( SearchUIUtils.getSections({ @@ -3098,6 +3576,162 @@ describe('SearchUIUtils', () => { expect(result.at(1)?.count).toBe(3); }); + // Merchant sorting tests + it('should return getSortedMerchantData result when type is EXPENSE and groupBy is merchant', () => { + expect( + SearchUIUtils.getSortedSections( + CONST.SEARCH.DATA_TYPES.EXPENSE, + '', + transactionMerchantGroupListItems, + localeCompare, + translateLocal, + CONST.SEARCH.TABLE_COLUMNS.DATE, + CONST.SEARCH.SORT_ORDER.ASC, + CONST.SEARCH.GROUP_BY.MERCHANT, + ), + ).toStrictEqual(transactionMerchantGroupListItemsSorted); + }); + + it('should sort merchant data by merchant name in ascending order', () => { + const result = SearchUIUtils.getSortedSections( + CONST.SEARCH.DATA_TYPES.EXPENSE, + '', + transactionMerchantGroupListItems, + localeCompare, + translateLocal, + CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT, + CONST.SEARCH.SORT_ORDER.ASC, + CONST.SEARCH.GROUP_BY.MERCHANT, + ) as TransactionMerchantGroupListItemType[]; + + // "Starbucks" should come before "Whole Foods" in ascending alphabetical order + expect(result.at(0)?.merchant).toBe(merchantName1); // Starbucks + expect(result.at(1)?.merchant).toBe(merchantName2); // Whole Foods + }); + + it('should sort merchant data by merchant name in descending order', () => { + const result = SearchUIUtils.getSortedSections( + CONST.SEARCH.DATA_TYPES.EXPENSE, + '', + transactionMerchantGroupListItems, + localeCompare, + translateLocal, + CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT, + CONST.SEARCH.SORT_ORDER.DESC, + CONST.SEARCH.GROUP_BY.MERCHANT, + ) as TransactionMerchantGroupListItemType[]; + + // "Whole Foods" should come before "Starbucks" in descending alphabetical order + expect(result.at(0)?.merchant).toBe(merchantName2); // Whole Foods + expect(result.at(1)?.merchant).toBe(merchantName1); // Starbucks + }); + + it('should sort merchant data by total amount', () => { + const result = SearchUIUtils.getSortedSections( + CONST.SEARCH.DATA_TYPES.EXPENSE, + '', + transactionMerchantGroupListItems, + localeCompare, + translateLocal, + CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL, + CONST.SEARCH.SORT_ORDER.DESC, + CONST.SEARCH.GROUP_BY.MERCHANT, + ) as TransactionMerchantGroupListItemType[]; + + // Starbucks (350) should come before Whole Foods (120) when sorted by total descending + expect(result.at(0)?.total).toBe(350); + expect(result.at(1)?.total).toBe(120); + }); + + it('should sort merchant data using non-group column name (parser default sortBy)', () => { + // The parser sets default sortBy to 'merchant' (not 'groupMerchant') when groupBy is merchant + // This test verifies that the sorting works with the parser's default value + const result = SearchUIUtils.getSortedSections( + CONST.SEARCH.DATA_TYPES.EXPENSE, + '', + transactionMerchantGroupListItems, + localeCompare, + translateLocal, + CONST.SEARCH.TABLE_COLUMNS.MERCHANT, // Parser default: 'merchant' not 'groupMerchant' + CONST.SEARCH.SORT_ORDER.ASC, + CONST.SEARCH.GROUP_BY.MERCHANT, + ) as TransactionMerchantGroupListItemType[]; + + // "Starbucks" should come before "Whole Foods" in ascending alphabetical order + expect(result.at(0)?.merchant).toBe(merchantName1); // Starbucks + expect(result.at(1)?.merchant).toBe(merchantName2); // Whole Foods + }); + + it('should sort merchant data by expenses count', () => { + const result = SearchUIUtils.getSortedSections( + CONST.SEARCH.DATA_TYPES.EXPENSE, + '', + transactionMerchantGroupListItems, + localeCompare, + translateLocal, + CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES, + CONST.SEARCH.SORT_ORDER.DESC, + CONST.SEARCH.GROUP_BY.MERCHANT, + ) as TransactionMerchantGroupListItemType[]; + + // Starbucks (7 expenses) should come before Whole Foods (4 expenses) when sorted by count descending + expect(result.at(0)?.count).toBe(7); + expect(result.at(1)?.count).toBe(4); + }); + + it('should sort "No merchant" alphabetically like other merchant names', () => { + // "No merchant" should sort alphabetically, not at the bottom + const merchantDataWithEmpty: TransactionMerchantGroupListItemType[] = [ + { + merchant: '', + count: 2, + currency: 'USD', + total: 50, + groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, + formattedMerchant: 'No merchant', // Translated by getMerchantSections + transactions: [], + transactionsQueryJSON: undefined, + }, + { + merchant: 'Apple Store', + count: 3, + currency: 'USD', + total: 100, + groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, + formattedMerchant: 'Apple Store', + transactions: [], + transactionsQueryJSON: undefined, + }, + { + merchant: 'Zebra Coffee', + count: 1, + currency: 'USD', + total: 25, + groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, + formattedMerchant: 'Zebra Coffee', + transactions: [], + transactionsQueryJSON: undefined, + }, + ]; + + const result = SearchUIUtils.getSortedSections( + CONST.SEARCH.DATA_TYPES.EXPENSE, + '', + merchantDataWithEmpty, + localeCompare, + translateLocal, + CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT, + CONST.SEARCH.SORT_ORDER.ASC, + CONST.SEARCH.GROUP_BY.MERCHANT, + ) as TransactionMerchantGroupListItemType[]; + + // Should sort alphabetically: "Apple Store", "No merchant", "Zebra Coffee" + expect(result.at(0)?.formattedMerchant).toBe('Apple Store'); + expect(result.at(1)?.formattedMerchant).toBe('No merchant'); + expect(result.at(2)?.formattedMerchant).toBe('Zebra Coffee'); + }); + + // Tag sorting tests it('should return getSortedTagData result when type is EXPENSE and groupBy is tag', () => { expect( SearchUIUtils.getSortedSections( @@ -4171,6 +4805,94 @@ describe('SearchUIUtils', () => { }); }); + describe('Test getCustomColumns', () => { + it('should return custom columns for EXPENSE type', () => { + const columns = SearchUIUtils.getCustomColumns(CONST.SEARCH.DATA_TYPES.EXPENSE); + expect(columns).toEqual(Object.values(CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE)); + }); + + it('should return custom columns for CATEGORY groupBy', () => { + const columns = SearchUIUtils.getCustomColumns(CONST.SEARCH.GROUP_BY.CATEGORY); + expect(columns).toEqual(Object.values(CONST.SEARCH.GROUP_CUSTOM_COLUMNS.CATEGORY)); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.GROUP_CATEGORY); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL); + }); + + it('should return custom columns for MERCHANT groupBy', () => { + const columns = SearchUIUtils.getCustomColumns(CONST.SEARCH.GROUP_BY.MERCHANT); + expect(columns).toEqual(Object.values(CONST.SEARCH.GROUP_CUSTOM_COLUMNS.MERCHANT)); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL); + }); + + it('should return empty array for undefined value', () => { + const columns = SearchUIUtils.getCustomColumns(undefined); + expect(columns).toEqual([]); + }); + }); + + describe('Test getCustomColumnDefault', () => { + it('should return default columns for EXPENSE type', () => { + const columns = SearchUIUtils.getCustomColumnDefault(CONST.SEARCH.DATA_TYPES.EXPENSE); + expect(columns).toEqual(CONST.SEARCH.TYPE_DEFAULT_COLUMNS.EXPENSE); + }); + + it('should return default columns for CATEGORY groupBy', () => { + const columns = SearchUIUtils.getCustomColumnDefault(CONST.SEARCH.GROUP_BY.CATEGORY); + expect(columns).toEqual(CONST.SEARCH.GROUP_DEFAULT_COLUMNS.CATEGORY); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.GROUP_CATEGORY); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL); + }); + + it('should return default columns for MERCHANT groupBy', () => { + const columns = SearchUIUtils.getCustomColumnDefault(CONST.SEARCH.GROUP_BY.MERCHANT); + expect(columns).toEqual(CONST.SEARCH.GROUP_DEFAULT_COLUMNS.MERCHANT); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL); + }); + + it('should return empty array for undefined value', () => { + const columns = SearchUIUtils.getCustomColumnDefault(undefined); + expect(columns).toEqual([]); + }); + }); + + describe('Test getSearchColumnTranslationKey', () => { + it('should return correct translation key for GROUP_CATEGORY', () => { + const translationKey = SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.GROUP_CATEGORY); + expect(translationKey).toBe('common.category'); + }); + + it('should return correct translation key for GROUP_MERCHANT', () => { + const translationKey = SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT); + expect(translationKey).toBe('common.merchant'); + }); + + it('should return correct translation key for GROUP_EXPENSES', () => { + const translationKey = SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES); + expect(translationKey).toBe('common.expenses'); + }); + + it('should return correct translation key for GROUP_TOTAL', () => { + const translationKey = SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL); + expect(translationKey).toBe('common.total'); + }); + + it('should return correct translation key for MERCHANT column', () => { + const translationKey = SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.MERCHANT); + expect(translationKey).toBe('common.merchant'); + }); + + it('should return correct translation key for CATEGORY column', () => { + const translationKey = SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.CATEGORY); + expect(translationKey).toBe('common.category'); + }); + }); + describe('createAndOpenSearchTransactionThread', () => { const threadReportID = 'thread-report-123'; const threadReport = {reportID: threadReportID};