Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
22f0cde
fix: Search selections of partly loaded groups and Select all exclusions
TaduJR Oct 9, 2026
32faa32
Merge branch 'main' of https://github.com/TaduJR/App into fix/90539-s…
TaduJR Oct 10, 2026
58c9b12
fix: Hide Download receipts next to a group that is still loading
TaduJR Oct 10, 2026
8c4af47
fix: Clear a group's off-page rows when a range gives it back, and ke…
TaduJR Oct 10, 2026
6a9f46a
fix: Keep a fully checked group whole when an expense leaves it off t…
TaduJR Oct 10, 2026
78d586e
test: Expect Download receipts hidden next to a collapsed group
TaduJR Oct 10, 2026
b566f85
fix: A group with an unchecked loaded expense never reads as fully se…
TaduJR Oct 10, 2026
ad6bf9f
fix: Check loaded rows wherever group coverage is decided, and let de…
TaduJR Oct 10, 2026
ee96c8b
refactor: Give buildGroupSelectedEntry its own params type
TaduJR Oct 10, 2026
b438db3
fix: Prove group coverage only from loaded rows, and remember the cou…
TaduJR Oct 10, 2026
fa4c673
fix: Keep row actions for groups with rows off the page
TaduJR Oct 10, 2026
deed9a4
Merge branch 'main' of https://github.com/TaduJR/App into fix/90539-s…
TaduJR Oct 19, 2026
7c702ff
Merge branch 'main' of https://github.com/TaduJR/App into fix/90539-s…
TaduJR Oct 20, 2026
8f39e06
fix: Keep a fully checked group whole after a delete, and add re-chec…
TaduJR Oct 20, 2026
8ef878f
fix: Drop a selected expense that left its group instead of keeping i…
TaduJR Oct 20, 2026
0398bec
fix: Drop the selected expenses of a group whose every row left it
TaduJR Oct 20, 2026
250c1a8
Merge branch 'main' of https://github.com/TaduJR/App into fix/90539-s…
TaduJR Oct 20, 2026
9769f43
fix: End a group's coverage when an expense is rejected off the page,…
TaduJR Oct 20, 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
2 changes: 1 addition & 1 deletion src/components/MoneyRequestHeaderSecondaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money
return;
}

removeTransaction(transaction.transactionID);
removeTransaction(transaction.transactionID, {isDeleted: true});
};
}
if (isInNarrowPaneModal) {
Expand Down
7 changes: 4 additions & 3 deletions src/components/Search/SearchBulkActionsButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type {BulkPaySelectionData, SearchQueryJSON, SelectedTransactions} from '
import BulkDuplicateHandler from './BulkDuplicateHandler';
import BulkDuplicateReportHandler from './BulkDuplicateReportHandler';
import {useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from './SearchContext';
import {getRowsCheckedInExcludedGroups, mergeRowsIntoPartlyLoadedGroups} from './selectionBuilders';

type SearchBulkActionsButtonProps = {
queryJSON: SearchQueryJSON;
Expand Down Expand Up @@ -127,11 +128,11 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) {
};

return {
selectedItemsCount: getItemsCount(selectedTransactions, 1),
selectedItemsCount: getItemsCount(mergeRowsIntoPartlyLoadedGroups(selectedTransactions, searchData, areAllMatchingItemsSelected), 1),
// Excluded items come off the server count, which only sums expenses, so a cash back row takes nothing off.
excludedItemsCount: getItemsCount(excludedTransactions, 0),
excludedItemsCount: getItemsCount(excludedTransactions, 0) - getItemsCount(getRowsCheckedInExcludedGroups(selectedTransactions, excludedTransactions), 0),
};
}, [excludedTransactions, selectedTransactions, isExpenseReportType, searchData]);
}, [excludedTransactions, selectedTransactions, isExpenseReportType, searchData, areAllMatchingItemsSelected]);

