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$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 = /^[:=]/;
@@ -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};