Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
430a63e
feat: [Insights] [Release 2] Top Merchants - Add group-by:merchant an…
TaduJR Jan 27, 2026
e53a673
fix: normalize empty merchant values to avoid invalid search query
TaduJR Jan 27, 2026
930435d
Merge branch 'main' of https://github.com/TaduJR/App into feat-Insigh…
TaduJR Jan 27, 2026
a772852
fix: address PR review feedback for merchant grouping
TaduJR Jan 27, 2026
3b74d40
Merge branch 'main' into feat-Insights-Release-2-Top-Merchants-Add-gr…
TaduJR Jan 28, 2026
6f1357d
fix: Revert unintended Mobile-Expensify submodule change
TaduJR Jan 28, 2026
271e302
Merge branch 'main' into feat-Insights-Release-2-Top-Merchants-Add-gr…
TaduJR Jan 28, 2026
66c7953
refactor: Address PR review feedback for list item headers
TaduJR Jan 28, 2026
bc745e5
chore: run prettier
TaduJR Jan 28, 2026
9eec09c
refactor: Sort 'No merchant' alphabetically like 'No tag'
TaduJR Jan 28, 2026
b11181e
fix: Use top-level type imports instead of inline type specifiers
TaduJR Jan 28, 2026
d6b0e83
chore: Temporarily disable max-lines rule for SearchUIUtils.ts
TaduJR Jan 28, 2026
e800c92
fix: treat DEFAULT_MERCHANT 'Expense' as empty merchant in Top Merchants
TaduJR Jan 28, 2026
3ecc56e
feat: add Basket icon for Top Merchants suggested search
TaduJR Jan 28, 2026
1ffd4cd
feat: add basket icon for Top Merchants suggested search
TaduJR Jan 28, 2026
b978864
chore: compress basket.svg
TaduJR Jan 28, 2026
f6d4fb3
fix: update merchant test data to use hash-based keys matching backen…
TaduJR Jan 28, 2026
d28b13c
refactor: remove unused Building icon from search menu lazy icons
TaduJR Jan 28, 2026
37e2c1f
fix: handle all empty merchant values (none, Expense, (none), Unknown…
TaduJR Jan 28, 2026
3ba084e
chore: remove debug logging from getMerchantSections
TaduJR Jan 28, 2026
5cd7fe3
refactor: use transactionGroupBaseSortingProperties spread in merchan…
TaduJR Jan 28, 2026
c9ca3b7
feat: add groupMerchant to search parser peggy rules
TaduJR Jan 28, 2026
4382a14
fix: add MERCHANT case to isTransactionMatchWithGroupItem function
TaduJR Jan 28, 2026
f6bd386
fix: add merchant entries to searchParser.peggy GROUP_BY_DEFAULT_SORT
TaduJR Jan 28, 2026
bceb5c3
fix: use asc sort order for merchant grouping
TaduJR Jan 28, 2026
c1de48c
fix: use camelCase groupMerchant and asc sort order in searchParser
TaduJR Jan 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/images/basket.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6882,6 +6882,7 @@ const CONST = {
CARD: 'card',
WITHDRAWAL_ID: 'withdrawal-id',
CATEGORY: 'category',
MERCHANT: 'merchant',
TAG: 'tag',
MONTH: 'month',
},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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],
};
Expand Down Expand Up @@ -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',
},
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
};
},
Expand Down Expand Up @@ -7349,6 +7359,7 @@ const CONST = {
RECONCILIATION: 'reconciliation',
TOP_SPENDERS: 'topSpenders',
TOP_CATEGORIES: 'topCategories',
TOP_MERCHANTS: 'topMerchants',
},
GROUP_PREFIX: 'group_',
ANIMATION: {
Expand Down
4 changes: 3 additions & 1 deletion src/components/Icon/Expensicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -166,8 +167,9 @@ export {
Apple,
AttachmentNotFound,
Bank,
Bill,
Basket,
Bell,
Bill,
Bolt,
Bug,
Building,
Expand Down
2 changes: 2 additions & 0 deletions src/components/Icon/chunks/expensify-icons.chunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -256,6 +257,7 @@ const Expensicons = {
AttachmentNotFound,
BackArrow,
Bank,
Basket,
CircularArrowBackwards,
Bill,
BillComSquare,
Expand Down
4 changes: 4 additions & 0 deletions src/components/Search/SearchList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
TransactionCategoryGroupListItemType,
TransactionGroupListItemType,
TransactionListItemType,
TransactionMerchantGroupListItemType,
TransactionMonthGroupListItemType,
} from '@components/SelectionListWithSections/types';
import Text from '@components/Text';
Expand Down Expand Up @@ -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 ?? '';
Expand Down
15 changes: 15 additions & 0 deletions src/components/Search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
isTransactionGroupListItemType,
isTransactionListItemType,
isTransactionMemberGroupListItemType,
isTransactionMerchantGroupListItemType,
isTransactionMonthGroupListItemType,
isTransactionTagGroupListItemType,
isTransactionWithdrawalIDGroupListItemType,
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/components/Search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ type SearchCustomColumnIds =
| ValueOf<typeof CONST.SEARCH.GROUP_CUSTOM_COLUMNS.FROM>
| ValueOf<typeof CONST.SEARCH.GROUP_CUSTOM_COLUMNS.WITHDRAWAL_ID>
| ValueOf<typeof CONST.SEARCH.GROUP_CUSTOM_COLUMNS.CATEGORY>
| ValueOf<typeof CONST.SEARCH.GROUP_CUSTOM_COLUMNS.MERCHANT>
| ValueOf<typeof CONST.SEARCH.GROUP_CUSTOM_COLUMNS.TAG>
| ValueOf<typeof CONST.SEARCH.GROUP_CUSTOM_COLUMNS.MONTH>;

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we name it MONTH to follow the convention?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not make a change that does not directly affect the actual implementation.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This refactoring is supposed to be a part of this PR. See #80672 (review)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, but not the variable name changing.


type BaseListItemHeaderProps<TItem extends ListItem> = {
/** 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<TItem extends ListItem>({
item,
displayName,
groupColumnKey,
columnStyleKey,
onCheckboxPress,
isDisabled,
canSelectMultiple,
isSelectAllChecked,
isIndeterminate,
isExpanded,
onDownArrowClick,
columns,
}: BaseListItemHeaderProps<TItem>) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {isLargeScreenWidth} = useResponsiveLayout();
const {translate} = useLocalize();

const columnComponents = {
[groupColumnKey]: (
<View
key={groupColumnKey}
style={StyleUtils.getReportTableColumnStyles(columnStyleKey)}
>
<View style={[styles.gap1, styles.flexShrink1]}>
<TextWithTooltip
text={displayName}
style={[styles.optionDisplayName, styles.sidebarLinkTextBold, styles.pre, styles.fontWeightNormal]}
/>
</View>
</View>
),
[CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES]: (
<View
key={CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES}
style={StyleUtils.getReportTableColumnStyles(CONST.SEARCH.TABLE_COLUMNS.EXPENSES)}
>
<TextCell text={String(item.count)} />
</View>
),
[CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL]: (
<View
key={CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL}
style={StyleUtils.getReportTableColumnStyles(CONST.SEARCH.TABLE_COLUMNS.TOTAL, false, false, false, false, false, false, false, true)}
>
<TotalCell
total={item.total}
currency={item.currency}
/>
</View>
),
};

return (
<View>
<View style={[styles.pv1Half, styles.pl3, styles.flexRow, styles.alignItemsCenter, isLargeScreenWidth ? styles.gap3 : styles.justifyContentStart]}>
<View style={[styles.flexRow, styles.alignItemsCenter, styles.mnh40, styles.flex1, styles.gap3]}>
{!!canSelectMultiple && (
<Checkbox
onPress={() => onCheckboxPress?.(item as unknown as TItem)}
isChecked={isSelectAllChecked}
isIndeterminate={isIndeterminate}
disabled={!!isDisabled || item.isDisabledCheckbox}
accessibilityLabel={translate('common.select')}
style={isLargeScreenWidth && styles.mr1}
/>
)}
{!isLargeScreenWidth && (
<View style={[styles.flexRow, styles.flex1, styles.gap3]}>
<View style={[styles.gap1, styles.flexShrink1]}>
<TextWithTooltip
text={displayName}
style={[styles.optionDisplayName, styles.sidebarLinkTextBold, styles.pre, styles.fontWeightNormal]}
/>
</View>
</View>
)}
{isLargeScreenWidth && columns?.map((column) => columnComponents[column as keyof typeof columnComponents])}
</View>
{!isLargeScreenWidth && (
<View style={[styles.flexShrink0, styles.ml2, styles.mr3, styles.gap1]}>
<TotalCell
total={item.total}
currency={item.currency}
/>
{!!onDownArrowClick && (
<ExpandCollapseArrowButton
isExpanded={isExpanded}
onPress={onDownArrowClick}
/>
)}
</View>
)}
</View>
</View>
);
}

export default BaseListItemHeader;
export type {BaseListItemHeaderProps, BaseGroupListItemType};
Loading
Loading