const payBulkSelectedItem = (subItem: PopoverMenuItem, triggerKYCFlow: (kycParams: ContinueActionParams) => void) =>
handleBulkPayItemSelected({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,11 @@ function ExpenseReportListItemInner<TItem extends ListItem>({
* so the row's own key alone can never reflect it. A report is checked by its rows once it has them, and
* by its own key only while it has none.
*/
const {isSelectAllChecked: isSelected, isIndeterminate} = useGroupCheckboxState({groupKey: item.keyForList, groupTransactions: reportItem.transactions ?? []});
const {isSelectAllChecked: isSelected, isIndeterminate} = useGroupCheckboxState({
groupKey: item.keyForList,
groupTransactions: reportItem.transactions ?? [],
groupCount: undefined,
});
const {translate, dateFnsLocale} = useLocalize();
const {getCurrencyDecimals, convertToDisplayString} = useCurrencyListActions();
const {isLargeScreenWidth} = useResponsiveLayout();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {getSearchGroupCount} from '@components/Search/selectionBuilders';

import useExpandCollapseAnimation from '@hooks/useExpandCollapseAnimation';
import useLayoutSpacing from '@hooks/useLayoutSpacing';
import useStyleUtils from '@hooks/useStyleUtils';
Expand Down Expand Up @@ -39,7 +41,7 @@ function GroupChildrenContainer({
const hasBorder = !isFirstItem;
const {isRendered, animatedStyle, onLayout} = useExpandCollapseAnimation(isExpanded, isExpanded && hasBorder, item.keyForList);
const isContentVisible = isExpanded || isRendered;
const {isSelectAllChecked} = useGroupCheckboxState({groupKey: item.groupKeyForList, groupTransactions: item.transactions});
const {isSelectAllChecked} = useGroupCheckboxState({groupKey: item.groupKeyForList, groupTransactions: item.transactions, groupCount: getSearchGroupCount(item)});

// Only the rows this container holds decide its background, so a group still waiting for its first page is not painted as selected.
const isSelected = !!item.isSelected || (item.transactions.length > 0 && isSelectAllChecked);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {getSearchGroupCount} from '@components/Search/selectionBuilders';

import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
Expand Down Expand Up @@ -47,6 +49,7 @@ function GroupChildrenContent({
const {transactions} = useGroupChildren({
groupKey: groupItem.groupKeyForList,
groupTransactions: groupItem.transactions,
groupCount: getSearchGroupCount(groupItem),
});

const isEmpty = transactions.length === 0;
Expand Down
7 changes: 6 additions & 1 deletion src/components/Search/SearchList/ListItem/GroupHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback';
import {PressableWithFeedback} from '@components/Pressable';
import {useHorizontalScrollFollower} from '@components/Search/hooks/useSyncedHorizontalScroll';
import SearchTableHeader from '@components/Search/SearchTableHeader';
import {getSearchGroupCount} from '@components/Search/selectionBuilders';
import type {SearchColumnType, SearchCustomColumnIds, SearchGroupBy} from '@components/Search/types';
import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types';

Expand Down Expand Up @@ -185,7 +186,11 @@ function GroupHeader({
const isCashBackWithdrawal = isCashBackWithdrawalGroup(groupItem);

// The same derivation the narrow layout reads, so the two cannot disagree about what a group's checkbox shows.
const {isSelectAllChecked, isIndeterminate} = useGroupCheckboxState({groupKey: item.groupKeyForList, groupTransactions: groupItem.transactions});
const {isSelectAllChecked, isIndeterminate} = useGroupCheckboxState({
groupKey: item.groupKeyForList,
groupTransactions: groupItem.transactions,
groupCount: getSearchGroupCount(groupItem),
});

const isItemSelected = isSelectAllChecked || item?.isSelected;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback';
import {PressableWithFeedback} from '@components/Pressable';
import {useSearchResultsContext} from '@components/Search/SearchContext';
import {useRowSelection} from '@components/Search/SearchSelectionProvider';
import {getSearchGroupCount} from '@components/Search/selectionBuilders';
import type {SearchGroupBy} from '@components/Search/types';
import type {ListItem} from '@components/SelectionList/types';

Expand Down Expand Up @@ -154,6 +155,7 @@ function TransactionGroupListItemImpl({
const {transactions, isSelectAllChecked, isIndeterminate} = useGroupChildren({
groupKey: groupItem.keyForList,
groupTransactions: groupItem.transactions,
groupCount: getSearchGroupCount(groupItem),
});

const transactionsWithoutPendingDelete = transactions.filter((transaction) => !isTransactionPendingDelete(transaction));
Expand Down
3 changes: 3 additions & 0 deletions src/components/Search/SearchList/ListItem/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ type TransactionGroupListItemType = ListItem & {

transactionsQueryJSON?: SearchQueryJSON;

/** Expenses the group's own search returned that the list no longer shows, because they stopped matching it after it ran */
hiddenTransactionIDs?: string[];

/** Whether the report has visible violations for user */
hasVisibleViolations?: boolean;

Expand Down
11 changes: 7 additions & 4 deletions src/components/Search/SearchList/ListItem/useGroupChildren.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,27 @@ type GroupCheckboxArgs = {

/** The rows the group carries */
groupTransactions: TransactionListItemType[];

/** How many rows the group holds, loaded or not, or undefined where the group carries no count */
groupCount: number | undefined;
};

/** What a group's checkbox shows. Every surface that draws one reads it from here, so they cannot disagree. */
function useGroupCheckboxState({groupKey, groupTransactions}: GroupCheckboxArgs): {isSelectAllChecked: boolean; isIndeterminate: boolean} {
function useGroupCheckboxState({groupKey, groupTransactions, groupCount}: GroupCheckboxArgs): {isSelectAllChecked: boolean; isIndeterminate: boolean} {
const {selectedTransactions, excludedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext();

return getGroupCheckboxState({groupKey, children: groupTransactions, selectedTransactions, excludedTransactions, areAllMatchingItemsSelected});
return getGroupCheckboxState({groupKey, children: groupTransactions, selectedTransactions, excludedTransactions, areAllMatchingItemsSelected, groupCount});
}

/** The same, plus the group's rows stamped with the live selection, for the call sites that render those rows. */
function useGroupChildren({groupKey, groupTransactions}: GroupCheckboxArgs): {
function useGroupChildren({groupKey, groupTransactions, groupCount}: GroupCheckboxArgs): {
transactions: TransactionListItemType[];
isSelectAllChecked: boolean;
isIndeterminate: boolean;
} {
// Read once: the checkbox state and the stamp answer the same question of the same three values, and must not diverge.
const {selectedTransactions, excludedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext();
const params = {groupKey, children: groupTransactions, selectedTransactions, excludedTransactions, areAllMatchingItemsSelected};
const params = {groupKey, children: groupTransactions, selectedTransactions, excludedTransactions, areAllMatchingItemsSelected, groupCount};

// Stamp the live selection and the parent key onto each row, which is how a row checks whether its group was excluded.
const transactions: TransactionListItemType[] = groupTransactions.map((transactionItem) => ({
Expand Down
66 changes: 49 additions & 17 deletions src/components/Search/SearchSelectionFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {SearchFooterCount, SearchFooterTotal, SelectedTransactionInfo, Sele

import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionContext} from './SearchContext';
import SearchPageFooter from './SearchPageFooter';
import {getRowsCheckedInExcludedGroups, mergeRowsIntoPartlyLoadedGroups} from './selectionBuilders';

type SearchSelectionFooterProps = {
/** The (sorting-aware) results the page is displaying; source of the footer's totals metadata. */
Expand Down Expand Up @@ -157,8 +158,9 @@ function areAllSelectedReportsConverted(selectedReportIDs: string[], isReportFre
// Self-subscribing footer leaf. Owns the `selectedTransactions` read so a checkbox press re-renders only this
// footer — not SearchPage and the <Search> list it contains.
function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProps) {
const {selectedTransactions, excludedTransactions = getEmptyObject<SelectedTransactions>(), areAllMatchingItemsSelected, selectedReports} = useSearchSelectionContext();
const {selectedTransactions: loadedSelection, excludedTransactions = getEmptyObject<SelectedTransactions>(), areAllMatchingItemsSelected, selectedReports} = useSearchSelectionContext();
const {currentSearchResults, currentSearchTransactionsByReportID, shouldUseLiveData} = useSearchResultsContext();
const selectedTransactions = mergeRowsIntoPartlyLoadedGroups(loadedSelection, currentSearchResults?.data, areAllMatchingItemsSelected);
const {currentSearchHash, currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext();
const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, true, areAllMatchingItemsSelected);
const {isOffline} = useNetwork();
Expand Down Expand Up @@ -271,6 +273,12 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp
() => excludedTransactionsKeys.map((key) => excludedTransactions[key]?.transaction?.transactionID).filter((transactionID): transactionID is string => !!transactionID),
[excludedTransactions, excludedTransactionsKeys],
);
// A group's exclusion takes off the rows checked again inside it along with the rest of the group, so the count and the total add them back.
const rowsCheckedInExcludedGroups = getRowsCheckedInExcludedGroups(selectedTransactions, excludedTransactions);
const rowsCheckedInExcludedGroupsCount = Object.keys(rowsCheckedInExcludedGroups).length;
const rowsCheckedInExcludedGroupsTransactionIDs = Object.values(rowsCheckedInExcludedGroups)
.map((transaction) => transaction.transaction?.transactionID)
.filter((transactionID): transactionID is string => !!transactionID);
const selectedReportIDs = useMemo(() => {
if (!isReportsSearch) {
return EMPTY_REPORT_IDS;
Expand Down Expand Up @@ -487,12 +495,25 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp
if (hasExcludedReports) {
return areAllSelectedReportsConverted(excludedReportIDs, (reportID) => isReportFresh(reportID, selectedCurrency));
}
return areAllSelectedEntriesConverted(
excludedTransactions,
(key) => isGroupFresh(key, selectedCurrency),
(transactionID) => isTransactionFresh(transactionID, selectedCurrency),
return [excludedTransactions, rowsCheckedInExcludedGroups].every((entries) =>
areAllSelectedEntriesConverted(
entries,
(key) => isGroupFresh(key, selectedCurrency),
(transactionID) => isTransactionFresh(transactionID, selectedCurrency),
),
);
}, [excludedReportIDs, excludedTransactions, hasCustomFooterCurrency, hasExcludedExpenses, hasExcludedReports, isGroupFresh, isReportFresh, isTransactionFresh, selectedCurrency]);
}, [
excludedReportIDs,
excludedTransactions,
hasCustomFooterCurrency,
hasExcludedExpenses,
hasExcludedReports,
isGroupFresh,
isReportFresh,
isTransactionFresh,
rowsCheckedInExcludedGroups,
selectedCurrency,
]);

// Show the loading skeleton only while a conversion can still arrive — there is something to convert, the request
// can be made (online) and hasn't failed. Otherwise the footer stays on the default-currency total instead of a
Expand Down Expand Up @@ -600,7 +621,9 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp
});
}

const excludedTransactionIDsToConvert = excludedTransactionIDs.filter((transactionID) => !wasTransactionRequested(transactionID, selectedCurrency));
const excludedTransactionIDsToConvert = [...excludedTransactionIDs, ...rowsCheckedInExcludedGroupsTransactionIDs].filter(
(transactionID) => !wasTransactionRequested(transactionID, selectedCurrency),
);
if (excludedTransactionIDsToConvert.length > 0) {
getFooterConvertedAmounts({
queryJSON: currentSearchQueryJSON,
Expand Down Expand Up @@ -643,6 +666,7 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp
wasTransactionRequested,
metadataTotal,
reportSourceByID,
rowsCheckedInExcludedGroupsTransactionIDs,
selectedCurrency,
selectedGroupKeys,
selectedReportIDs,
Expand Down Expand Up @@ -764,7 +788,7 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp

if (hasExcludedGroupUnderBreakdown) {
return {
count: metadataCount === undefined ? undefined : Math.max(metadataCount - excludedExpenseCount, 0),
count: metadataCount === undefined ? undefined : Math.max(metadataCount - excludedExpenseCount + rowsCheckedInExcludedGroupsCount, 0),
total: undefined,
currency: effectiveDefaultCurrency ?? metadataCurrency,
};
Expand All @@ -779,12 +803,8 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp
!hasConversionFailed &&
selectedCurrencyConvertedTotal
) {
let excludedConvertedTotal = 0;
if (hasExcludedReports && selectedCurrency) {
excludedConvertedTotal = excludedReportIDs.reduce((total, reportID) => total - (convertedReports?.[reportID]?.[selectedCurrency] ?? 0), 0);
} else if (hasExcludedExpenses) {
excludedConvertedTotal = excludedKeysForTotal.reduce((total, key) => {
const transaction = excludedTransactions[key];
const getConvertedTotal = (entries: Array<[string, SelectedTransactionInfo]>) =>
entries.reduce((total, [key, transaction]) => {
const transactionID = transaction.transaction?.transactionID;
let convertedAmount;
if (isGroupEntry(key)) {
Expand All @@ -794,17 +814,27 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp
}
return total - (convertedAmount ?? getEntrySource(transaction));
}, 0);
let excludedConvertedTotal = 0;
if (hasExcludedReports && selectedCurrency) {
excludedConvertedTotal = excludedReportIDs.reduce((total, reportID) => total - (convertedReports?.[reportID]?.[selectedCurrency] ?? 0), 0);
} else if (hasExcludedExpenses) {
excludedConvertedTotal =
getConvertedTotal(excludedKeysForTotal.map((key): [string, SelectedTransactionInfo] => [key, excludedTransactions[key]])) -
getConvertedTotal(Object.entries(rowsCheckedInExcludedGroups));
}
return {
count: Math.max(selectedCurrencyConvertedTotal.count - excludedExpenseCount, 0),
count: Math.max(selectedCurrencyConvertedTotal.count - excludedExpenseCount + rowsCheckedInExcludedGroupsCount, 0),
total: selectedCurrencyConvertedTotal.total - excludedConvertedTotal,
currency: selectedCurrency,
};
}

const excludedTotal = hasExcludedExpenses || hasExcludedReports ? getTransactionTotal(excludedKeysForTotal.map((key) => excludedTransactions[key])) : 0;
const excludedTotal =
hasExcludedExpenses || hasExcludedReports
? getTransactionTotal(excludedKeysForTotal.map((key) => excludedTransactions[key])) - getTransactionTotal(Object.values(rowsCheckedInExcludedGroups))
: 0;
return {
count: metadataCount === undefined ? undefined : Math.max(metadataCount - excludedExpenseCount, 0),
count: metadataCount === undefined ? undefined : Math.max(metadataCount - excludedExpenseCount + rowsCheckedInExcludedGroupsCount, 0),
total: metadataTotal === undefined ? undefined : metadataTotal - excludedTotal,
currency: effectiveDefaultCurrency ?? metadataCurrency,
};
Expand All @@ -830,6 +860,8 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp
metadataCount,
metadataCurrency,
metadataTotal,
rowsCheckedInExcludedGroups,
rowsCheckedInExcludedGroupsCount,
selectedCurrency,
selectedCurrencyConvertedTotal,
selectedExpenseCount,
Expand Down
Loading
Loading