diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index 3b9876e6b464..9b922f128b1f 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -636,7 +636,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money return; } - removeTransaction(transaction.transactionID); + removeTransaction(transaction.transactionID, {isDeleted: true}); }; } if (isInNarrowPaneModal) { diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index 9daaf363ded8..7f6874065460 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -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; @@ -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({ diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index 30daf44fcbf5..940cb296d3af 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -109,7 +109,11 @@ function ExpenseReportListItemInner({ * 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(); diff --git a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx index ccc1d4b4e23b..23a058567f12 100644 --- a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx +++ b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx @@ -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'; @@ -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); diff --git a/src/components/Search/SearchList/ListItem/GroupChildrenContent.tsx b/src/components/Search/SearchList/ListItem/GroupChildrenContent.tsx index ffbfec542e8c..675389c5d675 100644 --- a/src/components/Search/SearchList/ListItem/GroupChildrenContent.tsx +++ b/src/components/Search/SearchList/ListItem/GroupChildrenContent.tsx @@ -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'; @@ -47,6 +49,7 @@ function GroupChildrenContent({ const {transactions} = useGroupChildren({ groupKey: groupItem.groupKeyForList, groupTransactions: groupItem.transactions, + groupCount: getSearchGroupCount(groupItem), }); const isEmpty = transactions.length === 0; diff --git a/src/components/Search/SearchList/ListItem/GroupHeader.tsx b/src/components/Search/SearchList/ListItem/GroupHeader.tsx index 5040e99da0dd..8ae19ba97b60 100644 --- a/src/components/Search/SearchList/ListItem/GroupHeader.tsx +++ b/src/components/Search/SearchList/ListItem/GroupHeader.tsx @@ -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'; @@ -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; diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 195d2285e7e8..8c63edbcb9d3 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -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'; @@ -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)); diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index 69bc088aa173..38feb4585ac5 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -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; diff --git a/src/components/Search/SearchList/ListItem/useGroupChildren.ts b/src/components/Search/SearchList/ListItem/useGroupChildren.ts index 01295866ea42..3578551ee802 100644 --- a/src/components/Search/SearchList/ListItem/useGroupChildren.ts +++ b/src/components/Search/SearchList/ListItem/useGroupChildren.ts @@ -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) => ({ diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 90140bc9bf99..cc8498c9308c 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -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. */ @@ -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 list it contains. function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProps) { - const {selectedTransactions, excludedTransactions = getEmptyObject(), areAllMatchingItemsSelected, selectedReports} = useSearchSelectionContext(); + const {selectedTransactions: loadedSelection, excludedTransactions = getEmptyObject(), 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(); @@ -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; @@ -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 @@ -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, @@ -643,6 +666,7 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp wasTransactionRequested, metadataTotal, reportSourceByID, + rowsCheckedInExcludedGroupsTransactionIDs, selectedCurrency, selectedGroupKeys, selectedReportIDs, @@ -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, }; @@ -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)) { @@ -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, }; @@ -830,6 +860,8 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp metadataCount, metadataCurrency, metadataTotal, + rowsCheckedInExcludedGroups, + rowsCheckedInExcludedGroupsCount, selectedCurrency, selectedCurrencyConvertedTotal, selectedExpenseCount, diff --git a/src/components/Search/SearchSelectionProvider.tsx b/src/components/Search/SearchSelectionProvider.tsx index ac54602ea271..214b66589514 100644 --- a/src/components/Search/SearchSelectionProvider.tsx +++ b/src/components/Search/SearchSelectionProvider.tsx @@ -1,3 +1,5 @@ +import {isGroupEntry} from '@libs/SearchUIUtils'; + import CONST from '@src/CONST'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -136,6 +138,7 @@ function createSelectionActions(setSelectionState: React.Dispatch { + const removeTransaction: SearchSelectionActionsValue['removeTransaction'] = (transactionID, {isDeleted}) => { if (!transactionID) { return; } @@ -266,11 +282,14 @@ function createSelectionActions(setSelectionState: React.Dispatch { if (key === transactionID) { return acc; } - acc[key] = value; + acc[key] = partialGroupKey && value.groupKey === partialGroupKey ? {...value, isSelectedViaGroup: false, isEntireGroupSelected: false} : value; return acc; }, {} as SelectedTransactions); newState.selectedTransactions = newSelectedTransactions; diff --git a/src/components/Search/SearchWriteActionsProvider.tsx b/src/components/Search/SearchWriteActionsProvider.tsx index 7c282c7b0605..592b92a4285c 100644 --- a/src/components/Search/SearchWriteActionsProvider.tsx +++ b/src/components/Search/SearchWriteActionsProvider.tsx @@ -9,6 +9,7 @@ import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actio import {canRejectReportAction} from '@libs/ReportUtils'; import { isGroupedItemArray, + isGroupEntry, isReportActionListItemType, isReportEntry, isTaskListItemType, @@ -41,8 +42,12 @@ import {useSyncSelectedReports} from './SearchSelectionProvider'; import { applyShiftRangeBatchToSelection, buildShiftRangeSource, + getCoveredGroupCount, + getGroupClaims, + getRemainingSearchGroupCount, getSearchGroupCount, getSearchGroupCountByKey, + getSelectedRowKeysByGroupKey, isGroupSelected, isRowChecked, mapEmptyReportToSelectedEntry, @@ -127,6 +132,37 @@ type ReconcileSelectionParams = { shouldReconcileExcludedTransactions: boolean; }; +/** The keys of every row the groups have loaded, which places a row by the group it sits in rather than the group its entry still names. */ +function getLoadedGroupRowKeys(data: SearchData): Set { + return new Set(isGroupedItemArray(data) ? data.flatMap((group) => group.transactions.map((transaction) => transaction.keyForList)) : []); +} + +/** The keys of the rows the groups' own searches returned that the list now hides, which have left their group, as an expense submitted under a drafts filter has. */ +function getHiddenGroupRowKeys(data: SearchData): Set { + return new Set(isGroupedItemArray(data) ? data.flatMap((group) => group.hiddenTransactionIDs ?? []) : []); +} + +/** + * The transaction behind a row, the one it was split from, and its parent report, which every selection entry is built from. + * Live Onyx comes first, since the hold and split flags read the optimistic row and the snapshot only refreshes when the search returns. + */ +function resolveTransactionRefs(item: TransactionListItemType, transactions: OnyxCollection, searchResultsData: SearchResults['data'] | undefined) { + const readTransaction = (transactionID: string | undefined): OnyxEntry => { + if (!transactionID) { + return undefined; + } + const key = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; + return isTransactionEntry(key) ? (transactions?.[key] ?? searchResultsData?.[key]) : undefined; + }; + const itemTransaction = readTransaction(item.transactionID); + const parentReportKey = `${ONYXKEYS.COLLECTION.REPORT}${item.report?.parentReportID}`; + return { + itemTransaction, + originalItemTransaction: readTransaction(itemTransaction?.comment?.originalTransactionID), + parentReport: item.report?.parentReportID && isReportEntry(parentReportKey) ? searchResultsData?.[parentReportKey] : undefined, + }; +} + /** * Rebuilds `selectedTransactions` whenever the underlying data changes (e.g. an Onyx push adds rows to a * selected report) so the selection stays in sync with what is on screen, then atomically commits it via @@ -179,6 +215,8 @@ function useReconcileSelectionWithData({ const inferredExcludedTransactions: SelectedTransactions = {}; const liveSelectionEntries = new Map(); const presentGroupKeys = new Set(); + // Neither pass that keeps rows the list does not show keeps one that left its group. + const hiddenRowKeys = getHiddenGroupRowKeys(filteredData); const nonEmptyReportKeys = new Set(); const excludedReportKeys = new Set( isExpenseReportType @@ -188,13 +226,18 @@ function useReconcileSelectionWithData({ : [], ); if (areItemsGrouped) { + // A group's claim is read over the whole selection rather than its loaded rows, since a refresh can bring back rows the claimed ones are not among. + const groupClaims = getGroupClaims(selectedTransactions); + const selectedRowKeysByGroupKey = getSelectedRowKeysByGroupKey(selectedTransactions); + // Read before any group is processed, so a row that moved to a group further down is not kept in, and counted for, the group it left. + const loadedRowKeys = getLoadedGroupRowKeys(filteredData); for (const transactionGroup of filteredData) { if (!Object.hasOwn(transactionGroup, 'transactions') || !('transactions' in transactionGroup)) { continue; } const reportKey = transactionGroup.keyForList; - // Only groups carrying no rows: a row missing from a group that has them is gone for real. + // Only a group with no rows goes in here, since a group that has rows keeps the selected ones a refresh left out itself while it still has rows to load. if (reportKey && !isExpenseReportType && transactionGroup.transactions.length === 0 && transactionGroup.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { presentGroupKeys.add(reportKey); } @@ -231,7 +274,24 @@ function useReconcileSelectionWithData({ const hasIndividualSelectedInGroup = transactionGroup.transactions.some( (transaction) => (!!transaction.keyForList && transaction.keyForList in selectedTransactions) || transaction.transactionID in selectedTransactions, ); - const propagateSelectionToAllRows = (isExpenseReportType && (wasReportSelected || hasIndividualSelectedInGroup)) || (wasReportSelected && !isExpenseReportType); + const getRowSelection = (transaction: TransactionListItemType) => + (transaction.keyForList ? selectedTransactions[transaction.keyForList] : undefined) ?? selectedTransactions[transaction.transactionID]; + const groupClaim = !isExpenseReportType && reportKey && isGroupEntry(reportKey) ? groupClaims.get(reportKey) : undefined; + const groupCount = reportKey ? (getSearchGroupCount(transactionGroup) ?? getSearchGroupCountByKey(searchResultsData, reportKey)) : undefined; + const loadedSelectableCount = transactionGroup.transactions.filter((transaction) => !isTransactionPendingDelete(transaction)).length; + const remainingGroupCount = getRemainingSearchGroupCount(groupCount, transactionGroup.transactions.length, loadedSelectableCount) ?? 0; + const hasUnloadedRows = remainingGroupCount > loadedSelectableCount; + // Once a claim covers every row, a later row or count is a new expense, which stays unchecked and ends the claim. + const hasClaimCoveredGroup = !!groupClaim?.isEntireGroupSelected; + // Kept rows can be stale, so growth is measured from the count the group was covered at, not from its claimed rows. + const priorCoveredGroupCount = reportKey ? getCoveredGroupCount(selectedTransactions, selectedRowKeysByGroupKey.get(reportKey) ?? []) : undefined; + const hasGroupOutgrownClaim = + (priorCoveredGroupCount === undefined ? remainingGroupCount > (groupClaim?.rowCount ?? 0) : (groupCount ?? 0) > priorCoveredGroupCount) || + transactionGroup.transactions.some((transaction) => !isTransactionPendingDelete(transaction) && !getRowSelection(transaction)); + const isClaimEnding = hasClaimCoveredGroup && hasGroupOutgrownClaim; + // A group checked through its header stands for every row in it, loaded or not, so the rows it is written out into and the rows that load later keep that claim. + const isWritingOutGroupSelection = !isExpenseReportType && (wasReportSelected || (!!groupClaim && !hasClaimCoveredGroup)); + const propagateSelectionToAllRows = isExpenseReportType && (wasReportSelected || hasIndividualSelectedInGroup); const isParentGroupExcluded = !!reportKey && ((type === CONST.SEARCH.DATA_TYPES.EXPENSE && Object.hasOwn(excludedTransactions, reportKey)) || @@ -250,20 +310,18 @@ function useReconcileSelectionWithData({ // of an excluded group holds. A report is not a group in that sense: an expense moving into an excluded // report takes its destination's state, which is the same rule the source-report check above states. const isExcluded = (isExpenseReportType || !isSelected) && (isParentGroupExcluded || isDirectlyExcluded); + // A group selection is not written out to a row being deleted, which the header click leaves out as well. + const isReachedByGroupSelection = propagateSelectionToAllRows || (isWritingOutGroupSelection && !isTransactionPendingDelete(transactionItem)); // Include transaction if: already individually selected, part of select-all, or group-level propagation (expense report / empty group expanded) - const shouldInclude = !isExcluded && (isSelected || areAllMatchingItemsSelected || propagateSelectionToAllRows); + const shouldInclude = !isExcluded && (isSelected || areAllMatchingItemsSelected || isReachedByGroupSelection); if (!shouldInclude && !isDirectlyExcluded && !(isExpenseReportType && isParentGroupExcluded)) { continue; } - const itemTransaction = (searchResultsData?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] ?? - transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`]) as OnyxEntry; - const originalItemTransaction = - searchResultsData?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`] ?? - transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - const itemParentReport = searchResultsData?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.report?.parentReportID}`] as OnyxEntry; + const {itemTransaction, originalItemTransaction, parentReport: itemParentReport} = resolveTransactionRefs(transactionItem, transactions, searchResultsData); const previousSelection = selectedTransactions[listKey] ?? selectedTransactions[transactionItem.transactionID]; + const hasMovedGroup = !isExpenseReportType && !!reportKey && !!previousSelection?.groupKey && previousSelection.groupKey !== reportKey; const [, baseEntry] = mapTransactionItemToSelectedEntry({ item: transactionItem, @@ -281,12 +339,13 @@ function useReconcileSelectionWithData({ const liveSelectionEntry: SelectedTransactionInfo = { ...baseEntry, - isSelected: !isExcluded && (areAllMatchingItemsSelected || !!previousSelection?.isSelected || propagateSelectionToAllRows), + isSelected: !isExcluded && (areAllMatchingItemsSelected || !!previousSelection?.isSelected || isReachedByGroupSelection), canReject: transactionItem.report ? canRejectReportAction(transactionItem.report, currentUserAccountID, transactionItem.policy) : false, policyID: transactionItem.report?.policyID, - groupKey: - previousSelection?.groupKey ?? ((propagateSelectionToAllRows && !isExpenseReportType) || (isExpenseReportType && isParentGroupExcluded) ? reportKey : undefined), - isSelectedViaGroup: previousSelection?.isSelectedViaGroup, + groupKey: hasMovedGroup + ? reportKey + : (previousSelection?.groupKey ?? (isWritingOutGroupSelection || (isExpenseReportType && isParentGroupExcluded) ? reportKey : undefined)), + isSelectedViaGroup: isClaimEnding || hasMovedGroup ? false : (previousSelection?.isSelectedViaGroup ?? (isWritingOutGroupSelection ? true : undefined)), }; liveSelectionEntries.set(listKey, liveSelectionEntry); liveSelectionEntries.set(transactionItem.transactionID, liveSelectionEntry); @@ -298,6 +357,16 @@ function useReconcileSelectionWithData({ } } + // A refresh can bring back only a group's first page, so the selected rows it left out stay selected while the group has rows still to load. + if (reportKey && !isExpenseReportType && hasUnloadedRows) { + for (const key of selectedRowKeysByGroupKey.get(reportKey) ?? []) { + const selection = selectedTransactions[key]; + if (selection && !loadedRowKeys.has(key) && !hiddenRowKeys.has(key)) { + newTransactionList[key] = {...selection, isKeptOffPage: true, ...(isClaimEnding ? {isSelectedViaGroup: false} : {})}; + } + } + } + // Copying `isEntireGroupSelected` would leave delete thinking the group is still fully covered // after a new child lands in it (the create path bumps `count` without touching the selection). if (reportKey) { @@ -313,9 +382,9 @@ function useReconcileSelectionWithData({ const stampedSelection = stampGroupCoverageFlags({ selectedTransactions: newTransactionList, groupKey: reportKey, - groupCount: getSearchGroupCount(transactionGroup) ?? getSearchGroupCountByKey(searchResultsData, reportKey), - loadedChildrenCount: transactionGroup.transactions.length, - loadedSelectableCount: transactionGroup.transactions.filter((transaction) => !isTransactionPendingDelete(transaction)).length, + groupCount, + loadedRows: transactionGroup.transactions, + priorCoveredGroupCount, }); for (const [key, entry] of Object.entries(stampedSelection)) { newTransactionList[key] = entry; @@ -333,6 +402,11 @@ function useReconcileSelectionWithData({ liveSelectionEntries.set(transactionItem.transactionID, nextEntry); } } + + // A group selected under its own key keeps that entry while none of its loaded rows can take the selection and some of its rows have not loaded. + if (reportKey && wasReportSelected && !isExpenseReportType && loadedSelectableCount === 0 && hasUnloadedRows) { + newTransactionList[reportKey] = selectedTransactions[reportKey]; + } } } else { for (const transactionItem of filteredData) { @@ -345,9 +419,7 @@ function useReconcileSelectionWithData({ continue; } - const itemTransaction = searchResultsData?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; - const originalItemTransaction = searchResultsData?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - const itemParentReport = searchResultsData?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.report?.parentReportID}`] as OnyxEntry; + const {itemTransaction, originalItemTransaction, parentReport: itemParentReport} = resolveTransactionRefs(transactionItem, transactions, searchResultsData); const flatPreviousSelection = selectedTransactions[listKey] ?? selectedTransactions[transactionItem.transactionID]; const [, baseEntry] = mapTransactionItemToSelectedEntry({ @@ -387,7 +459,8 @@ function useReconcileSelectionWithData({ Object.hasOwn(newTransactionList, key) || Object.hasOwn(excludedTransactions, key) || Object.hasOwn(excludedTransactions, parentGroupKey) || - !presentGroupKeys.has(parentGroupKey) + !presentGroupKeys.has(parentGroupKey) || + hiddenRowKeys.has(key) ) { continue; } @@ -412,7 +485,7 @@ function useReconcileSelectionWithData({ } nextExcludedTransactions[key] = { ...liveEntry, - groupKey: excludedTransaction.groupKey, + groupKey: isExpenseReportType ? excludedTransaction.groupKey : (liveEntry.groupKey ?? excludedTransaction.groupKey), isSelectedViaGroup: excludedTransaction.isSelectedViaGroup, isEntireGroupSelected: liveEntry.isEntireGroupSelected, }; @@ -550,28 +623,8 @@ function SearchWriteActionsProvider({ const currentUserLogin = login ?? ''; const shouldPreserveAllMatchingSelection = type === CONST.SEARCH.DATA_TYPES.EXPENSE || isExpenseReportType; - // Live Onyx first: the hold and split flags read the optimistic row, and the snapshot only refreshes when the search returns. - const readTransaction = (transactionID: string | undefined): OnyxEntry => { - if (!transactionID) { - return undefined; - } - const key = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; - return isTransactionEntry(key) ? (transactions?.[key] ?? searchResultsData?.[key]) : undefined; - }; - - const resolveTransactionRefs = (item: TransactionListItemType) => { - const itemTransaction = readTransaction(item.transactionID); - const parentReportID = item.report?.parentReportID; - const parentReportKey = `${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`; - return { - itemTransaction, - originalItemTransaction: readTransaction(itemTransaction?.comment?.originalTransactionID), - parentReport: parentReportID && isReportEntry(parentReportKey) ? searchResultsData?.[parentReportKey] : undefined, - }; - }; - const buildSelectedEntry = (item: TransactionListItemType) => { - const {itemTransaction, originalItemTransaction, parentReport} = resolveTransactionRefs(item); + const {itemTransaction, originalItemTransaction, parentReport} = resolveTransactionRefs(item, transactions, searchResultsData); return mapTransactionItemToSelectedEntry({ item, itemTransaction, @@ -603,9 +656,8 @@ function SearchWriteActionsProvider({ const isShiftRangeHeaderItem = (item: SearchData[number]) => isTransactionGroupListItemType(item) && hasValidGroupBy; - const getGroupCount = (groupKey: string) => - getSearchGroupCount(isGroupedItemArray(filteredData) ? filteredData.find((group) => group.keyForList === groupKey) : undefined) ?? - getSearchGroupCountByKey(searchResultsData, groupKey); + const groupCountByKey = new Map(isGroupedItemArray(filteredData) ? filteredData.map((group) => [group.keyForList, getSearchGroupCount(group)]) : []); + const getGroupCount = (groupKey: string) => groupCountByKey.get(groupKey) ?? getSearchGroupCountByKey(searchResultsData, groupKey); // Read when the click happens, like the refs they come from, and passed to the writers, which cannot read Onyx or the rows themselves. const readGroupLookups = () => ({ @@ -628,6 +680,7 @@ function SearchWriteActionsProvider({ selectedTransactions, excludedTransactions, areAllMatchingItemsSelected, + groupCount: groupKey ? getGroupCount(groupKey) : undefined, }); const applyShiftRangeBatch = (batch: ShiftRangeBatch) => { @@ -725,7 +778,7 @@ function SearchWriteActionsProvider({ } const lookups = readGroupLookups(); applySelection((selectedTransactions, {areAllMatchingItemsSelected}) => { - const {itemTransaction, originalItemTransaction, parentReport: itemParentReport} = resolveTransactionRefs(item); + const {itemTransaction, originalItemTransaction, parentReport: itemParentReport} = resolveTransactionRefs(item, transactions, searchResultsData); const baseSelection = spellOutGroupSelection(selectedTransactions, item.keyForList, areAllMatchingItemsSelected, lookups); const updatedTransactions = prepareTransactionsList({ item, @@ -773,8 +826,7 @@ function SearchWriteActionsProvider({ selectedTransactions: updatedTransactions, groupKey, groupCount: getSearchGroupCount(parentGroup) ?? getSearchGroupCountByKey(searchResultsData, groupKey), - loadedChildrenCount: loadedChildren.length, - loadedSelectableCount: loadedChildren.filter((transaction) => !isTransactionPendingDelete(transaction)).length, + loadedRows: loadedChildren, }); } @@ -783,6 +835,21 @@ function SearchWriteActionsProvider({ return; } + // Under select-all-matching, unchecking a group that has not loaded every row excludes the group itself, since excluding only its loaded rows would leave the rest selected. + const groupCount = getGroupCount(item.keyForList); + const isUncheckingPartlyLoadedGroup = + type === CONST.SEARCH.DATA_TYPES.EXPENSE && + getAreAllMatchingItemsSelected() && + groupCount !== undefined && + groupTransactions.length > 0 && + groupTransactions.length < groupCount && + isGroupSelected(groupSelectionParams(item.keyForList, groupTransactions)); + const deselectedWithoutEntry: SelectedTransactions = {}; + if (isUncheckingPartlyLoadedGroup) { + const [groupKey, groupEntry] = mapEmptyReportToSelectedEntry(item); + deselectedWithoutEntry[groupKey] = groupEntry; + } + applySelection( (selectedTransactions, {excludedTransactions, areAllMatchingItemsSelected}) => { if (groupTransactions.length === 0 && item.keyForList) { @@ -805,14 +872,16 @@ function SearchWriteActionsProvider({ // A group selected before its children were fetched is stored under the group key. Once the children load, // deselecting has to clear that entry too, otherwise the group stays selected with no way to deselect it. const groupKey = item.keyForList; + // A refresh can leave some of a group's selected rows unloaded, and they stay selected while the group has rows to load, so whatever clears the group clears every row it holds. + const getSelectedGroupRowKeys = () => (groupKey ? (getSelectedRowKeysByGroupKey(selectedTransactions).get(groupKey) ?? []) : []); if (isGroupSelected(groupSelectionParams(groupKey, groupTransactions, selectedTransactions, excludedTransactions, areAllMatchingItemsSelected))) { const reducedSelectedTransactions: SelectedTransactions = {...selectedTransactions}; if (groupKey) { delete reducedSelectedTransactions[groupKey]; } - for (const transaction of groupTransactions) { - delete reducedSelectedTransactions[transaction.keyForList]; + for (const key of [...groupTransactions.map((transaction) => transaction.keyForList), ...getSelectedGroupRowKeys()]) { + delete reducedSelectedTransactions[key]; } return reducedSelectedTransactions; } @@ -821,7 +890,7 @@ function SearchWriteActionsProvider({ if (selectableTransactions.length === 0) { // Nothing under it can be checked, so the press clears whatever it still holds. Leaving any of it counts rows the checkbox does not show, and no checkbox is left to uncheck them. const staleKeys: string[] = []; - for (const key of [groupKey, ...groupTransactions.map((transactionItem) => transactionItem.keyForList)]) { + for (const key of [groupKey, ...groupTransactions.map((transactionItem) => transactionItem.keyForList), ...getSelectedGroupRowKeys()]) { if (key && selectedTransactions[key]) { staleKeys.push(key); } @@ -848,12 +917,11 @@ function SearchWriteActionsProvider({ }, groupKey, groupCount: getSearchGroupCount(item) ?? getSearchGroupCountByKey(searchResultsData, groupKey), - loadedChildrenCount: groupTransactions.length, - loadedSelectableCount: selectableTransactions.length, + loadedRows: groupTransactions, }); // A report row's own selection is derived from `selectedReports`, so this one commit has to re-derive it. }, - {...commitOptions, ...(isExpenseReportType ? {data: filteredData} : {})}, + {...commitOptions, ...(isExpenseReportType ? {data: filteredData} : {}), deselectedWithoutEntry}, ); }; @@ -890,8 +958,7 @@ function SearchWriteActionsProvider({ selectedTransactions: Object.fromEntries(entries), groupKey: item.keyForList, groupCount: getSearchGroupCount(item) ?? getSearchGroupCountByKey(searchResultsData, item.keyForList), - loadedChildrenCount: item.transactions.length, - loadedSelectableCount: selectableTransactions.length, + loadedRows: item.transactions, }), ); }); @@ -911,7 +978,8 @@ function SearchWriteActionsProvider({ } return Object.fromEntries(entries); }, - {data: filteredData, totalSelectableItemsCount}, + // Selecting writes like every other path, so the rows it checks stop being excluded. Clearing turns select-all-matching off instead. + isClearing ? {data: filteredData, totalSelectableItemsCount} : {...commitOptions, data: filteredData}, ); }; diff --git a/src/components/Search/hooks/useSearchSnapshot.ts b/src/components/Search/hooks/useSearchSnapshot.ts index e889874c1b1d..2a6c0490add6 100644 --- a/src/components/Search/hooks/useSearchSnapshot.ts +++ b/src/components/Search/hooks/useSearchSnapshot.ts @@ -13,7 +13,7 @@ import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useReportAttributes from '@hooks/useReportAttributes'; -import {getSections, getSortedSections, getSortedTransactionData, getValidGroupBy, isSearchDataLoaded, isTransactionGroupListItemType} from '@libs/SearchUIUtils'; +import {getSections, getSortedSections, getSortedTransactionData, getValidGroupBy, isSearchDataLoaded, isTransactionEntry, isTransactionGroupListItemType} from '@libs/SearchUIUtils'; import {shouldShowAttendees} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; @@ -314,9 +314,15 @@ function useSearchSnapshot({queryJSON, searchResults, transactions, reportAction }); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- group children are flat transactions const typedGroupTransactions = groupTransactions as TransactionListItemType[]; + const shownTransactionIDs = new Set(typedGroupTransactions.map((transaction) => transaction.transactionID)); + const hiddenTransactionIDs = Object.keys(subSnapshot.data) + .filter(isTransactionEntry) + .map((key) => subSnapshot.data[key]?.transactionID) + .filter((transactionID): transactionID is string => !!transactionID && !shownTransactionIDs.has(transactionID)); return { ...item, transactions: getSortedTransactionData(typedGroupTransactions, localeCompare, translate, CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.SORT_ORDER.DESC), + hiddenTransactionIDs, }; }); }, [ diff --git a/src/components/Search/selectionBuilders.ts b/src/components/Search/selectionBuilders.ts index 2438c60de9df..b3bf92405b3e 100644 --- a/src/components/Search/selectionBuilders.ts +++ b/src/components/Search/selectionBuilders.ts @@ -1,6 +1,7 @@ import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; import {canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canRejectReportAction, getReimbursableTotal, isMoneyRequestReport, isOneTransactionReport} from '@libs/ReportUtils'; import {isGroupedItemArray, isGroupEntry, isTransactionGroupListItemType, isTransactionListItemType, isTransactionReportGroupListItemType} from '@libs/SearchUIUtils'; +import type {SearchGroupKey} from '@libs/SearchUIUtils'; import type {ShiftRangeBatch} from '@libs/shiftRangeSelection'; import {getOriginalTransactionWithSplitInfo, hasValidModifiedAmount, isExpenseUnreported, isOnHold, isTransactionPendingDelete} from '@libs/TransactionUtils'; @@ -9,6 +10,7 @@ import type {OutstandingReportsByPolicyIDDerivedValue, Report, ReportNameValuePa import type {SearchGroupBase, SearchResultDataType} from '@src/types/onyx/SearchResults'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import {deepEqual} from 'fast-equals'; @@ -47,55 +49,72 @@ function getRemainingSearchGroupCount(groupCount: number | undefined, loadedChil return Math.max(groupCount - pendingDeleteLoadedCount, 0); } -/** - * A group is fully selected only when every remaining transaction it contains is selected. If the group count - * is unknown (expense-report rows), the loaded selectable children are treated as the whole group. - */ -function isSelectionCoveringEntireGroup(groupCount: number | undefined, selectedCount: number, loadedSelectableCount: number): boolean { - if (selectedCount <= 0) { - return false; - } - if (groupCount === undefined) { - return loadedSelectableCount > 0 && selectedCount === loadedSelectableCount; +function areLoadedRowsSelected(selectedTransactions: SelectedTransactions, loadedRows: TransactionListItemType[]): boolean { + return loadedRows.every((row) => isTransactionPendingDelete(row) || !!selectedTransactions[row.keyForList ?? row.transactionID]); +} + +/** The count recorded on a group's rows when its selection last covered it, or undefined when it does not. */ +function getCoveredGroupCount(selectedTransactions: SelectedTransactions, rowKeys: string[]): number | undefined { + for (const key of rowKeys) { + const entry = selectedTransactions[key]; + if (entry?.isEntireGroupSelected && entry.coveredGroupCount !== undefined) { + return entry.coveredGroupCount; + } } - return selectedCount === groupCount; + return undefined; } type StampGroupCoverageFlagsParams = { selectedTransactions: SelectedTransactions; groupKey: string | undefined; groupCount: number | undefined; - loadedChildrenCount: number; - loadedSelectableCount: number; + + /** The group's rows as loaded, including any being deleted */ + loadedRows: TransactionListItemType[]; + + /** The count the group was covered at before this change, which lets a partly loaded group keep its coverage while rows only leave it */ + priorCoveredGroupCount?: number; }; /** - * Sets `isEntireGroupSelected` from whether the selection covers the group's remaining transaction count. - * A `limit:` that leaves children unloaded must not look like a whole-group selection, because delete only - * removes the loaded rows. `isSelectedViaGroup` is left alone so export can still treat a group-row click as a - * group export. + * The count a group is wholly selected at, or undefined. Kept rows can be stale, so only a fully loaded group with every row checked earns it, and a partly loaded one + * keeps it only while every loaded row stays checked and its count has not risen. With no rows loaded, the selected rows are counted instead. + * The count is the server's, rows being deleted included, since it only drops once the search runs again. */ -function stampGroupCoverageFlags({selectedTransactions, groupKey, groupCount, loadedChildrenCount, loadedSelectableCount}: StampGroupCoverageFlagsParams): SelectedTransactions { +function getWholeGroupCoverage({selectedTransactions, groupKey, groupCount, loadedRows, priorCoveredGroupCount}: StampGroupCoverageFlagsParams): number | undefined { + if (loadedRows.length === 0) { + const selectedRowCount = Object.entries(selectedTransactions).filter(([key, transaction]) => key !== groupKey && transaction.groupKey === groupKey).length; + return selectedRowCount > 0 && selectedRowCount === groupCount ? groupCount : undefined; + } + const loadedSelectableCount = loadedRows.filter((row) => !isTransactionPendingDelete(row)).length; + if (loadedSelectableCount === 0 || !areLoadedRowsSelected(selectedTransactions, loadedRows)) { + return undefined; + } + const remainingGroupCount = getRemainingSearchGroupCount(groupCount, loadedRows.length, loadedSelectableCount); + if (remainingGroupCount === undefined || loadedSelectableCount >= remainingGroupCount) { + return loadedRows.length; + } + return priorCoveredGroupCount !== undefined && groupCount !== undefined && groupCount <= priorCoveredGroupCount ? groupCount : undefined; +} + +/** + * Sets `isEntireGroupSelected`, with the count it holds at, on every row of the group. + * A `limit:` that leaves children unloaded must not make a group look whole, so a partly loaded group is whole only while it keeps coverage it had. + * `isSelectedViaGroup` is left alone so export can still treat a group-row click as a group export. + */ +function stampGroupCoverageFlags({selectedTransactions, groupKey, groupCount, loadedRows, priorCoveredGroupCount}: StampGroupCoverageFlagsParams): SelectedTransactions { if (!groupKey) { return selectedTransactions; } + const coveredGroupCount = getWholeGroupCoverage({selectedTransactions, groupKey, groupCount, loadedRows, priorCoveredGroupCount}); + const isEntireGroupSelected = coveredGroupCount !== undefined; const nextSelectedTransactions = {...selectedTransactions}; - let selectedCount = 0; - for (const [key, transaction] of Object.entries(nextSelectedTransactions)) { - if (key === groupKey || transaction.groupKey !== groupKey) { - continue; - } - selectedCount += 1; - } - - const remainingGroupCount = getRemainingSearchGroupCount(groupCount, loadedChildrenCount, loadedSelectableCount); - const isEntireGroupSelected = isSelectionCoveringEntireGroup(remainingGroupCount, selectedCount, loadedSelectableCount); for (const [key, transaction] of Object.entries(nextSelectedTransactions)) { if (key !== groupKey && transaction.groupKey !== groupKey) { continue; } - nextSelectedTransactions[key] = {...transaction, groupKey, isEntireGroupSelected}; + nextSelectedTransactions[key] = {...transaction, groupKey, isEntireGroupSelected, coveredGroupCount}; } return nextSelectedTransactions; @@ -202,55 +221,182 @@ function mapTransactionItemToSelectedEntry({ function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType | TransactionGroupListItemType): [string, SelectedTransactionInfo] { if (isTransactionReportGroupListItemType(item)) { - const currency = item.currency ?? ''; return [ item.keyForList ?? '', - { - isFromOneTransactionReport: false, - isSelected: true, - canHold: false, - canSplit: false, - canReject: false, - hasBeenSplit: false, - isHeld: false, - canUnhold: false, - canChangeReport: false, - action: item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW, - reportID: item.reportID, - policyID: item.policyID ?? CONST.POLICY.ID_FAKE, + buildGroupSelectedEntry({ amount: item.totalDisplaySpend ?? item.total ?? 0, displayAmount: item.totalDisplaySpend ?? 0, - currency, - ...(currency ? {groupCurrency: currency} : {}), - }, + currency: item.currency ?? '', + action: item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW, + reportID: item.reportID, + policyID: item.policyID, + }), ]; } - const currency = item.currency ?? ''; - + const total = item.total ?? 0; return [ item.keyForList ?? '', - { - isFromOneTransactionReport: false, - isSelected: true, - canHold: false, - canSplit: false, - canReject: false, - hasBeenSplit: false, - isHeld: false, - canUnhold: false, - canChangeReport: false, + buildGroupSelectedEntry({ + amount: total, + displayAmount: total, + currency: item.currency ?? '', action: CONST.SEARCH.ACTION_TYPES.VIEW, reportID: item.reportID, - policyID: item.policyID ?? CONST.POLICY.ID_FAKE, - amount: item.total ?? 0, - displayAmount: item.total ?? 0, - currency, - ...(currency ? {groupCurrency: currency} : {}), - }, + policyID: item.policyID, + }), ]; } +type BuildGroupSelectedEntryParams = { + /** The group's total, which the selection's total adds up */ + amount: number; + + /** The group's total as the footer shows it */ + displayAmount: number; + + currency: string; + + action: ValueOf; + + /** Set only for a report row, since a group of expenses is not a report */ + reportID: string | undefined; + + /** Set only for a report row, and a placeholder policy stands in otherwise */ + policyID: string | undefined; +}; + +/** The entry of a group selected under its own key, which stands for every row in it. */ +function buildGroupSelectedEntry({amount, displayAmount, currency, action, reportID, policyID}: BuildGroupSelectedEntryParams): SelectedTransactionInfo { + return { + isFromOneTransactionReport: false, + isSelected: true, + canHold: false, + canSplit: false, + canReject: false, + hasBeenSplit: false, + isHeld: false, + canUnhold: false, + canChangeReport: false, + action, + reportID, + policyID: policyID ?? CONST.POLICY.ID_FAKE, + amount, + displayAmount, + currency, + ...(currency ? {groupCurrency: currency} : {}), + }; +} + +/** The group a row checked through its group header stands for, or undefined for a row checked on its own. */ +function getClaimedGroupKey(transaction: SelectedTransactionInfo): SearchGroupKey | undefined { + return transaction.isSelectedViaGroup && transaction.groupKey && isGroupEntry(transaction.groupKey) ? transaction.groupKey : undefined; +} + +/** + * Rows checked again inside a group excluded whole from Select all. The group's exclusion still takes them off with the rest of the group, + * so a count or a total adds them back. + */ +function getRowsCheckedInExcludedGroups(selectedTransactions: SelectedTransactions, excludedTransactions: SelectedTransactions): SelectedTransactions { + const rows: SelectedTransactions = {}; + for (const [key, transaction] of Object.entries(selectedTransactions)) { + if (!isGroupEntry(key) && transaction.isSelected && !!transaction.groupKey && isGroupEntry(transaction.groupKey) && Object.hasOwn(excludedTransactions, transaction.groupKey)) { + rows[key] = transaction; + } + } + return rows; +} + +/** For each group checked through its header, how many selected rows stand for it and whether any of them records the group as wholly selected. */ +function getGroupClaims(selectedTransactions: SelectedTransactions): Map { + const claimByGroupKey = new Map(); + for (const transaction of Object.values(selectedTransactions)) { + const groupKey = getClaimedGroupKey(transaction); + if (!groupKey) { + continue; + } + const claim = claimByGroupKey.get(groupKey) ?? {rowCount: 0, isEntireGroupSelected: false}; + claim.rowCount += 1; + claim.isEntireGroupSelected = claim.isEntireGroupSelected || !!transaction.isEntireGroupSelected; + claimByGroupKey.set(groupKey, claim); + } + return claimByGroupKey; +} + +/** The keys of the selected rows in each group, whether the group's rows are loaded or not. */ +function getSelectedRowKeysByGroupKey(selectedTransactions: SelectedTransactions): Map { + const rowKeysByGroupKey = new Map(); + for (const [key, transaction] of Object.entries(selectedTransactions)) { + if (!transaction.groupKey || isGroupEntry(key)) { + continue; + } + const rowKeys = rowKeysByGroupKey.get(transaction.groupKey); + if (rowKeys) { + rowKeys.push(key); + } else { + rowKeysByGroupKey.set(transaction.groupKey, [key]); + } + } + return rowKeysByGroupKey; +} + +/** + * A header check stands for every row of the group, as an export reads it, so until the stamp finds the group wholly selected, one entry for the whole group replaces its rows. + * So does a wholly selected group whose rows outnumber its count, since some left it unseen. Under Select all every action sends the query, so nothing is merged. + */ +function mergeRowsIntoPartlyLoadedGroups( + selectedTransactions: SelectedTransactions, + searchData: SearchResultDataType | undefined, + areAllMatchingItemsSelected: boolean, +): SelectedTransactions { + if (areAllMatchingItemsSelected) { + return selectedTransactions; + } + const partlyLoadedGroups = new Map(); + for (const [groupKey, rowKeys] of getSelectedRowKeysByGroupKey(selectedTransactions)) { + if (!isGroupEntry(groupKey)) { + continue; + } + const group = searchData?.[groupKey]; + const groupCount = getSearchGroupCount(group); + if (!group || groupCount === undefined) { + continue; + } + const claimedRowCount = rowKeys.filter((key) => getClaimedGroupKey(selectedTransactions[key]) === groupKey).length; + const isWhollySelected = rowKeys.some((key) => !!selectedTransactions[key].isEntireGroupSelected); + const isStamped = rowKeys.some((key) => selectedTransactions[key].isEntireGroupSelected !== undefined); + const isClaimStillLoading = claimedRowCount > 0 && !isWhollySelected && (isStamped || claimedRowCount < groupCount); + const hasKeptRow = rowKeys.some((key) => !!selectedTransactions[key].isKeptOffPage); + const hasRowsThatLeft = (claimedRowCount > 0 || isWhollySelected) && hasKeptRow && rowKeys.length > groupCount; + if (isClaimStillLoading || hasRowsThatLeft) { + partlyLoadedGroups.set(groupKey, group); + } + } + if (partlyLoadedGroups.size === 0) { + return selectedTransactions; + } + + // The group's entry stands for every row in it, so a row of the group checked one by one would otherwise be counted a second time. + const merged: SelectedTransactions = {}; + for (const [key, transaction] of Object.entries(selectedTransactions)) { + const groupKey = transaction.groupKey; + if (!groupKey || !isGroupEntry(groupKey) || !partlyLoadedGroups.has(groupKey)) { + merged[key] = transaction; + } + } + for (const [groupKey, group] of partlyLoadedGroups) { + merged[groupKey] = buildGroupSelectedEntry({ + amount: group.total, + displayAmount: group.total, + currency: group.currency, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + reportID: undefined, + policyID: undefined, + }); + } + return merged; +} + type PrepareTransactionsListParams = { /** The transaction row being toggled in the selection */ item: TransactionListItemType; @@ -418,6 +564,9 @@ type GroupSelectionParams = { /** Whether every matching item is selected, which checks rows that have no entry of their own */ areAllMatchingItemsSelected: boolean; + + /** How many rows the group holds, loaded or not, or undefined where the group carries no count */ + groupCount: number | undefined; }; /** Whether clicking a group's checkbox means "deselect": true once any row under it reads as checked. */ @@ -427,7 +576,7 @@ function isGroupSelected(params: GroupSelectionParams): boolean { } /** What a group's checkbox shows: fully checked, and whether only some of its rows are. Rows being deleted count for neither. */ -function getGroupCheckboxState({groupKey, children, selectedTransactions, excludedTransactions, areAllMatchingItemsSelected}: GroupSelectionParams): { +function getGroupCheckboxState({groupKey, children, selectedTransactions, excludedTransactions, areAllMatchingItemsSelected, groupCount}: GroupSelectionParams): { isSelectAllChecked: boolean; isIndeterminate: boolean; } { @@ -442,14 +591,35 @@ function getGroupCheckboxState({groupKey, children, selectedTransactions, exclud checkedCount++; } } + // The group's own key answers for the rows it has not loaded, which is every row while none are. + const areUnloadedRowsChecked = !!groupKey && isRowChecked({rowKey: groupKey, parentGroupKey: undefined, selectedTransactions, excludedTransactions, areAllMatchingItemsSelected}); // A group carrying no rows answers from its own key. One whose rows are all being deleted has rows, so it does not. if (children.length === 0) { - return { - isSelectAllChecked: !!groupKey && isRowChecked({rowKey: groupKey, parentGroupKey: undefined, selectedTransactions, excludedTransactions, areAllMatchingItemsSelected}), - isIndeterminate: false, - }; + return {isSelectAllChecked: areUnloadedRowsChecked, isIndeterminate: false}; } - return {isSelectAllChecked: selectableCount > 0 && checkedCount === selectableCount, isIndeterminate: checkedCount > 0 && checkedCount !== selectableCount}; + const hasUnloadedRows = groupCount !== undefined && groupCount > children.length; + const hasCheckedUnloadedRows = hasUnloadedRows && areUnloadedRowsChecked; + // The rows not loaded are covered by the group's own key, Select all, a header check, or rows checked one by one that the stamp found to be every row of the group. + const isGroupCoveredByLoadedRows = () => + !!groupKey && + children.some((child) => { + const entry = selectedTransactions[child.keyForList]; + return !!entry && (getClaimedGroupKey(entry) === groupKey || (entry.groupKey === groupKey && !!entry.isEntireGroupSelected)); + }); + // A refresh can leave rows checked or unchecked one by one off the loaded page, and the header still has to show them. + let loadedRowKeys: Set | undefined; + const isRowLeftOut = (key: string, entry: SelectedTransactionInfo) => { + if (!groupKey || key === groupKey || entry.groupKey !== groupKey) { + return false; + } + loadedRowKeys ??= new Set(children.map((child) => child.keyForList)); + return !loadedRowKeys.has(key); + }; + const hasExcludedRowLeftOut = () => Object.entries(excludedTransactions).some(([key, entry]) => isRowLeftOut(key, entry)); + const hasCheckedRowLeftOut = () => Object.entries(selectedTransactions).some(([key, entry]) => !!entry.isSelected && isRowLeftOut(key, entry)); + const areUnloadedRowsCovered = () => !hasUnloadedRows || ((areUnloadedRowsChecked || isGroupCoveredByLoadedRows()) && !hasExcludedRowLeftOut()); + const isSelectAllChecked = selectableCount > 0 && checkedCount === selectableCount && areUnloadedRowsCovered(); + return {isSelectAllChecked, isIndeterminate: !isSelectAllChecked && (checkedCount > 0 || hasCheckedUnloadedRows || (hasUnloadedRows && hasCheckedRowLeftOut()))}; } type RowCheckedParams = { @@ -615,13 +785,14 @@ function applyShiftRangeBatchToSelection( } const entry = parentGroupKey ? {...info, groupKey: parentGroupKey, isSelectedViaGroup: !!blockGroupKey || isAlreadySelectedViaGroup} : info; // Re-covering a row is not a write, and coverage is recounted after the batch, so it is left out of the comparison. - if (deepEqual({...updated[key], isEntireGroupSelected: undefined}, {...entry, isEntireGroupSelected: undefined})) { + if (deepEqual({...updated[key], isEntireGroupSelected: undefined, coveredGroupCount: undefined}, {...entry, isEntireGroupSelected: undefined, coveredGroupCount: undefined})) { return; } updated[key] = entry; hasWritten = true; }; + let selectedRowKeysByGroupKey: Map | undefined; const removeRow = (row: SearchListItem) => { if (isTransactionListItemType(row) || (isTransactionReportGroupListItemType(row) && row.transactions.length === 0)) { if (row.keyForList) { @@ -636,9 +807,13 @@ function applyShiftRangeBatchToSelection( return; } if (isTransactionGroupListItemType(row)) { - // A group can hold an entry under its own key as well as under its children's. + // A group can hold an entry under its own key as well as under its children's, including children a refresh left off the loaded page. if (row.keyForList) { dropKey(row.keyForList); + selectedRowKeysByGroupKey ??= getSelectedRowKeysByGroupKey(selection); + for (const key of selectedRowKeysByGroupKey.get(row.keyForList) ?? []) { + dropKey(key); + } } for (const child of row.transactions ?? []) { if (child.keyForList) { @@ -694,29 +869,25 @@ function applyShiftRangeBatchToSelection( } } - // Delete takes a whole group on this flag, so each group the range wrote under is recounted, in one pass over the selection rather than one per group. - const selectedCountByGroupKey = new Map(); - for (const [key, transaction] of Object.entries(updated)) { - if (key === transaction.groupKey || !transaction.groupKey || !touchedGroups.has(transaction.groupKey)) { - continue; - } - selectedCountByGroupKey.set(transaction.groupKey, (selectedCountByGroupKey.get(transaction.groupKey) ?? 0) + 1); - } - - const coverageByGroupKey = new Map(); + // Delete takes a whole group on this flag, so each group the range wrote under is recounted, keeping the coverage it had while its rows only left it. + const coverageByGroupKey = new Map(); for (const [groupKey, loadedRows] of touchedGroups) { - const loadedSelectableCount = loadedRows.filter((child) => !isTransactionPendingDelete(child)).length; - const remainingGroupCount = getRemainingSearchGroupCount(lookups.getGroupCount(groupKey), loadedRows.length, loadedSelectableCount); - coverageByGroupKey.set(groupKey, isSelectionCoveringEntireGroup(remainingGroupCount, selectedCountByGroupKey.get(groupKey) ?? 0, loadedSelectableCount)); + selectedRowKeysByGroupKey ??= getSelectedRowKeysByGroupKey(selection); + const priorCoveredGroupCount = getCoveredGroupCount(selection, selectedRowKeysByGroupKey.get(groupKey) ?? []); + coverageByGroupKey.set(groupKey, getWholeGroupCoverage({selectedTransactions: updated, groupKey, groupCount: lookups.getGroupCount(groupKey), loadedRows, priorCoveredGroupCount})); } for (const [key, transaction] of Object.entries(updated)) { const groupKey = touchedGroups.has(key) ? key : transaction.groupKey; - const isEntireGroupSelected = groupKey ? coverageByGroupKey.get(groupKey) : undefined; - if (isEntireGroupSelected === undefined || !groupKey || (transaction.groupKey === groupKey && transaction.isEntireGroupSelected === isEntireGroupSelected)) { + if (!groupKey || !coverageByGroupKey.has(groupKey)) { + continue; + } + const coveredGroupCount = coverageByGroupKey.get(groupKey); + const isEntireGroupSelected = coveredGroupCount !== undefined; + if (transaction.groupKey === groupKey && transaction.isEntireGroupSelected === isEntireGroupSelected && transaction.coveredGroupCount === coveredGroupCount) { continue; } - updated[key] = {...transaction, groupKey, isEntireGroupSelected}; + updated[key] = {...transaction, groupKey, isEntireGroupSelected, coveredGroupCount}; hasWritten = true; } @@ -724,6 +895,7 @@ function applyShiftRangeBatchToSelection( } export { + getCoveredGroupCount, mapTransactionItemToSelectedEntry, mapEmptyReportToSelectedEntry, prepareTransactionsList, @@ -736,5 +908,11 @@ export { isRowChecked, getSearchGroupCount, getSearchGroupCountByKey, + getClaimedGroupKey, + getGroupClaims, + getSelectedRowKeysByGroupKey, + getRemainingSearchGroupCount, + getRowsCheckedInExcludedGroups, + mergeRowsIntoPartlyLoadedGroups, stampGroupCoverageFlags, }; diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 04ff97b9927b..fa9f81ecf85a 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -108,8 +108,14 @@ type SelectedTransactionInfo = { /** Whether the transaction was selected through its group header */ isSelectedViaGroup?: boolean; - /** Whether every transaction in the group is selected. False when a `limit:` left some of the group unloaded. */ + /** Whether every transaction in the group is selected. Only a fully loaded group earns it, and a partly loaded one can only keep it. */ isEntireGroupSelected?: boolean; + + /** The group's count when the selection last covered all of it, so a rise past it means a new expense joined */ + coveredGroupCount?: number; + + /** Set on a row a refresh left off its group's loaded page and kept selected, whose amount and flags date from when it last loaded */ + isKeptOffPage?: boolean; }; /** Model of selected transactions */ @@ -266,6 +272,8 @@ type SearchSelectionActionsValue = { * `shouldPreserveAllMatchingSelection` keeps that mode active for row toggles and records removed rows as exclusions. * `shouldClearAllMatchingSelectionWhenEmpty` exits that mode when no selected rows or additional results remain. * `reconciledExcludedTransactions` refreshes or prunes exclusions when the underlying search data changes. + * `deselectedWithoutEntry` names keys deselected while they had no entry of their own, such as a group excluded whole, + * which the previous/next diff cannot see leave. */ applySelection: ( updater: (previousSelectedTransactions: SelectedTransactions, previousSelection: PreviousSelectionSlices) => SelectedTransactions, @@ -275,6 +283,7 @@ type SearchSelectionActionsValue = { shouldPreserveAllMatchingSelection?: boolean; shouldClearAllMatchingSelectionWhenEmpty?: boolean; reconciledExcludedTransactions?: SelectedTransactions; + deselectedWithoutEntry?: SelectedTransactions; }, ) => void; /** Read on demand without subscribing, so a handler can anchor from the live selection without re-rendering every row */ @@ -288,7 +297,8 @@ type SearchSelectionActionsValue = { (hash?: number, shouldTurnOffSelectionMode?: boolean): void; (clearIDs: true, unused?: undefined): void; }; - removeTransaction: (transactionID: string | undefined) => void; + /** Takes an expense out of the selection after an action on it. A deleted expense leaves its group. One rejected or moved stays in it, which makes a group checked through its header a partial selection */ + removeTransaction: (transactionID: string | undefined, options: {isDeleted: boolean}) => void; selectAllMatchingItems: (on: boolean) => void; }; diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index 0fb1ddd2d909..7a05b444f4b7 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -606,7 +606,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic return; } - removeTransaction(transaction.transactionID); + removeTransaction(transaction.transactionID, {isDeleted: true}); }, CONST.EXPENSE_REPORT_DELETE_DELAY_MS); }; if (goBackRoute) { diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 5a6967f5e8e1..f03c561dd697 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -6,7 +6,7 @@ import {useAllReportsTransactionsAndViolations} from '@components/OnyxListItemPr import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useOpenSearchReportSubmitToPopover} from '@components/ReportSubmitToPopoverAnchor'; import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; -import {getSearchGroupCountByKey} from '@components/Search/selectionBuilders'; +import {getClaimedGroupKey, getSearchGroupCountByKey, mergeRowsIntoPartlyLoadedGroups} from '@components/Search/selectionBuilders'; import type {BulkPaySelectionData, PaymentData, QueryFilterKey, SearchColumnType, SearchGroupBy, SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types'; import {getAccountingIntegrationDisplayName, getExportLabelForConnection, isIntuitEnterpriseSuiteConnection} from '@libs/AccountingUtils'; @@ -235,14 +235,14 @@ function getGroupExportScope(queryJSON: SearchQueryJSON | undefined, selectedTra /** * The group rows a selection covers in full. * - * A group is fully selected when the number of selected children matches the group's remaining transaction - * count. Snapshot `count` is not decremented for pending-delete children, so `isEntireGroupSelected` (stamped - * after subtracting those children) is what covers a second-batch delete of the rest. Clicking the group - * checkbox is not enough on its own: a `limit:` smaller than that count leaves children unloaded, so delete - * cannot remove the whole group. An empty group row is still selected under its own group key. + * A group is fully selected when its rows carry `isEntireGroupSelected`, which the stamp sets after subtracting + * pending-delete children, since snapshot `count` is not decremented for them. Rows never stamped fall back to + * the number of selected children matching the group's count. Clicking the group checkbox is not enough on its + * own: a `limit:` smaller than that count leaves children unloaded, so delete cannot remove the whole group. + * An empty group row is still selected under its own group key. */ function getSelectedGroupKeys(selectedTransactions: SelectedTransactions, searchData?: SearchResultDataType): SearchGroupKey[] { - const selectedCountByGroupKey = new Map(); + const selectedCountByGroupKey = new Map(); const groupKeys = new Set(); for (const [key, transaction] of Object.entries(selectedTransactions)) { @@ -253,16 +253,18 @@ function getSelectedGroupKeys(selectedTransactions: SelectedTransactions, search if (!transaction.groupKey || !isGroupEntry(transaction.groupKey)) { continue; } - const current = selectedCountByGroupKey.get(transaction.groupKey) ?? {selectedCount: 0, isEntireGroupSelected: false}; + const current = selectedCountByGroupKey.get(transaction.groupKey) ?? {selectedCount: 0, isEntireGroupSelected: false, isStamped: false}; current.selectedCount += 1; current.isEntireGroupSelected = current.isEntireGroupSelected || !!transaction.isEntireGroupSelected; + current.isStamped = current.isStamped || transaction.isEntireGroupSelected !== undefined; selectedCountByGroupKey.set(transaction.groupKey, current); } - for (const [groupKey, {selectedCount, isEntireGroupSelected}] of selectedCountByGroupKey) { + for (const [groupKey, {selectedCount, isEntireGroupSelected, isStamped}] of selectedCountByGroupKey) { const groupCount = getSearchGroupCountByKey(searchData, groupKey); if (groupCount !== undefined) { - if (isEntireGroupSelected || selectedCount === groupCount) { + // A stamp has already weighed the loaded rows against rows a refresh kept that may since have left the group, so the count decides only for rows never stamped. + if (isEntireGroupSelected || (!isStamped && selectedCount === groupCount)) { groupKeys.add(groupKey); } continue; @@ -304,14 +306,10 @@ function getGroupKeysSelectedViaGroup(selectedTransactions: SelectedTransactions const groupKeys = new Set(); for (const [key, transaction] of Object.entries(selectedTransactions)) { - if (isGroupEntry(key)) { - groupKeys.add(key); - continue; - } - if (!transaction.isSelectedViaGroup || !transaction.groupKey || !isGroupEntry(transaction.groupKey)) { - continue; + const groupKey = isGroupEntry(key) ? key : getClaimedGroupKey(transaction); + if (groupKey) { + groupKeys.add(groupKey); } - groupKeys.add(transaction.groupKey); } return [...groupKeys]; @@ -453,6 +451,17 @@ function getAllMatchingReportQuery(queryJSON: SearchQueryJSON, excludedTransacti return buildSearchQueryJSON(buildSearchQueryString({...queryJSON, flatFilters})); } +/** + * The query an action on whole reports, such as Pay or Mark as exported, sends under Select all, or undefined when the unchecked rows cannot be left out of it. + * The Reports view leaves out the report of each unchecked expense. An expense search has no report to leave out for one unchecked expense, so it needs nothing unchecked. + */ +function getAllMatchingReportActionQuery(queryJSON: SearchQueryJSON, excludedTransactions: SelectedTransactions, isExpenseReportType: boolean): SearchQueryJSON | undefined { + if (isExpenseReportType) { + return getAllMatchingReportQuery(queryJSON, excludedTransactions); + } + return isEmptyObject(excludedTransactions) ? queryJSON : undefined; +} + /** * The groupings whose selected groups `getSelectedGroupFilterEntry` can turn into a query filter, which a template export needs. * Keep this in sync with that function. @@ -624,8 +633,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {isOffline} = useNetwork(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); - const {selectedTransactions, excludedTransactions = getEmptyObject(), selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {selectedTransactions: loadedSelection, excludedTransactions = getEmptyObject(), selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext(); const {currentSearchResults} = useSearchResultsContext(); + // A group checked through its header that is still loading reaches the actions as one entry, as a collapsed one does, so no action takes only its loaded rows. + const selectedTransactions = useMemo( + () => mergeRowsIntoPartlyLoadedGroups(loadedSelection, currentSearchResults?.data, areAllMatchingItemsSelected), + [loadedSelection, currentSearchResults?.data, areAllMatchingItemsSelected], + ); const {currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); const {clearSelectedTransactions, selectAllMatchingItems} = useSearchSelectionActions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -1643,13 +1657,12 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } if (areAllMatchingItemsSelected) { - const allMatchingQuery = isExpenseReportType && queryJSON ? getAllMatchingReportQuery(queryJSON, excludedTransactions) : queryJSON; - if (isExpenseReportType && !allMatchingQuery) { - Log.info('[BulkPay] Dropping bulk pay: report exclusion has no reportID'); + const allMatchingQuery = queryJSON ? getAllMatchingReportActionQuery(queryJSON, excludedTransactions, isExpenseReportType) : undefined; + if (!allMatchingQuery) { + Log.info('[BulkPay] Dropping bulk pay: the unchecked expenses cannot be left out of the matching reports'); return; } - const serializedQuery = allMatchingQuery ? serializeQueryJSONForBackend(allMatchingQuery) : JSON.stringify(allMatchingQuery); - queueBulkPayReports(serializedQuery); + queueBulkPayReports(serializeQueryJSONForBackend(allMatchingQuery)); playSound(SOUNDS.SUCCESS); clearSelectedTransactions(); return; @@ -2134,6 +2147,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { payableSelectedReports.length > 0 ? Object.values(selectedTransactions).some((transaction) => transaction.isHeld && !!transaction.reportID && payableSelectedReportIDs.includes(transaction.reportID)) : isAnyTransactionOnHold; + const allMatchingReportActionQuery = areAllMatchingItemsSelected && queryJSON ? getAllMatchingReportActionQuery(queryJSON, excludedTransactions, isExpenseReportType) : undefined; const getExportOptions = () => { const areFullReportsSelected = selectedTransactionReportIDs.length === selectedReportIDs.length && selectedTransactionReportIDs.every((id) => selectedReportIDs.includes(id)); @@ -2401,13 +2415,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { .filter((report) => canReportBeExported(report, CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED)) .map((report) => report.reportID) .filter((reportID): reportID is string => reportID !== undefined); - if (reportIDsToMark.length > 0) { + if (reportIDsToMark.length > 0 && (!areAllMatchingItemsSelected || allMatchingReportActionQuery)) { // Under "select all matching", the loaded page can only ever hold a subset of the full matching // set, so the partial-export and export-again modals below (built from page-scoped counts) do // not apply. Send the query instead of the loaded IDs, the same way bulk pay does, so the // backend resolves and marks every matching report on this connection, not just this page. const handleMarkAllMatchingAction = () => { - if (!hash || !queryJSON) { + if (!hash || !allMatchingReportActionQuery) { return; } if (isOffline) { @@ -2419,7 +2433,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { integration === CONST.POLICY.CONNECTIONS.NAME.QBO && isIntuitEnterpriseSuiteConnection(integrationPolicy) ? CONST.POLICY.CONNECTIONS.ACCOUNTING_INTEGRATION_ALIASES.INTUIT_ENTERPRISE_SUITE : undefined; - queueBulkMarkAsExported(serializeQueryJSONForBackend(queryJSON), integration, qboIntegrationAlias); + queueBulkMarkAsExported(serializeQueryJSONForBackend(allMatchingReportActionQuery), integration, qboIntegrationAlias); playSound(SOUNDS.SUCCESS); }; const handleMarkAction = areAllMatchingItemsSelected @@ -2601,7 +2615,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {shouldEnableBulkPayOption} = getPayOption(selectedReports, selectedTransactions, lastPaymentMethods, selectedReportIDs, personalPolicyID); const hasLoadedPayableReport = payableSelectedReports.length > 0 || selectedReports.length === 0; const shouldShowPayOption = areAllMatchingItemsSelected - ? hasLoadedPayableReport && !!bulkPayButtonOptions?.length + ? hasLoadedPayableReport && !!bulkPayButtonOptions?.length && !!allMatchingReportActionQuery : !isAnyPayableTransactionOnHold && shouldEnableBulkPayOption && !!bulkPayButtonOptions?.length; const payButtonOption: DropdownOption & Pick = { icon: expensifyIcons.MoneyBag, @@ -2717,10 +2731,19 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return buildResult(deletedTransactionOptions); } + // Edit multiple, Merge and Download receipts act only on loaded expenses, so they would leave out a group entry's unloaded ones, though not a cash back row's, since it holds none. + const hasGroupEntrySelected = selectedTransactionsKeys.some((key) => { + if (!isGroupEntry(key)) { + return false; + } + const group = currentSearchResults?.data?.[key]; + return !group || !('isCashBack' in group) || !group.isCashBack; + }); const selectedTransactionsList = Object.values(selectedTransactions) .map((transaction) => transaction.transaction) .filter((transaction): transaction is Transaction => !!transaction); const canEditMultiple = + !hasGroupEntrySelected && canEditMultipleTransactions(selectedTransactionsList, allReportActions, allReports, policies, rules, isExpenseReportSearch, searchResults?.data) && isBetaEnabled(CONST.BETAS.BULK_EDIT); @@ -3107,7 +3130,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const selected = selectedTransactions[key]; return hasReceiptTransactionUtils(selected?.transaction) && !isDeletedTransaction(selected ?? {}); }); - if (isExpenseSearch && transactionIDs.length > 0) { + if (isExpenseSearch && !hasGroupEntrySelected && transactionIDs.length > 0) { options.push({ icon: expensifyIcons.Download, text: translate('common.downloadReceipt', {count: transactionIDs.length}), @@ -3212,7 +3235,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); } - if (selectedTransactionsKeys.length < 3 && searchResults?.search.type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && searchResults?.data) { + if (!hasGroupEntrySelected && selectedTransactionsKeys.length < 3 && searchResults?.search.type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && searchResults?.data) { const {transactions: searchedTransactions, reports, policies: transactionPolicies} = getTransactionsAndReportsFromSearch(searchResults, selectedTransactionsKeys); if (isMergeActionForSelectedTransactions(searchedTransactions, reports, transactionPolicies, rules, accountID)) { diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsDeleteAction.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsDeleteAction.tsx index 62d3552746c3..dd34f489512c 100644 --- a/src/pages/DynamicReportDetailsPage/ReportDetailsDeleteAction.tsx +++ b/src/pages/DynamicReportDetailsPage/ReportDetailsDeleteAction.tsx @@ -92,7 +92,7 @@ function ReportDetailsDeleteAction({reportID, caseID, requestData}: ReportDetail if (deleteResult.action === 'redirected') { return; } - removeTransaction(iouTransactionID); + removeTransaction(iouTransactionID, {isDeleted: true}); } }; diff --git a/src/pages/iou/RejectReasonPage.tsx b/src/pages/iou/RejectReasonPage.tsx index b157858f53fd..2434e73ccca5 100644 --- a/src/pages/iou/RejectReasonPage.tsx +++ b/src/pages/iou/RejectReasonPage.tsx @@ -71,7 +71,7 @@ function RejectReasonPage({route}: RejectReasonPageProps) { rules, reportTransactionsCollection, }); - removeTransaction(transactionID); + removeTransaction(transactionID, {isDeleted: false}); // If the super wide rhp is not opened, dismiss the entire modal. if (superWideRHPRouteKeys.length > 0) { Navigation.dismissToSuperWideRHP(); diff --git a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts index cd18b4c2b28e..7fda9fb3a923 100644 --- a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts +++ b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts @@ -227,7 +227,7 @@ function useReportSelectionActions({ getCurrencyDecimals, getCurrencySymbol, }); - removeTransaction(transaction.transactionID); + removeTransaction(transaction.transactionID, {isDeleted: false}); } }, }); @@ -260,7 +260,7 @@ function useReportSelectionActions({ getCurrencyDecimals, getCurrencySymbol, }); - removeTransaction(transaction.transactionID); + removeTransaction(transaction.transactionID, {isDeleted: false}); }, }); }; diff --git a/tests/ui/TableTest.tsx b/tests/ui/TableTest.tsx index 4570ad085b50..03ed2d8c2a2f 100644 --- a/tests/ui/TableTest.tsx +++ b/tests/ui/TableTest.tsx @@ -3245,6 +3245,7 @@ describe('Table', () => { columns={props.columns} renderItem={renderSelectableRow} keyExtractor={props.keyExtractor} + compareItems={props.compareItems} selectionEnabled selectedKeys={selectedKeys} isItemInSearch={props.isItemInSearch} @@ -3315,6 +3316,22 @@ describe('Table', () => { expect(screen.getByTestId('selected-keys')).toHaveTextContent(/^1,2,3$/); }); + it('should range over the rows in their new order when the table is re-sorted mid-range', () => { + render(); + + // Given Apple clicked and a range shift+clicked down to Carrot, in the order the rows arrive: Apple, Banana, Carrot, Date, Eggplant + pressRow(0); + pressRow(2, true); + + // When the table is sorted by value, high to low (Banana, Date, Apple, Eggplant, Carrot), and the range is shift+clicked to Eggplant + fireEvent.press(screen.getByLabelText('Value')); + pressRow(3, true); + + // Then the range runs from Apple to Eggplant as they now sit, so Date, between them only in the old order, stays unchecked; + // Carrot, now past Eggplant, is given back, and Banana, across the anchor, stays checked + expect(screen.getByTestId('selected-keys')).toHaveTextContent(/^1,2,5$/); + }); + it('should exclude disabled rows from the range', () => { const dataWithDisabledRow = mockData.map((item, index) => (index === 2 ? {...item, disabled: true} : item)); render(); diff --git a/tests/unit/Search/LazyGroupSelectionTest.tsx b/tests/unit/Search/LazyGroupSelectionTest.tsx index 5d8fc441e036..7e2af3059b33 100644 --- a/tests/unit/Search/LazyGroupSelectionTest.tsx +++ b/tests/unit/Search/LazyGroupSelectionTest.tsx @@ -9,20 +9,24 @@ import type { TransactionReportGroupListItemType, } from '@components/Search/SearchList/ListItem/types'; import SearchWriteActionsProvider from '@components/Search/SearchWriteActionsProvider'; -import {isRowChecked, mapEmptyReportToSelectedEntry} from '@components/Search/selectionBuilders'; +import {getGroupCheckboxState, isRowChecked, mapEmptyReportToSelectedEntry, mergeRowsIntoPartlyLoadedGroups} from '@components/Search/selectionBuilders'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import type {SearchGroupKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {SearchResults} from '@src/types/onyx'; +import type {SearchResults, Transaction} from '@src/types/onyx'; +import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; import type * as ReactNavigation from '@react-navigation/native'; +import type {OnyxCollection} from 'react-native-onyx'; import React from 'react'; import Onyx from 'react-native-onyx'; import {buildCategoryGroup, buildReportGroup, buildTransactionRow} from '../../utils/collections/searchListItems'; +import createRandomTransaction from '../../utils/collections/transaction'; import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; let mockIsFocused = true; @@ -104,14 +108,25 @@ const earlierChildren = [buildChild(3, '3', EARLIER_GROUP_KEY), buildChild(4, '4 /** The same group as the list sees it: empty at first, carrying the loaded rows afterwards. */ let pagingGroup: TransactionCategoryGroupListItemType = partiallyLoadedGroup; +/** A second group rendered beside the paging one, for a row that moves between them, above it unless `isNeighborGroupBelow` is set. */ +let neighborGroup: TransactionCategoryGroupListItemType | undefined; +let isNeighborGroupBelow = false; + +/** The paging group's search results, for the tests that need excluded rows reconciled. */ +let pagingSearchResults: SearchResults | undefined; + function PagingWrapper({children}: {children: React.ReactNode}) { + let groups = [pagingGroup]; + if (neighborGroup) { + groups = isNeighborGroupBelow ? [pagingGroup, neighborGroup] : [neighborGroup, pagingGroup]; + } return ( ; let groupedSearchResults: SearchResults | undefined; let groupedSearchHash = SEARCH_HASH; @@ -299,7 +315,7 @@ function FlatWrapper({children}: {children: React.ReactNode}) { totalSelectableItemsCount={flatFilteredData.length} searchResults={flatSearchResults} searchHash={SEARCH_HASH} - transactions={undefined} + transactions={flatLiveTransactions} isMobileSelectionModeEnabled={false} type={CONST.SEARCH.DATA_TYPES.EXPENSE} areItemsGrouped={false} @@ -500,9 +516,13 @@ describe('Lazily loaded group selection', () => { partiallyLoadedGroup.transactions = []; cachedPartialGroup.transactions = loadedChildren; pagingGroup = partiallyLoadedGroup; + neighborGroup = undefined; + isNeighborGroupBelow = false; + pagingSearchResults = undefined; flatExpense = makeFlatExpense(-3000); flatFilteredData = [flatExpense, ...flatRangeRows]; flatSearchResults = makeFlatSearchResults(flatExpense); + flatLiveTransactions = undefined; reportFilteredData = [firstReport, secondReport]; reportSearchResults = makeReportSearchResults(); await act(async () => { @@ -803,9 +823,9 @@ describe('Lazily loaded group selection', () => { }); it('recounts whether a group is wholly selected when a range drops one of its rows, so delete cannot take the rest', async () => { + categoryGroup.count = threeLoadedChildren.length; const {result} = renderSelection(TwoGroupWrapper); const [firstChild, secondChild, thirdChild] = threeLoadedChildren; - categoryGroup.count = threeLoadedChildren.length; // Given both groups selected from their headers, the upper one last, so the next shift+click starts from its first row await act(async () => { @@ -1247,6 +1267,32 @@ describe('Lazily loaded group selection', () => { expect(result.current.areAllMatchingItemsSelected).toBe(true); }); + it('rebuilds a selected expense from its live transaction rather than the search snapshot, as a click does', async () => { + // Given a checked expense that was then split on another device, which reaches the live transaction through a push but not the search snapshot + const {result, rerender} = renderFlatSelection(); + await act(async () => { + result.current.toggle(flatExpense); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions[FLAT_TRANSACTION_ID]?.hasBeenSplit).toBe(false); + flatLiveTransactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${FLAT_TRANSACTION_ID}`]: { + ...createRandomTransaction(0), + transactionID: FLAT_TRANSACTION_ID, + reportID: '11', + comment: {originalTransactionID: 'original-1', source: CONST.IOU.TYPE.SPLIT}, + }, + }; + + // When the list's rows refresh and the selection is rebuilt from them + flatFilteredData = [...flatFilteredData]; + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + + // Then the expense reads as split, so the bulk menu cannot offer Split on it again + expect(result.current.selectedTransactions[FLAT_TRANSACTION_ID]?.hasBeenSplit).toBe(true); + }); + it('prunes an excluded expense after it leaves the settled search results', async () => { // Given an expense taken back out of a select-all-matching selection const {result, rerender} = renderFlatSelection(); @@ -2407,4 +2453,1197 @@ describe('Lazily loaded group selection', () => { expect(result.current.selectedTransactions['empty-report-transaction-1']).toBeUndefined(); expect(result.current.areAllMatchingItemsSelected).toBe(true); }); + + describe('selections that rows alone cannot express', () => { + it('clears the page exclusions when Select all on this page covers them again', async () => { + flatFilteredData = [flatExpense]; + const {result} = renderFlatSelection(); + + // Given every matching item selected, then the only loaded row unchecked, which records it as excluded + await excludeFlatExpense(result); + expect(result.current.excludedTransactions[FLAT_TRANSACTION_ID]).toBeDefined(); + expect(Object.keys(result.current.selectedTransactions)).toEqual([]); + + // When the page is selected again from the header checkbox + await act(async () => { + result.current.toggleAll(); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the row is not both selected and excluded, which would have a bulk action skip the row the user just checked + expect(result.current.selectedTransactions[FLAT_TRANSACTION_ID]?.isSelected).toBe(true); + expect(result.current.excludedTransactions[FLAT_TRANSACTION_ID]).toBeUndefined(); + }); + + it('unchecks a child in one click when Select all covers a group whose rows arrived afterwards', async () => { + const {result, rerender} = renderSelection(PagingWrapper); + const [firstChild] = loadedChildren; + + // Given a group selected while collapsed, then every matching item selected, then the group's first page arriving + await act(async () => { + result.current.toggle(pagingGroup, []); + result.current.selectAllMatchingItems(true); + await waitForBatchedUpdatesWithAct(); + }); + pagingGroup = cachedPartialGroup; + rerender({}); + await act(async () => { + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // When one of its rows, checked by the wider selection, is clicked once + await act(async () => { + result.current.toggle(firstChild); + await waitForBatchedUpdatesWithAct(); + }); + + // Then that one click unchecks it, rather than adding a second entry for a row that already reads checked + const isFirstChildChecked = isRowChecked({ + rowKey: firstChild.keyForList, + parentGroupKey: GROUP_KEY, + selectedTransactions: result.current.selectedTransactions, + excludedTransactions: result.current.excludedTransactions, + areAllMatchingItemsSelected: result.current.areAllMatchingItemsSelected, + }); + expect(isFirstChildChecked).toBe(false); + expect(result.current.areAllMatchingItemsSelected).toBe(true); + }); + + it('drops a group exclusion once every row of the group is checked again', async () => { + pagingGroup = {...categoryGroup, count: loadedChildren.length, transactions: []}; + const {result, rerender} = renderSelection(PagingWrapper); + const [firstChild, secondChild] = loadedChildren; + + // Given every matching item selected, then a two-row group unchecked from its header while collapsed + await act(async () => { + result.current.selectAllMatchingItems(true); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(pagingGroup, []); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.excludedTransactions[GROUP_KEY]).toBeDefined(); + + // When both of its rows arrive and are checked again one at a time + pagingGroup = {...categoryGroup, count: loadedChildren.length, transactions: loadedChildren}; + rerender({}); + await act(async () => { + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(firstChild); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(secondChild); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the group's exclusion is gone, since nothing is left for it to stand for and an export would drop both rows with it + expect(result.current.excludedTransactions[GROUP_KEY]).toBeUndefined(); + }); + + it('takes every row of a partly loaded group out of Select all when its header is unchecked, including the ones not loaded', async () => { + pagingGroup = {...cachedPartialGroup}; + const {result} = renderSelection(PagingWrapper); + + // Given every matching item selected, and a group of five whose first page of two has loaded + await act(async () => { + result.current.selectAllMatchingItems(true); + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // When its header is unchecked + await act(async () => { + result.current.toggle(pagingGroup, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the group itself is excluded, so the three rows that never loaded leave the selection with the two that did, + // and its rows hold no exclusion of their own, which the footer would count a second time + expect(result.current.excludedTransactions[GROUP_KEY]).toBeDefined(); + for (const child of loadedChildren) { + expect(result.current.excludedTransactions[child.keyForList]).toBeUndefined(); + } + expect(result.current.areAllMatchingItemsSelected).toBe(true); + }); + + describe('a group checked through its header while its rows page in', () => { + const groupKey = `${CONST.SEARCH.GROUP_PREFIX}advertising`; + const firstPage = [buildChild(1, '1', groupKey), buildChild(2, '2', groupKey)]; + const laterRow = buildChild(5, '5', groupKey); + + async function loadRows(rerender: ReturnType['rerender'], rows: TransactionListItemType[]) { + pagingGroup = {...pagingGroup, transactions: rows}; + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + } + + it('checks the rows that load later in a group checked while collapsed, since an export covers them', async () => { + pagingGroup = {...buildCategoryGroup(groupKey), count: 5}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given a group of five checked from its header while collapsed, then its first page of two arriving + await act(async () => { + result.current.toggle(pagingGroup, []); + await waitForBatchedUpdatesWithAct(); + }); + await loadRows(rerender, firstPage); + + // When its next page arrives + await loadRows(rerender, [...firstPage, laterRow]); + + // Then the row that arrived is checked and claims the group, rather than reading unchecked while the count and an export include it + expect(result.current.selectedTransactions[laterRow.keyForList]?.isSelected).toBe(true); + expect(result.current.selectedTransactions[laterRow.keyForList]?.isSelectedViaGroup).toBe(true); + }); + + it('checks the rows that load later in a group checked after it was expanded', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, firstPage), count: 5}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given a group of five whose first page of two has loaded, checked from its header + await act(async () => { + result.current.toggle(pagingGroup, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + + // When its next page arrives + await loadRows(rerender, [...firstPage, laterRow]); + + // Then the row that arrived is checked and claims the group, as the rows checked through the header do + expect(result.current.selectedTransactions[laterRow.keyForList]?.isSelected).toBe(true); + expect(result.current.selectedTransactions[laterRow.keyForList]?.isSelectedViaGroup).toBe(true); + }); + + it('leaves the rows that load later unchecked once one row of the group was unchecked', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, firstPage), count: 5}; + const {result, rerender} = renderSelection(PagingWrapper); + const [firstRow, secondRow] = firstPage; + + // Given a group of five whose first page of two has loaded, checked from its header, then one of those rows unchecked + await act(async () => { + result.current.toggle(pagingGroup, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(firstRow); + await waitForBatchedUpdatesWithAct(); + }); + + // When its next page arrives + await loadRows(rerender, [...firstPage, laterRow]); + + // Then the row that arrived stays unchecked, since the group no longer stands for every row in it + expect(result.current.selectedTransactions[laterRow.keyForList]).toBeUndefined(); + expect(result.current.selectedTransactions[secondRow.keyForList]?.isSelected).toBe(true); + }); + + it('leaves a new expense unchecked when it lands in a group checked once every row had loaded', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, firstPage), count: 2}; + const {result, rerender} = renderSelection(PagingWrapper); + const [firstRow] = firstPage; + + // Given a group whose two loaded rows are every row it holds, checked from its header + await act(async () => { + result.current.toggle(pagingGroup, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions[firstRow.keyForList]?.isEntireGroupSelected).toBe(true); + + // When a new expense lands in it, and the list refreshes once more afterwards + pagingGroup = {...pagingGroup, count: 3}; + await loadRows(rerender, [...firstPage, laterRow]); + await loadRows(rerender, [...firstPage, laterRow]); + + // Then the new expense stays unchecked and the group becomes a partial selection, so neither a delete nor an export takes it + expect(result.current.selectedTransactions[laterRow.keyForList]).toBeUndefined(); + expect(result.current.selectedTransactions[firstRow.keyForList]?.isSelected).toBe(true); + expect(result.current.selectedTransactions[firstRow.keyForList]?.isSelectedViaGroup).toBe(false); + expect(result.current.selectedTransactions[firstRow.keyForList]?.isEntireGroupSelected).toBe(false); + }); + + it('leaves a row being deleted unchecked when the rows of a group checked from its header refresh', async () => { + const deletingRow = {...buildChild(3, '3', groupKey), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}; + pagingGroup = {...buildCategoryGroup(groupKey, [...firstPage, deletingRow]), count: 5}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given a group of five whose first page holds a row being deleted, checked from its header, which leaves that row out + await act(async () => { + result.current.toggle(pagingGroup, [...firstPage, deletingRow]); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions[deletingRow.keyForList]).toBeUndefined(); + + // When the list refreshes + await loadRows(rerender, [...firstPage, deletingRow]); + + // Then the row being deleted is still left out, as the header left it out + expect(result.current.selectedTransactions[deletingRow.keyForList]).toBeUndefined(); + for (const row of firstPage) { + expect(result.current.selectedTransactions[row.keyForList]?.isSelected).toBe(true); + } + }); + + it('keeps an expense out of a group checked from its header once it is removed from the selection', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, firstPage), count: 5}; + const {result, rerender} = renderSelection(PagingWrapper); + const [firstRow, secondRow] = firstPage; + + // Given a group of five whose first page of two has loaded, checked from its header + await act(async () => { + result.current.toggle(pagingGroup, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + + // When one of its expenses is removed from the selection, as rejecting or moving it does, and the list refreshes + await act(async () => { + result.current.removeTransaction(firstRow.transactionID, {isDeleted: false}); + await waitForBatchedUpdatesWithAct(); + }); + await loadRows(rerender, firstPage); + + // Then it stays out, and the group becomes a partial selection, as unchecking the row would make it + expect(result.current.selectedTransactions[firstRow.keyForList]).toBeUndefined(); + expect(result.current.selectedTransactions[secondRow.keyForList]?.isSelected).toBe(true); + expect(result.current.selectedTransactions[secondRow.keyForList]?.isSelectedViaGroup).toBe(false); + }); + + it('stops reading a group as wholly selected as soon as one of its expenses is rejected or moved', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, firstPage), count: 2}; + const {result} = renderSelection(PagingWrapper); + const [firstRow, secondRow] = firstPage; + + // Given a group whose two loaded rows are every row it holds, checked from its header + await act(async () => { + result.current.toggle(pagingGroup, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions[secondRow.keyForList]?.isEntireGroupSelected).toBe(true); + + // When one of them is rejected or moved, which keeps it in the group but removes it from the selection + await act(async () => { + result.current.removeTransaction(firstRow.transactionID, {isDeleted: false}); + await waitForBatchedUpdatesWithAct(); + }); + + // Then, before any refresh, the other row neither claims the group nor covers it, so a bulk delete cannot take the group with the expense still in it + expect(result.current.selectedTransactions[secondRow.keyForList]?.isSelectedViaGroup).toBe(false); + expect(result.current.selectedTransactions[secondRow.keyForList]?.isEntireGroupSelected).toBe(false); + }); + + it('keeps a group checked from its header whole when one of its expenses is deleted', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, firstPage), count: 5}; + const {result, rerender} = renderSelection(PagingWrapper); + const [firstRow, secondRow] = firstPage; + + // Given a group of five whose first page of two has loaded, checked from its header + await act(async () => { + result.current.toggle(pagingGroup, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + + // When one of its expenses is deleted from its details, which marks the row for deletion and removes it from the selection + await act(async () => { + result.current.removeTransaction(firstRow.transactionID, {isDeleted: true}); + await waitForBatchedUpdatesWithAct(); + }); + await loadRows(rerender, [{...firstRow, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, secondRow]); + + // Then the deleted expense stays out and the other row still stands for the group, since the group no longer holds the expense and its rows not loaded stay selected + expect(result.current.selectedTransactions[firstRow.keyForList]).toBeUndefined(); + expect(result.current.selectedTransactions[secondRow.keyForList]?.isSelectedViaGroup).toBe(true); + }); + + it('keeps a fully loaded group checked from its header whole once a deleted expense leaves its rows while its count still includes it', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, [...firstPage, laterRow]), count: 3}; + const {result, rerender} = renderSelection(PagingWrapper); + const [firstRow, secondRow] = firstPage; + + // Given a group of three whose every row has loaded, checked from its header + await act(async () => { + result.current.toggle(pagingGroup, [...firstPage, laterRow]); + await waitForBatchedUpdatesWithAct(); + }); + + // When one of its expenses is deleted from its details, so its row is first marked for deletion and then leaves the group, while the group's count reads three until the search runs again + await act(async () => { + result.current.removeTransaction(firstRow.transactionID, {isDeleted: true}); + await waitForBatchedUpdatesWithAct(); + }); + await loadRows(rerender, [{...firstRow, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, secondRow, laterRow]); + await loadRows(rerender, [secondRow, laterRow]); + + // Then the group still reads as whole and its header claim stays, so its checkbox keeps its full check rather than turning partly checked + expect(result.current.selectedTransactions[secondRow.keyForList]?.isEntireGroupSelected).toBe(true); + expect(result.current.selectedTransactions[secondRow.keyForList]?.isSelectedViaGroup).toBe(true); + const header = getGroupCheckboxState({ + groupKey, + children: [secondRow, laterRow], + selectedTransactions: result.current.selectedTransactions, + excludedTransactions: result.current.excludedTransactions, + areAllMatchingItemsSelected: result.current.areAllMatchingItemsSelected, + groupCount: 3, + }); + expect(header).toEqual({isSelectAllChecked: true, isIndeterminate: false}); + }); + + it('ends the header claim when a new expense arrives off the page while another of the group is being deleted', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, [...firstPage, laterRow]), count: 3}; + const {result, rerender} = renderSelection(PagingWrapper); + const [firstRow, secondRow] = firstPage; + + // Given a group of three whose every row has loaded, checked from its header + await act(async () => { + result.current.toggle(pagingGroup, [...firstPage, laterRow]); + await waitForBatchedUpdatesWithAct(); + }); + + // When one of its expenses is being deleted from its details while a new expense lands in it on a page that has not loaded, raising its count to four + await act(async () => { + result.current.removeTransaction(firstRow.transactionID, {isDeleted: true}); + await waitForBatchedUpdatesWithAct(); + }); + pagingGroup = {...pagingGroup, count: 4}; + await loadRows(rerender, [{...firstRow, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, secondRow, laterRow]); + + // Then the claim ends and the group stops reading as whole, so an export no longer sends a group filter that would take the new expense nobody checked + expect(result.current.selectedTransactions[secondRow.keyForList]?.isSelectedViaGroup).toBe(false); + expect(result.current.selectedTransactions[secondRow.keyForList]?.isEntireGroupSelected).toBe(false); + }); + + it('keeps a group checked while collapsed when the first page that arrives holds only rows being deleted', async () => { + pagingGroup = {...buildCategoryGroup(groupKey), count: 5}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given a group of five checked from its header while collapsed + await act(async () => { + result.current.toggle(pagingGroup, []); + await waitForBatchedUpdatesWithAct(); + }); + + // When its first page arrives holding only rows being deleted + await loadRows( + rerender, + firstPage.map((row) => ({...row, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE})), + ); + + // Then the group stays selected under its own key, since none of those rows can take the selection and the three not loaded are still checked + expect(result.current.selectedTransactions[groupKey]?.isSelected).toBe(true); + }); + + it('shows a row checked on a later page that a refresh left out, so pressing the header clears it rather than counting it twice', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, [...firstPage, laterRow]), count: 5}; + const {result, rerender} = renderSelection(PagingWrapper); + const readHeader = () => + getGroupCheckboxState({ + groupKey, + children: pagingGroup.transactions, + selectedTransactions: result.current.selectedTransactions, + excludedTransactions: result.current.excludedTransactions, + areAllMatchingItemsSelected: result.current.areAllMatchingItemsSelected, + groupCount: 5, + }); + + // Given a row on the group's second page checked on its own, then a refresh that brings back only the first page + await act(async () => { + result.current.toggle(laterRow); + await waitForBatchedUpdatesWithAct(); + }); + await loadRows(rerender, firstPage); + + // When the header is drawn, and then pressed + const headerBeforePress = readHeader(); + await act(async () => { + result.current.toggle(pagingGroup, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + + // Then it reads partly checked, as the footer counts that row, and the press clears it rather than checking the group around it + expect(headerBeforePress.isIndeterminate).toBe(true); + expect(Object.keys(result.current.selectedTransactions)).toEqual([]); + }); + + it('keeps a group checked whole reading as whole when one of its rows moves to a group further down the list', async () => { + const otherGroupKey = `${CONST.SEARCH.GROUP_PREFIX}travel`; + const [firstRow, movedRow] = firstPage; + const newRow = buildChild(6, '6', groupKey); + pagingGroup = {...buildCategoryGroup(groupKey, [...firstPage, laterRow]), count: 3}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given a group of three whose every row has loaded, checked from its header + await act(async () => { + result.current.toggle(pagingGroup, [...firstPage, laterRow]); + await waitForBatchedUpdatesWithAct(); + }); + + // When one of its rows moves to a group further down the list, as changing its category does, and the group refreshes from its first page alone with its count down to two + isNeighborGroupBelow = true; + neighborGroup = {...buildCategoryGroup(otherGroupKey, [{...movedRow, selectionGroupKey: otherGroupKey}]), count: 1}; + pagingGroup = {...pagingGroup, count: 2}; + await loadRows(rerender, [firstRow]); + + // Then the group still reads as whole, since the moved row is counted where it now sits rather than in the group it left + expect(result.current.selectedTransactions[firstRow.keyForList]?.isEntireGroupSelected).toBe(true); + + // And a new expense landing in it later stays unchecked, rather than being written into a claim that no longer reads as covering + pagingGroup = {...pagingGroup, count: 3}; + await loadRows(rerender, [firstRow, laterRow, newRow]); + expect(result.current.selectedTransactions[newRow.keyForList]).toBeUndefined(); + }); + + it('moves a row to the group it now sits in, so unchecking its old group neither brings that group back nor leaves its header half checked', async () => { + const otherGroupKey = `${CONST.SEARCH.GROUP_PREFIX}travel`; + const [firstRow, movedRow] = firstPage; + pagingGroup = {...buildCategoryGroup(groupKey, firstPage), count: 5}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given a group of five whose first page of two has loaded, checked from its header, then one of those rows moved to another group, as changing its category does + await act(async () => { + result.current.toggle(pagingGroup, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + neighborGroup = {...buildCategoryGroup(otherGroupKey, [{...movedRow, selectionGroupKey: otherGroupKey}]), count: 1}; + await loadRows(rerender, [firstRow]); + expect(result.current.selectedTransactions[movedRow.keyForList]?.groupKey).toBe(otherGroupKey); + + // When the old group is unchecked from its header, and the list refreshes afterwards + await act(async () => { + result.current.toggle(pagingGroup, [firstRow]); + await waitForBatchedUpdatesWithAct(); + }); + await loadRows(rerender, [firstRow]); + + // Then the old group stays unchecked and its header reads unchecked, while the moved row stays checked in the group it now sits in + const oldGroupHeader = getGroupCheckboxState({ + groupKey, + children: [firstRow], + selectedTransactions: result.current.selectedTransactions, + excludedTransactions: result.current.excludedTransactions, + areAllMatchingItemsSelected: result.current.areAllMatchingItemsSelected, + groupCount: 5, + }); + expect(result.current.selectedTransactions[firstRow.keyForList]).toBeUndefined(); + expect(oldGroupHeader).toEqual({isSelectAllChecked: false, isIndeterminate: false}); + expect(result.current.selectedTransactions[movedRow.keyForList]?.isSelected).toBe(true); + }); + + it('leaves a new expense unchecked when a group checked whole refreshes to a page holding only that expense', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, [...firstPage, laterRow]), count: 3}; + const {result, rerender} = renderSelection(PagingWrapper); + const newRow = buildChild(6, '6', groupKey); + + // Given a group of three whose every row has loaded, checked from its header + await act(async () => { + result.current.toggle(pagingGroup, [...firstPage, laterRow]); + await waitForBatchedUpdatesWithAct(); + }); + + // When a new expense lands in it and a refresh brings back a page holding only that expense, then a later refresh brings back every row + pagingGroup = {...pagingGroup, count: 4}; + await loadRows(rerender, [newRow]); + await loadRows(rerender, [newRow, ...firstPage, laterRow]); + + // Then the new expense stays unchecked, so a bulk action cannot take it, while the three rows the header check took stay checked + expect(result.current.selectedTransactions[newRow.keyForList]).toBeUndefined(); + for (const row of [...firstPage, laterRow]) { + expect(result.current.selectedTransactions[row.keyForList]?.isSelected).toBe(true); + } + }); + + it('checks the rows a refresh brings in when none of the rows checked through the header came back with them', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, firstPage), count: 5}; + const {result, rerender} = renderSelection(PagingWrapper); + const otherRows = [buildChild(3, '3', groupKey), buildChild(4, '4', groupKey)]; + + // Given a group of five whose first page of two has loaded, checked from its header + await act(async () => { + result.current.toggle(pagingGroup, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + + // When a refresh brings back two other rows of the group and neither of the first two + await loadRows(rerender, otherRows); + + // Then the rows that came back are checked through the header too, and the first two stay selected, since the check covers all five + for (const row of [...firstPage, ...otherRows]) { + expect(result.current.selectedTransactions[row.keyForList]?.isSelected).toBe(true); + } + }); + + it('reads a partly loaded group as partly checked once one of its rows is unchecked and checked again after its header was checked', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, firstPage), count: 5}; + const {result} = renderSelection(PagingWrapper); + const [firstRow] = firstPage; + const readHeader = () => + getGroupCheckboxState({ + groupKey, + children: firstPage, + selectedTransactions: result.current.selectedTransactions, + excludedTransactions: result.current.excludedTransactions, + areAllMatchingItemsSelected: result.current.areAllMatchingItemsSelected, + groupCount: 5, + }); + + // Given a group of five whose first page of two has loaded, checked from its header, which reads checked since the check covers all five + await act(async () => { + result.current.toggle(pagingGroup, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + expect(readHeader().isSelectAllChecked).toBe(true); + + // When one of its rows is unchecked and checked again, which leaves only the two loaded rows selected + await act(async () => { + result.current.toggle(firstRow); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(firstRow); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the header reads partly checked, so it no longer looks like the check that covered all five + expect(readHeader().isSelectAllChecked).toBe(false); + expect(readHeader().isIndeterminate).toBe(true); + }); + + it('keeps a group checked from its header whole when a refresh brings back only its first page', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, [...firstPage, laterRow]), count: 3}; + const {result, rerender} = renderSelection(PagingWrapper); + const [firstRow] = firstPage; + + // Given a group of three whose every row has loaded, checked from its header + await act(async () => { + result.current.toggle(pagingGroup, [...firstPage, laterRow]); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions[firstRow.keyForList]?.isEntireGroupSelected).toBe(true); + + // When it is refreshed from its first page, as reopening it or returning from the details pane does, and only that page comes back + await loadRows(rerender, firstPage); + + // Then its rows still stand for the whole group, so an export keeps covering the row the refresh did not bring back, and that row is marked as kept off the page + expect(result.current.selectedTransactions[firstRow.keyForList]?.isSelectedViaGroup).toBe(true); + expect(result.current.selectedTransactions[laterRow.keyForList]?.isKeptOffPage).toBe(true); + + // And that row is checked again once its page returns, rebuilt from the loaded row so the mark goes + await loadRows(rerender, [...firstPage, laterRow]); + expect(result.current.selectedTransactions[laterRow.keyForList]?.isSelected).toBe(true); + expect(result.current.selectedTransactions[laterRow.keyForList]?.isKeptOffPage).toBeUndefined(); + }); + + it('leaves a new expense unchecked when it lands in a group checked whole after a refresh brought back only its first page', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, [...firstPage, laterRow]), count: 3}; + const {result, rerender} = renderSelection(PagingWrapper); + const [firstRow] = firstPage; + const newRow = buildChild(6, '6', groupKey); + + // Given a group of three whose every row has loaded, checked from its header, then refreshed from its first page alone + await act(async () => { + result.current.toggle(pagingGroup, [...firstPage, laterRow]); + await waitForBatchedUpdatesWithAct(); + }); + await loadRows(rerender, firstPage); + + // Then the row the refresh left out stays checked and the group still reads as whole, since the header click took every row it held + expect(result.current.selectedTransactions[laterRow.keyForList]?.isSelected).toBe(true); + expect(result.current.selectedTransactions[laterRow.keyForList]?.isEntireGroupSelected).toBe(true); + + // When a new expense lands in it + pagingGroup = {...pagingGroup, count: 4}; + await loadRows(rerender, [...firstPage, newRow]); + + // Then the new expense stays unchecked, so a bulk delete cannot take an expense the user never checked, and the row the refresh left out is still checked + expect(result.current.selectedTransactions[newRow.keyForList]).toBeUndefined(); + expect(result.current.selectedTransactions[firstRow.keyForList]?.isSelectedViaGroup).toBe(false); + expect(result.current.selectedTransactions[laterRow.keyForList]?.isSelected).toBe(true); + }); + + it('keeps the rows a refresh left out checked when that refresh also brings a new expense into a group checked whole', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, [...firstPage, laterRow]), count: 3}; + const {result, rerender} = renderSelection(PagingWrapper); + const newRow = buildChild(6, '6', groupKey); + + // Given a group of three whose every row has loaded, checked from its header + await act(async () => { + result.current.toggle(pagingGroup, [...firstPage, laterRow]); + await waitForBatchedUpdatesWithAct(); + }); + + // When a new expense lands in it, and the refetch that follows brings back only the first page with the new expense on it + pagingGroup = {...pagingGroup, count: 4}; + await loadRows(rerender, [...firstPage, newRow]); + + // Then the new expense stays unchecked, and the row on the page that did not come back stays checked rather than leaving the selection unseen + expect(result.current.selectedTransactions[newRow.keyForList]).toBeUndefined(); + expect(result.current.selectedTransactions[laterRow.keyForList]?.isSelected).toBe(true); + expect(result.current.selectedTransactions[laterRow.keyForList]?.isSelectedViaGroup).toBe(false); + + // And it is still checked after the next refresh, once nothing claims the group any more + await loadRows(rerender, [...firstPage, newRow]); + expect(result.current.selectedTransactions[laterRow.keyForList]?.isSelected).toBe(true); + }); + + it('drops a row that left a group checked whole once the refresh shows every row the group still holds', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, [...firstPage, laterRow]), count: 3}; + const {result, rerender} = renderSelection(PagingWrapper); + const [firstRow] = firstPage; + + // Given a group of three whose every row has loaded, checked from its header + await act(async () => { + result.current.toggle(pagingGroup, [...firstPage, laterRow]); + await waitForBatchedUpdatesWithAct(); + }); + + // When one of its expenses is deleted elsewhere, so the refresh brings back the two rows left and a count of two + pagingGroup = {...pagingGroup, count: 2}; + await loadRows(rerender, firstPage); + + // Then the deleted expense leaves the selection, and the group still reads as whole, so a new expense landing later stays unchecked + expect(result.current.selectedTransactions[laterRow.keyForList]).toBeUndefined(); + expect(result.current.selectedTransactions[firstRow.keyForList]?.isEntireGroupSelected).toBe(true); + }); + + it('keeps a group checked whole when an expense deleted elsewhere leaves it and the refresh brings back only its first row', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, [...firstPage, laterRow]), count: 3}; + const {result, rerender} = renderSelection(PagingWrapper); + const [firstRow] = firstPage; + const newRow = buildChild(6, '6', groupKey); + + // Given a group of three whose every row has loaded, checked from its header + await act(async () => { + result.current.toggle(pagingGroup, [...firstPage, laterRow]); + await waitForBatchedUpdatesWithAct(); + }); + + // When one of its expenses is deleted elsewhere, and the refresh brings back a count of two with only the first row on its page + pagingGroup = {...pagingGroup, count: 2}; + await loadRows(rerender, [firstRow]); + + // Then the group still reads as whole, since every row it still holds was checked, even though the refresh cannot tell which kept row left + expect(result.current.selectedTransactions[firstRow.keyForList]?.isEntireGroupSelected).toBe(true); + + // And a new expense that lands in it later stays unchecked rather than joining a selection the user never widened + pagingGroup = {...pagingGroup, count: 3}; + await loadRows(rerender, [firstRow, newRow]); + expect(result.current.selectedTransactions[newRow.keyForList]).toBeUndefined(); + + // And the group stops reading as whole, even though its kept rows, the deleted one among them, add up to the new count, so a bulk delete cannot hide the unchecked expense with it + expect(result.current.selectedTransactions[firstRow.keyForList]?.isEntireGroupSelected).toBe(false); + }); + + it('stops reading a group as whole once its count rises past the count it shrank to, though the rows kept off the page make up the new count', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, [...firstPage, laterRow]), count: 3}; + const {result, rerender} = renderSelection(PagingWrapper); + const [firstRow, secondRow] = firstPage; + + // Given a group of three checked from its header, one of whose expenses was deleted elsewhere, refreshed to a count of two with only its first row on the page + await act(async () => { + result.current.toggle(pagingGroup, [...firstPage, laterRow]); + await waitForBatchedUpdatesWithAct(); + }); + pagingGroup = {...pagingGroup, count: 2}; + await loadRows(rerender, [firstRow]); + + // When a new expense brings the count back to three on a page that has not loaded + pagingGroup = {...pagingGroup, count: 3}; + await loadRows(rerender, [firstRow]); + + // Then the group no longer reads as whole and its header claim ends, so a bulk delete cannot hide the new expense nobody checked + expect(result.current.selectedTransactions[firstRow.keyForList]?.isEntireGroupSelected).toBe(false); + expect(result.current.selectedTransactions[secondRow.keyForList]?.isEntireGroupSelected).toBe(false); + expect(result.current.selectedTransactions[firstRow.keyForList]?.isSelectedViaGroup).toBe(false); + }); + + it('stops reading a group as whole once its loaded row is unchecked, though the rows kept off the page make up the count', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, [...firstPage, laterRow]), count: 3}; + const {result, rerender} = renderSelection(PagingWrapper); + const [firstRow, secondRow] = firstPage; + + // Given a group of three checked from its header, one of whose expenses was deleted elsewhere, refreshed to a count of two with only its first row on the page + await act(async () => { + result.current.toggle(pagingGroup, [...firstPage, laterRow]); + await waitForBatchedUpdatesWithAct(); + }); + pagingGroup = {...pagingGroup, count: 2}; + await loadRows(rerender, [firstRow]); + + // When that loaded row is unchecked + await act(async () => { + result.current.toggle(firstRow); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the two kept rows, the deleted one among them, no longer stand for the whole group, so a bulk delete leaves the unchecked row on screen + expect(result.current.selectedTransactions[firstRow.keyForList]).toBeUndefined(); + expect(result.current.selectedTransactions[secondRow.keyForList]?.isEntireGroupSelected).toBe(false); + expect(result.current.selectedTransactions[laterRow.keyForList]?.isEntireGroupSelected).toBe(false); + }); + + it('takes the rows a refresh left out with the group when its header is unchecked', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, [...firstPage, laterRow]), count: 3}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given a group of three whose every row has loaded, checked from its header, then refreshed from its first page alone, which keeps the row it left out + await act(async () => { + result.current.toggle(pagingGroup, [...firstPage, laterRow]); + await waitForBatchedUpdatesWithAct(); + }); + await loadRows(rerender, firstPage); + expect(result.current.selectedTransactions[laterRow.keyForList]?.isSelected).toBe(true); + + // When its header is unchecked, and the list refreshes once more afterwards + await act(async () => { + result.current.toggle(pagingGroup, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + await loadRows(rerender, firstPage); + + // Then nothing of the group stays selected, so no count, total or export still holds a row the user can no longer see + expect(Object.keys(result.current.selectedTransactions)).toEqual([]); + }); + + it('leaves a new expense unchecked when the group count rises before the expense arrives', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, firstPage), count: 2}; + const {result, rerender} = renderSelection(PagingWrapper); + const [firstRow] = firstPage; + + // Given a group whose two loaded rows are every row it holds, checked from its header + await act(async () => { + result.current.toggle(pagingGroup, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + + // When a new expense raises the group's count, as creating one does, and its row only arrives with a later refresh + pagingGroup = {...pagingGroup, count: 3}; + await loadRows(rerender, firstPage); + await loadRows(rerender, [...firstPage, laterRow]); + + // Then the new expense stays unchecked and the group becomes a partial selection + expect(result.current.selectedTransactions[laterRow.keyForList]).toBeUndefined(); + expect(result.current.selectedTransactions[firstRow.keyForList]?.isSelectedViaGroup).toBe(false); + }); + }); + + it('keeps a row checked on its own on a later page when a refresh brings back only the first page', async () => { + const groupKey = `${CONST.SEARCH.GROUP_PREFIX}advertising`; + const firstPage = [buildChild(1, '1', groupKey), buildChild(2, '2', groupKey)]; + const laterRow = buildChild(5, '5', groupKey); + pagingGroup = {...buildCategoryGroup(groupKey, [...firstPage, laterRow]), count: 3}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given a group of three whose every row has loaded, with only its last row checked + await act(async () => { + result.current.toggle(laterRow); + await waitForBatchedUpdatesWithAct(); + }); + + // When it is refreshed from its first page and only that page comes back + pagingGroup = {...pagingGroup, transactions: firstPage}; + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + + // Then the row stays checked, since it is still in the group on a page that has not loaded again + expect(result.current.selectedTransactions[laterRow.keyForList]?.isSelected).toBe(true); + }); + + it('drops a checked expense that left a group with rows still to load, rather than keeping it as off the page', async () => { + const groupKey = `${CONST.SEARCH.GROUP_PREFIX}advertising`; + const [keptRow, otherRow, submittedRow] = [buildChild(1, '1', groupKey), buildChild(2, '2', groupKey), buildChild(5, '5', groupKey)]; + pagingGroup = {...buildCategoryGroup(groupKey, [keptRow, otherRow, submittedRow]), count: 60}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given a group of sixty with its first rows loaded, two of them checked on their own + await act(async () => { + result.current.toggle(keptRow); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(submittedRow); + await waitForBatchedUpdatesWithAct(); + }); + + // When one of them stops matching the search, as an expense whose report is submitted under a drafts filter does, so its group's own search still holds it while the list hides it + pagingGroup = {...pagingGroup, transactions: [keptRow, otherRow], hiddenTransactionIDs: [submittedRow.transactionID]}; + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + + // Then that expense leaves the selection, so no bulk action can reach an expense the list no longer shows, while the other stays checked + expect(result.current.selectedTransactions[submittedRow.keyForList]).toBeUndefined(); + expect(result.current.selectedTransactions[keptRow.keyForList]?.isSelected).toBe(true); + }); + + it('drops the checked expenses of a group once every one of its rows has left it', async () => { + const groupKey = `${CONST.SEARCH.GROUP_PREFIX}advertising`; + const draftRows = [buildChild(1, '1', groupKey), buildChild(2, '2', groupKey)]; + pagingGroup = {...buildCategoryGroup(groupKey, draftRows), count: 2}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given a group of two, both of its expenses checked from its header + await act(async () => { + result.current.toggle(pagingGroup, draftRows); + await waitForBatchedUpdatesWithAct(); + }); + + // When the report holding both is submitted under a drafts filter, so the group's own search still holds them while the list shows none + pagingGroup = {...pagingGroup, transactions: [], hiddenTransactionIDs: draftRows.map((row) => row.transactionID)}; + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + + // Then both leave the selection, rather than being kept as rows of a group that has not loaded, which nothing on screen could uncheck + for (const row of draftRows) { + expect(result.current.selectedTransactions[row.keyForList]).toBeUndefined(); + } + }); + + it('reads a group whose every row was checked one by one as checked after a refresh brings back only its first page', async () => { + const groupKey = `${CONST.SEARCH.GROUP_PREFIX}advertising`; + const firstPage = [buildChild(1, '1', groupKey), buildChild(2, '2', groupKey)]; + const laterRow = buildChild(5, '5', groupKey); + pagingGroup = {...buildCategoryGroup(groupKey, [...firstPage, laterRow]), count: 3}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given a group of three whose every row has loaded, with each row checked on its own, as a range over the group checks them + const [firstRow, secondRow] = firstPage; + await act(async () => { + result.current.toggle(firstRow); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(secondRow); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(laterRow); + await waitForBatchedUpdatesWithAct(); + }); + + // When it is refreshed from its first page and only that page comes back + pagingGroup = {...pagingGroup, transactions: firstPage}; + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + + // Then its header reads checked, since every row of the group is still selected, the one off the page included + expect(result.current.selectedTransactions[laterRow.keyForList]?.isEntireGroupSelected).toBe(true); + const header = getGroupCheckboxState({ + groupKey, + children: firstPage, + selectedTransactions: result.current.selectedTransactions, + excludedTransactions: result.current.excludedTransactions, + areAllMatchingItemsSelected: result.current.areAllMatchingItemsSelected, + groupCount: 3, + }); + expect(header).toEqual({isSelectAllChecked: true, isIndeterminate: false}); + }); + + it('stops reading a group checked one by one as whole once an expense off the page is rejected from its details', async () => { + const groupKey: SearchGroupKey = `${CONST.SEARCH.GROUP_PREFIX}advertising`; + const [firstRow, rejectedRow, laterRow] = [buildChild(1, '1', groupKey), buildChild(2, '2', groupKey), buildChild(5, '5', groupKey)]; + pagingGroup = {...buildCategoryGroup(groupKey, [firstRow, rejectedRow, laterRow]), count: 3}; + const searchData: SearchResultDataType = {}; + searchData[groupKey] = {category: groupKey, count: 3, total: 300, currency: CONST.CURRENCY.USD}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given a group of three with each row checked on its own, then refreshed from its first page so two of its rows are kept off the page + for (const row of [firstRow, rejectedRow, laterRow]) { + await act(async () => { + result.current.toggle(row); + await waitForBatchedUpdatesWithAct(); + }); + } + pagingGroup = {...pagingGroup, transactions: [firstRow]}; + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + + // When one of the expenses off the page is rejected from its details, so it stays in the group unchecked, and the group refreshes again + await act(async () => { + result.current.removeTransaction(rejectedRow.transactionID, {isDeleted: false}); + await waitForBatchedUpdatesWithAct(); + }); + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + + // Then the rest no longer stand for the whole group, so its header reads partly checked and the count is the two still checked rather than the group's three + for (const row of [firstRow, laterRow]) { + expect(result.current.selectedTransactions[row.keyForList]?.isEntireGroupSelected).toBe(false); + } + const header = getGroupCheckboxState({ + groupKey, + children: [firstRow], + selectedTransactions: result.current.selectedTransactions, + excludedTransactions: result.current.excludedTransactions, + areAllMatchingItemsSelected: result.current.areAllMatchingItemsSelected, + groupCount: 3, + }); + expect(header).toEqual({isSelectAllChecked: false, isIndeterminate: true}); + expect(Object.keys(mergeRowsIntoPartlyLoadedGroups(result.current.selectedTransactions, searchData, false)).sort()).toEqual([firstRow.keyForList, laterRow.keyForList].sort()); + }); + + it('counts the rows left of a wholly selected group after a loaded expense is deleted, rather than the server figures that still include it', async () => { + const groupKey: SearchGroupKey = `${CONST.SEARCH.GROUP_PREFIX}advertising`; + const [deletedRow, keptRow, laterRow] = [buildChild(1, '1', groupKey), buildChild(2, '2', groupKey), buildChild(5, '5', groupKey)]; + pagingGroup = {...buildCategoryGroup(groupKey, [deletedRow, keptRow, laterRow]), count: 3}; + const searchData: SearchResultDataType = {}; + searchData[groupKey] = {category: groupKey, count: 3, total: 300, currency: CONST.CURRENCY.USD}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given a group of three with each row checked on its own, then refreshed from its first page so its last row is kept off the page + for (const row of [deletedRow, keptRow, laterRow]) { + await act(async () => { + result.current.toggle(row); + await waitForBatchedUpdatesWithAct(); + }); + } + pagingGroup = {...pagingGroup, transactions: [deletedRow, keptRow]}; + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + + // When a loaded expense is deleted from its details, so its row is marked for deletion and then leaves, while the group's count and total still include it until the search runs again + await act(async () => { + result.current.removeTransaction(deletedRow.transactionID, {isDeleted: true}); + await waitForBatchedUpdatesWithAct(); + }); + pagingGroup = {...pagingGroup, transactions: [{...deletedRow, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, keptRow]}; + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + pagingGroup = {...pagingGroup, transactions: [keptRow]}; + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + + // Then the count and total take the two rows still selected, not the group's three and 300 + expect(Object.keys(mergeRowsIntoPartlyLoadedGroups(result.current.selectedTransactions, searchData, false)).sort()).toEqual([keptRow.keyForList, laterRow.keyForList].sort()); + }); + + describe('a partly loaded group taken out of Select all', () => { + const groupKey = `${CONST.SEARCH.GROUP_PREFIX}advertising`; + const firstPage = [buildChild(1, '1', groupKey), buildChild(2, '2', groupKey)]; + const [firstRow, secondRow] = firstPage; + + async function pressRow(result: ReturnType['result'], row: TransactionListItemType) { + await act(async () => { + result.current.toggle(row); + await waitForBatchedUpdatesWithAct(); + }); + } + + async function selectAllAndExpand(result: ReturnType['result']) { + await act(async () => { + result.current.selectAllMatchingItems(true); + expandGroup(result, groupKey, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + } + + it('reads a group as fully checked again once a row unchecked in it has moved to another group', async () => { + pagingSearchResults = makeFlatSearchResults(undefined); + const otherGroupKey = `${CONST.SEARCH.GROUP_PREFIX}travel`; + pagingGroup = {...buildCategoryGroup(groupKey, firstPage), count: 5}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given every matching item selected and one loaded row of a partly loaded group unchecked + await selectAllAndExpand(result); + await pressRow(result, secondRow); + + // When that row moves to another group, as changing its category does + neighborGroup = {...buildCategoryGroup(otherGroupKey, [{...secondRow, selectionGroupKey: otherGroupKey}]), count: 1}; + pagingGroup = {...pagingGroup, transactions: [firstRow], count: 4}; + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + + // Then its exclusion follows it, so the group it left reads fully checked rather than holding back a row it no longer has + const oldGroupHeader = getGroupCheckboxState({ + groupKey, + children: [firstRow], + selectedTransactions: result.current.selectedTransactions, + excludedTransactions: result.current.excludedTransactions, + areAllMatchingItemsSelected: result.current.areAllMatchingItemsSelected, + groupCount: 4, + }); + expect(result.current.excludedTransactions[secondRow.keyForList]?.groupKey).toBe(otherGroupKey); + expect(oldGroupHeader.isSelectAllChecked).toBe(true); + }); + + it('lets a row checked again inside a group excluded whole be unchecked once it has moved to another group', async () => { + const otherGroupKey = `${CONST.SEARCH.GROUP_PREFIX}travel`; + const secondRowAfterMove = {...secondRow, selectionGroupKey: otherGroupKey}; + pagingGroup = {...buildCategoryGroup(groupKey, firstPage), count: 5}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given every matching item selected, a group of five unchecked from its header after its first page loaded, and one of its rows checked again + await selectAllAndExpand(result); + await act(async () => { + result.current.toggle(pagingGroup, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + await pressRow(result, secondRow); + + // When that row moves to another group, as changing its category does, and is unchecked there + neighborGroup = {...buildCategoryGroup(otherGroupKey, [secondRowAfterMove]), count: 1}; + pagingGroup = {...pagingGroup, transactions: [firstRow]}; + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + await pressRow(result, secondRowAfterMove); + + // Then it reads unchecked, since its exclusion is recorded against the group it now sits in rather than covered by the old group's + const isMovedRowChecked = isRowChecked({ + rowKey: secondRow.keyForList, + parentGroupKey: otherGroupKey, + selectedTransactions: result.current.selectedTransactions, + excludedTransactions: result.current.excludedTransactions, + areAllMatchingItemsSelected: result.current.areAllMatchingItemsSelected, + }); + expect(isMovedRowChecked).toBe(false); + }); + + it('keeps a row checked again inside a group excluded whole when a refresh leaves it out', async () => { + const laterRow = buildChild(5, '5', groupKey); + pagingGroup = {...buildCategoryGroup(groupKey, firstPage), count: 5}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given every matching item selected, a group of five unchecked from its header after its first page loaded, then a later row loaded and checked again + await selectAllAndExpand(result); + await act(async () => { + result.current.toggle(pagingGroup, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + pagingGroup = {...pagingGroup, transactions: [...firstPage, laterRow]}; + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + await pressRow(result, laterRow); + expect(result.current.selectedTransactions[laterRow.keyForList]?.isSelected).toBe(true); + + // When a refresh brings back only the first page + pagingGroup = {...pagingGroup, transactions: firstPage}; + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + + // Then the row stays checked, since the user checked it again by hand and it is still in the group + expect(result.current.selectedTransactions[laterRow.keyForList]?.isSelected).toBe(true); + }); + + it('takes the rows a refresh left out with the group when its header is unchecked, rather than counting them as checked again', async () => { + const laterRow = buildChild(5, '5', groupKey); + pagingGroup = {...buildCategoryGroup(groupKey, [...firstPage, laterRow]), count: 3}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given every matching item selected over a group of three whose rows have all loaded, then a refresh that brings back only its first page + await act(async () => { + result.current.selectAllMatchingItems(true); + expandGroup(result, groupKey, [...firstPage, laterRow]); + await waitForBatchedUpdatesWithAct(); + }); + pagingGroup = {...pagingGroup, transactions: firstPage}; + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + + // When its header is unchecked + await act(async () => { + result.current.toggle(pagingGroup, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the group is excluded whole and none of its rows stays selected, which the footer would count back as checked again by hand + expect(Object.keys(result.current.excludedTransactions)).toEqual([groupKey]); + expect(result.current.selectedTransactions[laterRow.keyForList]).toBeUndefined(); + }); + + it('takes the rows not loaded out with the group when its loaded rows were unchecked one at a time before its header', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, firstPage), count: 5}; + const {result} = renderSelection(PagingWrapper); + + // Given every matching item selected, and a group of five whose first page of two has loaded and been unchecked row by row + await selectAllAndExpand(result); + await pressRow(result, firstRow); + await pressRow(result, secondRow); + + // When its header, which still holds the three rows not loaded, is pressed + await act(async () => { + result.current.toggle(pagingGroup, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the group itself is excluded, so those three rows leave the selection too, rather than the press checking the two loaded rows again + expect(result.current.excludedTransactions[groupKey]).toBeDefined(); + expect(result.current.selectedTransactions[firstRow.keyForList]).toBeUndefined(); + expect(result.current.selectedTransactions[secondRow.keyForList]).toBeUndefined(); + }); + + it('excludes a row once when it is checked again inside the group excluded whole and then unchecked', async () => { + pagingGroup = {...buildCategoryGroup(groupKey, firstPage), count: 5}; + const {result} = renderSelection(PagingWrapper); + + // Given every matching item selected, a group of five unchecked from its header after its first page loaded, and one of those rows checked again + await selectAllAndExpand(result); + await act(async () => { + result.current.toggle(pagingGroup, firstPage); + await waitForBatchedUpdatesWithAct(); + }); + await pressRow(result, firstRow); + expect(result.current.excludedTransactions[groupKey]).toBeDefined(); + + // When that row is unchecked again + await pressRow(result, firstRow); + + // Then the group's exclusion is the only one, since it already covers the row and the footer would otherwise subtract it twice + expect(Object.keys(result.current.excludedTransactions)).toEqual([groupKey]); + }); + + it('takes a partly loaded group out of Select all from its header when every loaded row is being deleted', async () => { + const deletingPage = firstPage.map((row) => ({...row, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE})); + pagingGroup = {...buildCategoryGroup(groupKey, deletingPage), count: 5}; + const {result} = renderSelection(PagingWrapper); + + // Given every matching item selected, and a group of five whose two loaded rows are both being deleted + await act(async () => { + result.current.selectAllMatchingItems(true); + expandGroup(result, groupKey, deletingPage); + await waitForBatchedUpdatesWithAct(); + }); + + // When its header, which still holds the three rows not loaded, is pressed + await act(async () => { + result.current.toggle(pagingGroup, deletingPage); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the group itself is excluded, rather than the press doing nothing because no loaded row can be checked + expect(result.current.excludedTransactions[groupKey]).toBeDefined(); + }); + }); + + it('drops a group exclusion when its header checks the group again after its rows arrived', async () => { + pagingGroup = {...categoryGroup, count: loadedChildren.length, transactions: []}; + const {result, rerender} = renderSelection(PagingWrapper); + + // Given every matching item selected, then a group unchecked from its header while collapsed, then its rows arriving + await act(async () => { + result.current.selectAllMatchingItems(true); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(pagingGroup, []); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.excludedTransactions[GROUP_KEY]).toBeDefined(); + pagingGroup = {...categoryGroup, count: loadedChildren.length, transactions: loadedChildren}; + rerender({}); + await act(async () => { + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // When its header checks the group again + await act(async () => { + result.current.toggle(pagingGroup, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the exclusion goes, rather than reading every row of the group as unchecked under a header that shows checked + expect(result.current.excludedTransactions[GROUP_KEY]).toBeUndefined(); + }); + }); }); diff --git a/tests/unit/Search/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx index 12ef7024f21c..6dc572be885f 100644 --- a/tests/unit/Search/SearchBulkActionsButtonTest.tsx +++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx @@ -218,6 +218,53 @@ describe('SearchBulkActionsButton all-matching count', () => { expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:1', isLoading: false}); }); + it('counts the rows of a group checked through its header as the whole group, including the rows not loaded', () => { + // Given a group of 692 expenses checked through its header with only two of its rows loaded, next to an expense of another group checked on its own + const groupKey = `${CONST.SEARCH.GROUP_PREFIX}2026_10_07`; + mockAreAllMatchingItemsSelected = false; + mockSearchData = {[groupKey]: {count: 692, total: 12990, currency: CONST.CURRENCY.USD}}; + mockSelectedTransactions = { + tx1: {...makeTransaction(), groupKey, isSelectedViaGroup: true}, + tx2: {...makeTransaction(), groupKey, isSelectedViaGroup: true}, + tx3: {...makeTransaction(), groupKey: `${CONST.SEARCH.GROUP_PREFIX}2026_10_03`}, + }; + + // When the bar shows that selection + render(); + + // Then it counts what an export of the selection covers, which is the whole group plus the other expense + expect(getBarProps()).toEqual({selectedCount: 693, isSelectedCountLoading: false}); + }); + + it('subtracts one expense for a row excluded from Select all after its group was checked through the header', () => { + // Given Select all with one row unchecked, where the row still carries the claim of the group header that checked it + const groupKey = `${CONST.SEARCH.GROUP_PREFIX}2026_10_07`; + mockSearchCount = 694; + mockSearchData = {[groupKey]: {count: 692, total: 12990, currency: CONST.CURRENCY.USD}}; + mockExcludedTransactions = {tx2: {...makeTransaction(), groupKey, isSelectedViaGroup: true}}; + + // When the bar shows the selection + render(); + + // Then only that row leaves the count, since the rest of its group stays selected + expect(getBarProps()).toEqual({selectedCount: 693, isSelectedCountLoading: false}); + }); + + it('counts a row checked again inside a group excluded whole from Select all', () => { + // Given Select all over ten expenses, a five-expense group excluded whole, and one of its rows checked again on its own + const groupKey = `${CONST.SEARCH.GROUP_PREFIX}2026_10_07`; + mockSearchCount = 10; + mockSearchData = {[groupKey]: {count: 5, total: 500, currency: CONST.CURRENCY.USD}}; + mockExcludedTransactions = {[groupKey]: makeTransaction()}; + mockSelectedTransactions = {tx1: {...makeTransaction(), groupKey}}; + + // When the bar shows the selection + render(); + + // Then the row counts alongside the five expenses still selected, as its checkbox shows + expect(getBarProps()).toEqual({selectedCount: 6, isSelectedCountLoading: false}); + }); + it('keeps loading when an exclusion exists before the count arrives', () => { mockSearchIsLoading = true; mockExcludedTransactions = {tx2: makeTransaction()}; diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index 623ee2fbdf76..e93842400497 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -72,7 +72,7 @@ const mockAreAllMatchingItemsSelected = {current: false}; // The loaded rows of each report, which is what tells a report excluded down to its last row from a part-excluded one. const mockTransactionsByReportID: {current: Map>} = {current: new Map()}; const mockShouldUseLiveData = {current: false}; -const mockCurrentSearchResults: {current: SearchResults | undefined} = {current: undefined}; +const mockCurrentSearchResults: {current: {data: Record} | undefined} = {current: undefined}; jest.mock('@components/Search/SearchContext', () => ({ useSearchQueryContext: () => mockSearchQueryContext.current, useSearchResultsContext: () => ({ @@ -345,6 +345,113 @@ describe('SearchSelectionFooter', () => { expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES, count: 2, total: 200, currency: CONST.CURRENCY.USD})); }); + it('counts and totals the rows of a group checked through its header as the whole group, including the rows not loaded', async () => { + // Given a group of 692 expenses checked through its header with only two of its rows loaded, next to an expense of another group checked on its own + const groupKey = `${CONST.SEARCH.GROUP_PREFIX}2026_10_07`; + mockCurrentSearchResults.current = {data: {[groupKey]: {count: 692, total: 12990, currency: CONST.CURRENCY.USD}}}; + mockSelectedTransactions.current = { + transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), groupKey, isSelectedViaGroup: true}, + transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), groupKey, isSelectedViaGroup: true}, + transaction3: {...buildSelectedTransaction(CONST.CURRENCY.USD), groupKey: `${CONST.SEARCH.GROUP_PREFIX}2026_10_03`}, + }; + + // When the footer shows that selection, which is short of the 694 matching expenses + render(); + await waitForBatchedUpdates(); + + // Then it counts and totals what an export of the selection covers, which is the whole group plus the other expense + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 693, total: 13090, currency: CONST.CURRENCY.USD})); + }); + + it('subtracts one expense for a row excluded from Select all after its group was checked through the header', async () => { + // Given Select all with one row unchecked, where the row still carries the claim of the group header that checked it + const groupKey = `${CONST.SEARCH.GROUP_PREFIX}2026_10_07`; + mockCurrentSearchResults.current = {data: {[groupKey]: {count: 692, total: 12990, currency: CONST.CURRENCY.USD}}}; + mockSelectedTransactions.current = {}; + mockExcludedTransactions.current = {transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), groupKey, isSelectedViaGroup: true}}; + mockAreAllMatchingItemsSelected.current = true; + + // When the footer shows the selection + render(); + await waitForBatchedUpdates(); + + // Then only that row leaves the count and the total, since the rest of its group stays selected + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 693, total: 12920, currency: CONST.CURRENCY.USD})); + }); + + describe('a row checked again inside a group excluded whole from Select all', () => { + const groupKey = `${CONST.SEARCH.GROUP_PREFIX}2026_10_07`; + const searchHash = mockSearchQueryContext.current.currentSearchHash; + + beforeEach(() => { + // Select all over ten expenses, a five-expense group excluded whole, and one of its rows checked again on its own + mockCurrentSearchResults.current = {data: {[groupKey]: {count: 5, total: 500, currency: CONST.CURRENCY.USD}}}; + mockExcludedTransactions.current = {[groupKey]: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: 500}}; + mockSelectedTransactions.current = { + transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), groupKey, transaction: {...createRandomTransaction(1), transactionID: 'transaction1'}}, + }; + mockAreAllMatchingItemsSelected.current = true; + }); + + async function chooseFooterCurrency(currency: string) { + await act(async () => { + mockCapturedFooterProps.current?.onCurrencyChange?.(currency); + await waitForBatchedUpdates(); + }); + } + + it('counts and totals the row alongside the expenses still selected', async () => { + // Given the selection above + + // When the footer shows it + render(); + await waitForBatchedUpdates(); + + // Then the row is back in the count and the total, as its checkbox shows, rather than going with its group's exclusion + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 6, total: 600, currency: CONST.CURRENCY.USD})); + }); + + it('adds the row back to a total converted to another currency', async () => { + // Given converted figures for the whole search, the excluded group and the row, each still matching the figures it was converted from + await Onyx.merge(ONYXKEYS.SEARCH_FOOTER_CONVERSION, { + searchTotals: {[searchHash]: {[CONST.CURRENCY.EUR]: {count: 10, total: 2000}}}, + groups: {[groupKey]: {[CONST.CURRENCY.EUR]: -1000}}, + transactions: {transaction1: {[CONST.CURRENCY.EUR]: -200}}, + sources: { + searchTotals: {[searchHash]: {[CONST.CURRENCY.EUR]: 1000}}, + groups: {[groupKey]: {[CONST.CURRENCY.EUR]: -500}}, + transactions: {transaction1: {[CONST.CURRENCY.EUR]: -100}}, + }, + }); + render(); + await waitForBatchedUpdates(); + + // When the footer is switched to that currency + await chooseFooterCurrency(CONST.CURRENCY.EUR); + + // Then the row's converted amount comes back into the converted total + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 6, total: 1200, currency: CONST.CURRENCY.EUR})); + }); + + it('converts the row as well before showing a converted total', async () => { + // Given converted figures for the whole search and the excluded group, but none yet for the row + await Onyx.merge(ONYXKEYS.SEARCH_FOOTER_CONVERSION, { + searchTotals: {[searchHash]: {[CONST.CURRENCY.EUR]: {count: 10, total: 2000}}}, + groups: {[groupKey]: {[CONST.CURRENCY.EUR]: -1000}}, + sources: {searchTotals: {[searchHash]: {[CONST.CURRENCY.EUR]: 1000}}, groups: {[groupKey]: {[CONST.CURRENCY.EUR]: -500}}}, + }); + render(); + await waitForBatchedUpdates(); + + // When the footer is switched to that currency + await chooseFooterCurrency(CONST.CURRENCY.EUR); + + // Then the row's conversion is requested, and the footer stays on the default currency until it arrives rather than mixing the two + expect(getFooterConvertedAmounts).toHaveBeenCalledWith(expect.objectContaining({targetCurrency: CONST.CURRENCY.EUR, transactionIDList: 'transaction1'})); + expect(mockCapturedFooterProps.current?.currency).toBe(CONST.CURRENCY.USD); + }); + }); + it('does not request the same report conversion twice before the optimistic source stamp is observed', async () => { setSearchQuery('type:expense-report'); mockSelectedTransactions.current = {transaction2: buildSelectedTransaction(CONST.CURRENCY.USD, undefined, -100, 'report2')}; @@ -427,6 +534,23 @@ describe('SearchSelectionFooter', () => { expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 169, total: undefined})); }); + it('adds a row checked again inside the group back onto the count under a breakdown', async () => { + // Given a select-all with the collapsed group unchecked and one of its expenses checked again on its own, on the reimbursable breakdown + setSearchQuery(`type:expense group-by:category footerTotal:${CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE}`); + mockAreAllMatchingItemsSelected.current = true; + mockExcludedTransactions.current = {[GROUP_KEY]: buildSelectedTransaction(CONST.CURRENCY.USD, CONST.CURRENCY.USD, -50000)}; + mockSelectedTransactions.current = {transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), groupKey: GROUP_KEY}}; + const searchResults = buildSearchResults(CONST.CURRENCY.USD, 172, 80000, CONST.SEARCH.DATA_TYPES.EXPENSE, undefined, true); + mockCurrentSearchResults.current = {...searchResults, data: {[GROUP_KEY]: makeSettlementGroup({count: 3, total: 50000})}}; + + // When the footer renders + render(); + await waitForBatchedUpdates(); + + // Then the row comes back onto the count, as on the plain total and the selection button, while no figure is shown + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 170, total: undefined})); + }); + it('keeps the total selector under a breakdown, so the plain total can still be picked', async () => { // When the footer describes the reimbursable breakdown, which leaves it no figure to show await renderWithExcludedGroup(`type:expense group-by:category footerTotal:${CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE}`); diff --git a/tests/unit/Search/SearchSelectionProviderTest.tsx b/tests/unit/Search/SearchSelectionProviderTest.tsx index 465a4982f985..969f9c770257 100644 --- a/tests/unit/Search/SearchSelectionProviderTest.tsx +++ b/tests/unit/Search/SearchSelectionProviderTest.tsx @@ -88,11 +88,33 @@ function removeTransaction(selectedTransactions: SelectedTransactions, transacti return nextSelection; } +function buildGroupRows(groupKey: string, ...keys: string[]): SelectedTransactions { + return Object.fromEntries(Object.entries(buildSelected(...keys)).map(([key, entry]) => [key, {...entry, groupKey, isSelectedViaGroup: true}])); +} + describe('SearchSelectionProvider all-matching exclusions', () => { beforeEach(() => { mockCurrentSearchQueryJSON = expenseQueryJSON; }); + it('keeps a group selected when the same commit names its exclusion and selects it again', () => { + // Given every matching item selected + const {result} = renderSelection(); + seedAllMatchingSelection(result); + + // When one commit both names the group as excluded and writes its rows back through the group + act(() => { + result.current.actions.applySelection((selectedTransactions) => ({...selectedTransactions, ...buildGroupRows('group_1', 'tx_3')}), { + shouldPreserveAllMatchingSelection: true, + deselectedWithoutEntry: buildSelected('group_1'), + }); + }); + + // Then the selection wins, since what the commit writes is what the checkbox will show + expect(result.current.state.excludedTransactions.group_1).toBeUndefined(); + expect(result.current.state.selectedTransactions.tx_3?.isSelected).toBe(true); + }); + it('keeps all-matching active and records a row exclusion', () => { // Given every matching item selected, with two rows loaded const {result} = renderSelection(); diff --git a/tests/unit/Search/applyShiftRangeBatchToSelectionTest.ts b/tests/unit/Search/applyShiftRangeBatchToSelectionTest.ts index 44a6313911b8..08ce667676d1 100644 --- a/tests/unit/Search/applyShiftRangeBatchToSelectionTest.ts +++ b/tests/unit/Search/applyShiftRangeBatchToSelectionTest.ts @@ -79,6 +79,42 @@ describe('applyShiftRangeBatchToSelection', () => { expect(Object.keys(updated)).toEqual(['t1']); }); + it('does not count a group as wholly selected once a range gives back its loaded row, though rows kept off the page make up the count', () => { + // Given a two-expense group whose one loaded row is selected along with two rows a refresh kept off the page, one of them deleted elsewhere + const loaded = makeChild(1, 'c1'); + const selection: SelectedTransactions = { + c1: {...buildEntry(loaded)[1], groupKey: 'groupA'}, + c2: {...buildEntry(makeChild(2, 'c2'))[1], groupKey: 'groupA'}, + c3: {...buildEntry(makeChild(3, 'c3'))[1], groupKey: 'groupA'}, + }; + const lookups = {...lookupsFor(new Map([['c1', 'groupA']]), new Map([['groupA', [loaded]]])), getGroupCount: () => 2}; + + // When a range gives back the loaded row + const updated = applyShiftRangeBatchToSelection(batchOf([], [loaded]), selection, false, lookups); + + // Then the kept rows no longer stand for the whole group, so a bulk delete leaves the unchecked row on screen + expect(updated.c2?.isEntireGroupSelected).toBe(false); + expect(updated.c3?.isEntireGroupSelected).toBe(false); + }); + + it('gives back the rows a refresh left off the page along with the group a range no longer covers', () => { + // Given a group a range selected whole, with one loaded row and one row a refresh left off the loaded page, both standing for the group + const loaded = makeChild(1, 'c1'); + const leftOut = makeChild(2, 'c2'); + const group = makeGroup('groupA', [loaded]); + const selection: SelectedTransactions = { + c1: {...buildEntry(loaded)[1], groupKey: 'groupA', isSelectedViaGroup: true}, + c2: {...buildEntry(leftOut)[1], groupKey: 'groupA', isSelectedViaGroup: true}, + }; + const lookups = lookupsFor(new Map([['c1', 'groupA']]), new Map([['groupA', [loaded]]])); + + // When the range shrinks and gives the group back + const updated = applyShiftRangeBatchToSelection(batchOf([], [group]), selection, false, lookups); + + // Then both rows go, as when the group's header is unchecked, so the row left off the page cannot check the group again + expect(updated).toEqual({}); + }); + it('returns the map it was given when the batch writes nothing, so the commit bails on identity', () => { // Given a row already selected exactly as the range would write it const first = makeChild(1, 't1'); diff --git a/tests/unit/Search/buildShiftRangeSourceTest.ts b/tests/unit/Search/buildShiftRangeSourceTest.ts index ebfb8804ab77..fbac4577b693 100644 --- a/tests/unit/Search/buildShiftRangeSourceTest.ts +++ b/tests/unit/Search/buildShiftRangeSourceTest.ts @@ -90,6 +90,7 @@ describe('isGroupSelected', () => { selectedTransactions, excludedTransactions: {}, areAllMatchingItemsSelected: false, + groupCount: undefined, ...overrides, }); @@ -138,6 +139,67 @@ describe('isGroupSelected', () => { expect(getGroupCheckboxState(params).isSelectAllChecked).toBe(false); }); + it('reads a partly loaded group excluded whole as unchecked, since its exclusion covers the rows it has not loaded', () => { + // Given the same group, with the group itself excluded as well as its loaded row + const params = groupOf({}, {areAllMatchingItemsSelected: true, excludedTransactions: selectionOf('c1', 'groupA'), groupCount: 692}); + + // When the checkbox is drawn + const {isSelectAllChecked, isIndeterminate} = getGroupCheckboxState(params); + + // Then it reads unchecked, since nothing under it is selected + expect(isSelectAllChecked).toBe(false); + expect(isIndeterminate).toBe(false); + }); + + it('reads a partly loaded group excluded whole as partly checked once its loaded row is checked again, since the rows it has not loaded stay excluded', () => { + // Given a 692-expense group excluded whole under an all-matching selection, whose one loaded row was then checked on its own + const params = groupOf(selectionOf('c1'), {areAllMatchingItemsSelected: true, excludedTransactions: selectionOf('groupA'), groupCount: 692}); + + // When the checkbox is drawn + const {isSelectAllChecked, isIndeterminate} = getGroupCheckboxState(params); + + // Then it reads partly checked rather than checked, so it does not claim the rows its exclusion still leaves out + expect(isSelectAllChecked).toBe(false); + expect(isIndeterminate).toBe(true); + }); + + it.each([ + {through: 'a check of its header', groupKey: `${CONST.SEARCH.GROUP_PREFIX}a`, isClaimed: true, isKeySelected: false, areAllMatchingItemsSelected: false}, + {through: 'its own key', groupKey: 'groupA', isClaimed: false, isKeySelected: true, areAllMatchingItemsSelected: false}, + {through: 'Select all', groupKey: 'groupA', isClaimed: false, isKeySelected: false, areAllMatchingItemsSelected: true}, + ])( + 'reads a partly loaded group as checked when its loaded rows are checked through $through, which stands for the rows it has not loaded', + ({groupKey, isClaimed, isKeySelected, areAllMatchingItemsSelected}) => { + // Given a five-expense group whose one loaded row is checked through something that also covers the rows not loaded + const [, entry] = mapEmptyReportToSelectedEntry(makeGroup('anyGroup')); + const selectedTransactions: SelectedTransactions = { + c1: isClaimed ? {...entry, groupKey, isSelectedViaGroup: true} : entry, + ...(isKeySelected ? {[groupKey]: entry} : {}), + }; + const params = groupOf(selectedTransactions, {groupKey, groupCount: 5, areAllMatchingItemsSelected}); + + // When the checkbox is drawn + const {isSelectAllChecked} = getGroupCheckboxState(params); + + // Then it reads checked, since every row of the group is selected + expect(isSelectAllChecked).toBe(true); + }, + ); + + it('reads a partly loaded group under Select all as partly checked when a row unchecked on a later page was left off by a refresh', () => { + // Given every matching item selected, a five-expense group whose loaded row is checked, and a row on a later page unchecked that a refresh left out + const groupKey = `${CONST.SEARCH.GROUP_PREFIX}a`; + const [, entry] = mapEmptyReportToSelectedEntry(makeGroup('anyGroup')); + const params = groupOf({}, {groupKey, groupCount: 5, areAllMatchingItemsSelected: true, excludedTransactions: {c7: {...entry, groupKey}}}); + + // When the checkbox is drawn + const {isSelectAllChecked, isIndeterminate} = getGroupCheckboxState(params); + + // Then it reads partly checked, since that row is still out of the selection + expect(isSelectAllChecked).toBe(false); + expect(isIndeterminate).toBe(true); + }); + it('ignores a row being deleted, so clicking the header cannot mean deselect while the checkbox reads unchecked', () => { // Given a group whose only checked row is on its way out const deletedChild = {...child, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}; diff --git a/tests/unit/Search/getSelectedGroupKeysTest.ts b/tests/unit/Search/getSelectedGroupKeysTest.ts index 816faf434dee..805ea11695dc 100644 --- a/tests/unit/Search/getSelectedGroupKeysTest.ts +++ b/tests/unit/Search/getSelectedGroupKeysTest.ts @@ -77,6 +77,20 @@ describe('getSelectedGroupKeys', () => { expect(keys).toEqual([groupKey]); }); + it('skips a group stamped as not wholly selected even when its selected rows match the count', () => { + // Given two selected rows of a two-expense group, stamped as not covering it since a loaded row was unchecked and one of them was deleted elsewhere + const selection = buildSelection({ + txn2: {groupKey, isEntireGroupSelected: false}, + txn3: {groupKey, isEntireGroupSelected: false}, + }); + + // When the groups a delete would take whole are read + const keys = getSelectedGroupKeys(selection, buildGroupSearchData({[groupKey]: {count: 2, total: 0, currency: 'USD'}})); + + // Then the group is left out, so the delete hides only the selected rows and not the unchecked one with them + expect(keys).toEqual([]); + }); + it('skips a group whose loaded selection is smaller than the group count', () => { const keys = getSelectedGroupKeys( buildSelection({ diff --git a/tests/unit/Search/selectionBuildersTest.ts b/tests/unit/Search/selectionBuildersTest.ts index c98185975bd0..f862bbef016b 100644 --- a/tests/unit/Search/selectionBuildersTest.ts +++ b/tests/unit/Search/selectionBuildersTest.ts @@ -1,18 +1,30 @@ import type {TransactionGroupListItemType, TransactionReportGroupListItemType} from '@components/Search/SearchList/ListItem/types'; -import {mapEmptyReportToSelectedEntry, stampGroupCoverageFlags} from '@components/Search/selectionBuilders'; +import {mapEmptyReportToSelectedEntry, mergeRowsIntoPartlyLoadedGroups, stampGroupCoverageFlags} from '@components/Search/selectionBuilders'; import type {SelectedTransactions} from '@components/Search/types'; +import type {SearchGroupKey} from '@libs/SearchUIUtils'; + import CONST from '@src/CONST'; +import type {SearchDayGroup, SearchResultDataType} from '@src/types/onyx/SearchResults'; +import {buildTransactionRow} from '../../utils/collections/searchListItems'; import createMock from '../../utils/createMock'; -const groupKey = `${CONST.SEARCH.GROUP_PREFIX}42`; +const groupKey: SearchGroupKey = `${CONST.SEARCH.GROUP_PREFIX}42`; function buildSelection(entries: Record>): SelectedTransactions { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return Object.fromEntries(Object.entries(entries).map(([key, entry]) => [key, {isSelected: true, ...entry}])) as SelectedTransactions; } +function buildDayGroupData(groups: Array<[SearchGroupKey, Omit]>): SearchResultDataType { + const data: SearchResultDataType = {}; + for (const [key, group] of groups) { + data[key] = {...group, currency: CONST.CURRENCY.USD, day: '2026-10-07'}; + } + return data; +} + describe('selectionBuilders', () => { describe('mapEmptyReportToSelectedEntry', () => { it('takes displayAmount from the report-signed total for a report row', () => { @@ -51,6 +63,8 @@ describe('selectionBuilders', () => { describe('stampGroupCoverageFlags', () => { it('covers the remaining children when snapshot count still includes pending-delete rows', () => { + // Given a group whose snapshot count of three still includes a loaded row being deleted, with its other two rows selected + // When its coverage is stamped const selected = stampGroupCoverageFlags({ selectedTransactions: buildSelection({ txn1: {groupKey}, @@ -58,15 +72,17 @@ describe('selectionBuilders', () => { }), groupKey, groupCount: 3, - loadedChildrenCount: 3, - loadedSelectableCount: 2, + loadedRows: [buildTransactionRow(1, 'txn1'), buildTransactionRow(2, 'txn2'), buildTransactionRow(3, 'txn3', {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE})], }); + // Then the group is wholly selected, since the row being deleted is no longer one the selection has to hold expect(selected.txn1.isEntireGroupSelected).toBe(true); expect(selected.txn2.isEntireGroupSelected).toBe(true); }); it('does not cover a truncated limit selection as the whole group', () => { + // Given two loaded rows of a five-expense group, both checked through its header + // When its coverage is stamped const selected = stampGroupCoverageFlags({ selectedTransactions: buildSelection({ txn1: {isSelectedViaGroup: true, groupKey}, @@ -74,12 +90,209 @@ describe('selectionBuilders', () => { }), groupKey, groupCount: 5, - loadedChildrenCount: 2, - loadedSelectableCount: 2, + loadedRows: [buildTransactionRow(1, 'txn1'), buildTransactionRow(2, 'txn2')], }); + // Then the group is not wholly selected, so delete cannot take three rows it never loaded, while the header claim stays for export expect(selected.txn1.isEntireGroupSelected).toBe(false); expect(selected.txn1.isSelectedViaGroup).toBe(true); }); + + it('does not cover the group while a loaded row is unchecked, even when rows kept off the page make up the count', () => { + // Given a group of two whose one loaded row was just unchecked, with two rows a refresh kept off the page, one of them deleted elsewhere + const selection = buildSelection({ + txn2: {groupKey}, + txn3: {groupKey}, + }); + + // When its coverage is stamped + const selected = stampGroupCoverageFlags({selectedTransactions: selection, groupKey, groupCount: 2, loadedRows: [buildTransactionRow(1, 'txn1')]}); + + // Then the group is not wholly selected, so a bulk delete takes only the checked rows and leaves the unchecked one on screen + expect(selected.txn2.isEntireGroupSelected).toBe(false); + expect(selected.txn3.isEntireGroupSelected).toBe(false); + }); + + it('does not let rows kept off the page prove a partly loaded group whole, since any of them can be stale', () => { + // Given a three-expense group whose one loaded row is checked, next to two rows a refresh kept off the page, with no earlier coverage to keep + const selection = buildSelection({ + txn1: {groupKey}, + txn2: {groupKey}, + txn3: {groupKey}, + }); + + // When its coverage is stamped + const selected = stampGroupCoverageFlags({selectedTransactions: selection, groupKey, groupCount: 3, loadedRows: [buildTransactionRow(1, 'txn1')]}); + + // Then the group is not wholly selected, even though the three rows match the count, since only rows on the page can prove it + expect(selected.txn1.isEntireGroupSelected).toBe(false); + }); + }); + + describe('mergeRowsIntoPartlyLoadedGroups', () => { + const otherGroupKey: SearchGroupKey = `${CONST.SEARCH.GROUP_PREFIX}43`; + + it('puts one entry for the whole group in place of its loaded rows when they were checked through the header', () => { + // Given two loaded rows of a 692-expense group checked through its header, next to a row of another group checked on its own + const searchData = buildDayGroupData([ + [groupKey, {count: 692, total: 12990}], + [otherGroupKey, {count: 2, total: 30}], + ]); + const selection = buildSelection({ + txn1: {isSelectedViaGroup: true, groupKey, displayAmount: 10}, + txn2: {isSelectedViaGroup: true, groupKey, displayAmount: 10}, + txn3: {groupKey: otherGroupKey, displayAmount: 15}, + }); + + // When the rows are merged into the groups they stand for + const merged = mergeRowsIntoPartlyLoadedGroups(selection, searchData, false); + + // Then the group appears once with its full total, as an export covers it, and the row checked on its own is kept as it was + expect(Object.keys(merged)).toEqual(['txn3', groupKey]); + expect(merged[groupKey]).toEqual(expect.objectContaining({displayAmount: 12990, currency: CONST.CURRENCY.USD})); + expect(merged.txn3).toBe(selection.txn3); + }); + + it('folds a row of the group checked one by one into the group entry, rather than counting it a second time', () => { + // Given a loaded row of a ten-expense group checked through its header, and a row of the same group checked on its own on a page a refresh left out + const searchData = buildDayGroupData([[groupKey, {count: 10, total: 100}]]); + const selection = buildSelection({ + txn1: {isSelectedViaGroup: true, groupKey, displayAmount: 10}, + txn7: {groupKey, displayAmount: 10}, + }); + + // When the rows are merged into the groups they stand for + const merged = mergeRowsIntoPartlyLoadedGroups(selection, searchData, false); + + // Then only the group's entry is left, so a count says ten and a total adds the row once + expect(Object.keys(merged)).toEqual([groupKey]); + }); + + it('leaves the selection alone once every row of the group is loaded', () => { + // Given both rows of a two-expense group checked through its header + const searchData = buildDayGroupData([[groupKey, {count: 2, total: 20}]]); + const selection = buildSelection({ + txn1: {isSelectedViaGroup: true, groupKey}, + txn2: {isSelectedViaGroup: true, groupKey}, + }); + + // When the rows are merged into the groups they stand for + const merged = mergeRowsIntoPartlyLoadedGroups(selection, searchData, false); + + // Then nothing changes, because the loaded rows already count as the whole group + expect(merged).toBe(selection); + }); + + it('leaves the selection alone when the group count still includes rows being deleted', () => { + // Given the two rows left of a group whose snapshot count of 3 still includes a row being deleted, stamped as covering the group + const searchData = buildDayGroupData([[groupKey, {count: 3, total: 30}]]); + const selection = buildSelection({ + txn1: {isSelectedViaGroup: true, groupKey, isEntireGroupSelected: true}, + txn2: {isSelectedViaGroup: true, groupKey, isEntireGroupSelected: true}, + }); + + // When the rows are merged into the groups they stand for + const merged = mergeRowsIntoPartlyLoadedGroups(selection, searchData, false); + + // Then nothing changes, because the rows already cover what is left of the group + expect(merged).toBe(selection); + }); + + it('puts one entry for the whole group in place of rows that outnumber it, since some of them left the group unseen', () => { + // Given three rows of a group checked whole through its header, kept after one of them was deleted elsewhere and the group's count fell to two + const searchData = buildDayGroupData([[groupKey, {count: 2, total: 20}]]); + const selection = buildSelection({ + txn1: {isSelectedViaGroup: true, groupKey, isEntireGroupSelected: true, displayAmount: 10}, + txn2: {isSelectedViaGroup: true, groupKey, isEntireGroupSelected: true, displayAmount: 10}, + txn3: {isSelectedViaGroup: true, groupKey, isEntireGroupSelected: true, displayAmount: 10, isKeptOffPage: true}, + }); + + // When the rows are merged into the groups they stand for + const merged = mergeRowsIntoPartlyLoadedGroups(selection, searchData, false); + + // Then the group appears once with the server's count and total, so the deleted expense is neither counted nor sent to an action + expect(Object.keys(merged)).toEqual([groupKey]); + expect(merged[groupKey]).toEqual(expect.objectContaining({displayAmount: 20})); + }); + + it('puts one entry for the whole group in place of rows checked through its header that match its count without covering it', () => { + // Given three rows standing for a four-expense group checked through its header, one of them deleted elsewhere, after a refresh brought the count to three with one row loaded + const searchData = buildDayGroupData([[groupKey, {count: 3, total: 30}]]); + const selection = buildSelection({ + txn1: {isSelectedViaGroup: true, groupKey, isEntireGroupSelected: false, displayAmount: 10}, + txn2: {isSelectedViaGroup: true, groupKey, isEntireGroupSelected: false, displayAmount: 10}, + txn3: {isSelectedViaGroup: true, groupKey, isEntireGroupSelected: false, displayAmount: 10}, + }); + + // When the rows are merged into the groups they stand for + const merged = mergeRowsIntoPartlyLoadedGroups(selection, searchData, false); + + // Then the group appears once, as the stamp found it not wholly selected, so no row action takes the deleted expense or misses the one still to load + expect(Object.keys(merged)).toEqual([groupKey]); + }); + + it('keeps the rows of a wholly selected group holding one kept off the page, since the server figures can still include an expense deleted since', () => { + // Given a wholly selected group whose rows a refresh left partly off the page, after one of its expenses was deleted while the server's figures still read three expenses and 300 + const searchData = buildDayGroupData([[groupKey, {count: 3, total: 300}]]); + const selection = buildSelection({ + txn2: {groupKey, isEntireGroupSelected: true, displayAmount: 100}, + txn3: {groupKey, isEntireGroupSelected: true, displayAmount: 100, isKeptOffPage: true}, + }); + + // When the rows are merged for the count, the total and the bulk actions + const merged = mergeRowsIntoPartlyLoadedGroups(selection, searchData, false); + + // Then the two selected rows stay, so the count and total are two expenses and 200 rather than the server's three and 300, and Delete or Hold stay on offer + expect(merged).toBe(selection); + }); + + it('leaves loaded rows that outnumber the count alone, since the count is only behind an expense that moved in', () => { + // Given four loaded rows of a group each checked on its own, which covered it, one of them just moved in while the group's count still reads three + const searchData = buildDayGroupData([[groupKey, {count: 3, total: 30}]]); + const selection = buildSelection({ + txn1: {groupKey, isEntireGroupSelected: true}, + txn2: {groupKey, isEntireGroupSelected: true}, + txn3: {groupKey, isEntireGroupSelected: true}, + txn4: {groupKey, isEntireGroupSelected: true}, + }); + + // When the rows are merged into the groups they stand for + const merged = mergeRowsIntoPartlyLoadedGroups(selection, searchData, false); + + // Then nothing changes, since only a row kept off the page can have left unseen, so the row actions stay on offer + expect(merged).toBe(selection); + }); + + it('puts one entry for the whole group in place of rows checked one by one that outnumber it, once they covered the group', () => { + // Given three rows of a group each checked on its own, which covered it, kept after one of them was deleted elsewhere and the group's count fell to two + const searchData = buildDayGroupData([[groupKey, {count: 2, total: 20}]]); + const selection = buildSelection({ + txn1: {groupKey, isEntireGroupSelected: true, displayAmount: 10}, + txn2: {groupKey, isEntireGroupSelected: true, displayAmount: 10}, + txn3: {groupKey, isEntireGroupSelected: true, displayAmount: 10, isKeptOffPage: true}, + }); + + // When the rows are merged into the groups they stand for + const merged = mergeRowsIntoPartlyLoadedGroups(selection, searchData, false); + + // Then the group appears once with the server's count and total, as it does for a group checked through its header + expect(Object.keys(merged)).toEqual([groupKey]); + expect(merged[groupKey]).toEqual(expect.objectContaining({displayAmount: 20})); + }); + + it('leaves the selection alone under Select all, where the query already covers the rows not loaded', () => { + // Given two loaded rows of a 692-expense group checked through its header, with every matching expense selected + const searchData = buildDayGroupData([[groupKey, {count: 692, total: 12990}]]); + const selection = buildSelection({ + txn1: {isSelectedViaGroup: true, groupKey}, + txn2: {isSelectedViaGroup: true, groupKey}, + }); + + // When the rows are merged into the groups they stand for + const merged = mergeRowsIntoPartlyLoadedGroups(selection, searchData, true); + + // Then nothing changes, because every action under Select all sends the query rather than the rows + expect(merged).toBe(selection); + }); }); }); diff --git a/tests/unit/Search/useGroupChildren.test.tsx b/tests/unit/Search/useGroupChildren.test.tsx index 8d04ab7fa8f7..9b252e3e6ffd 100644 --- a/tests/unit/Search/useGroupChildren.test.tsx +++ b/tests/unit/Search/useGroupChildren.test.tsx @@ -40,6 +40,7 @@ type HookArgs = Parameters[0]; const baseArgs: HookArgs = { groupKey: GROUP_KEY, groupTransactions: rows, + groupCount: undefined, }; const renderGroupChildren = (overrides: Partial = {}) => renderHook(() => useGroupChildren({...baseArgs, ...overrides})); diff --git a/tests/unit/Search/useSearchSnapshotTest.ts b/tests/unit/Search/useSearchSnapshotTest.ts index f3d2088da7d3..7f47b06ee3a8 100644 --- a/tests/unit/Search/useSearchSnapshotTest.ts +++ b/tests/unit/Search/useSearchSnapshotTest.ts @@ -56,9 +56,10 @@ jest.mock('@hooks/usePolicyForMovingExpenses', () => ({ policyForMovingExpenses: undefined, }), })); +let mockGroupSnapshots: Record = {}; jest.mock('@hooks/useMultipleSnapshots', () => ({ __esModule: true, - default: () => ({}), + default: () => mockGroupSnapshots, })); jest.mock('@components/Search/SearchContext', () => ({ @@ -96,6 +97,8 @@ jest.mock('@libs/SearchUIUtils', () => ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-return isSearchDataLoaded: (...args: unknown[]) => mockIsSearchDataLoaded(...args), isTransactionGroupListItemType: (item: Record) => 'transactions' in item, + getSortedTransactionData: (transactions: unknown) => transactions, + isTransactionEntry: (key: string) => key.startsWith('transactions_'), })); // The optimistic-tracking hooks are mocked so this suite exercises the hook's WIRING (correct inputs in, @@ -157,6 +160,7 @@ function trackingReturn(searchDataWithOptimisticTransaction: unknown, optimistic describe('useSearchSnapshot', () => { beforeEach(() => { mockIsOffline = false; + mockGroupSnapshots = {}; for (const key of Object.keys(onyxData)) { delete onyxData[key]; } @@ -537,6 +541,54 @@ describe('useSearchSnapshot', () => { expect(result.current.filteredData).toEqual(groups.slice(0, 2).map((group) => expect.objectContaining({keyForList: group.keyForList, transactions: group.transactions}))); }); + it('lists, for each group, the expenses its own search returned that the list no longer shows', () => { + const searchResults = makeSearchResults(); + mockUseOptimisticSearchTracking.mockReturnValue(trackingReturn(searchResults.data)); + const draftsGroupHash = 11; + const unchangedGroupHash = 22; + const draftsGroupData = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}a`]: {transactionID: 'a'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}b`]: {transactionID: 'b'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}submitted`]: {transactionID: 'submitted'}, + [`${ONYXKEYS.COLLECTION.REPORT}1`]: {reportID: '1'}, + }; + const unchangedGroupData = {[`${ONYXKEYS.COLLECTION.TRANSACTION}c`]: {transactionID: 'c'}}; + const groups = [ + {keyForList: 'group0', transactions: [], transactionsQueryJSON: {hash: draftsGroupHash}}, + {keyForList: 'group1', transactions: [], transactionsQueryJSON: {hash: unchangedGroupHash}}, + ]; + mockGroupSnapshots = { + [String(draftsGroupHash)]: {data: draftsGroupData, search: {hasMoreResults: true}}, + [String(unchangedGroupHash)]: {data: unchangedGroupData, search: {hasMoreResults: false}}, + }; + mockGetSections.mockImplementation(({data}: {data: unknown}) => { + if (data === draftsGroupData) { + return [[{transactionID: 'a'}, {transactionID: 'b'}], 2, false]; + } + if (data === unchangedGroupData) { + return [[{transactionID: 'c'}], 1, false]; + } + return [groups, groups.length, false]; + }); + + // Given two open groups, one holding an expense whose report was submitted, which the drafts list stops showing, and one whose rows all still match + // When the grouped search is projected + const {result} = renderHook(() => + useSearchSnapshot({ + queryJSON: makeQueryJSON({groupBy: CONST.SEARCH.GROUP_BY.FROM}), + searchResults, + transactions: undefined, + reportActions: undefined, + }), + ); + + // Then each group names the expenses that left it, so the selection can let go of them while it keeps rows that have only not loaded yet + expect(result.current.filteredData).toEqual([ + expect.objectContaining({keyForList: 'group0', transactions: [{transactionID: 'a'}, {transactionID: 'b'}], hiddenTransactionIDs: ['submitted']}), + expect.objectContaining({keyForList: 'group1', transactions: [{transactionID: 'c'}], hiddenTransactionIDs: []}), + ]); + }); + it('does not spend visibleRowLimit on rows being deleted online', () => { // Given a live list capped at two rows, whose first two rows are being deleted while online const searchResults = makeSearchResults(); diff --git a/tests/unit/hooks/useSearchBulkActionsDeleteTest.ts b/tests/unit/hooks/useSearchBulkActionsDeleteTest.ts index 2af05495edd3..221921083948 100644 --- a/tests/unit/hooks/useSearchBulkActionsDeleteTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsDeleteTest.ts @@ -169,6 +169,7 @@ jest.mock('@hooks/useUndeleteTransactions', () => ({ let mockShouldShowDeleteOption = false; jest.mock('@libs/SearchUIUtils', () => ({ shouldShowDeleteOption: () => mockShouldShowDeleteOption, + isGroupEntry: jest.requireActual('@libs/SearchUIUtils').isGroupEntry, getSelectedGroupFilterEntry: jest.fn(), navigateToSearchRHP: jest.fn(), getValidGroupBy: jest.fn(), diff --git a/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts b/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts index 06b4f72d9263..0c3c016442fc 100644 --- a/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts @@ -17,6 +17,8 @@ import Onyx from 'react-native-onyx'; import type * as MockUsePaymentContextUtil from '../../utils/mockUsePaymentContext'; +import {makeSettlementGroup} from '../../utils/ExpensifyCardStatementTestUtils'; + jest.mock('@libs/actions/Export', () => ({ exportReportsToPDF: jest.fn(() => 'mock-pdf-export-id'), exportReceiptsToZip: jest.fn(() => 'mock-receipts-export-id'), @@ -375,23 +377,55 @@ describe('useSearchBulkActions - Download receipts', () => { expect(exportReceiptsToZip).toHaveBeenCalledWith({transactionIDs: expect.arrayContaining(['tx1', 'tx2'])}); }); - it('drops group_ selection keys and only sends real transaction IDs', async () => { + it('hides the option next to a group checked while collapsed, since the download would leave out its expenses', async () => { + // Given an expense with a receipt checked next to a group checked while collapsed, whose expenses are not loaded const groupKey = `${CONST.SEARCH.GROUP_PREFIX}123`; mockSelectedTransactions = { [groupKey]: makeSelectedTransaction({reportID: undefined}), tx1: makeSelectedTransaction(), }; + // When the bulk actions are built const {result} = renderHookWithProvider(() => useSearchBulkActions({queryJSON: expenseQueryJSON})); + await waitFor(() => { + expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0); + }); + + // Then Download receipts is not offered, since the zip can only take loaded expenses and would leave the group out + expect(getDownloadReceiptsOption(result.current.headerButtonsOptions)).toBeUndefined(); + }); + it('drops a cash back row from the IDs it sends, since that row holds no expenses', async () => { + // Given an expense with a receipt checked next to a cash back row, which selecting the page checks under its own key + const cashBackKey = `${CONST.SEARCH.GROUP_PREFIX}cashBack` as const; + mockSelectedTransactions = { + [cashBackKey]: makeSelectedTransaction({reportID: undefined}), + tx1: makeSelectedTransaction(), + }; + mockCurrentSearchResults = { + data: {[cashBackKey]: makeSettlementGroup({count: 0, total: -2500, isCashBack: true})}, + search: { + offset: 0, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + hash: 12345, + hasMoreResults: false, + hasResults: true, + isLoading: false, + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + }, + }; + + // When Download receipts is chosen + const {result} = renderHookWithProvider(() => useSearchBulkActions({queryJSON: expenseQueryJSON})); await waitFor(() => { expect(getDownloadReceiptsOption(result.current.headerButtonsOptions)).toBeDefined(); }); - await act(async () => { await getDownloadReceiptsOption(result.current.headerButtonsOptions)?.onSelected?.(); }); + // Then only the expense is sent, since the cash back row leaves out no expense and is not one itself expect(exportReceiptsToZip).toHaveBeenCalledTimes(1); expect(exportReceiptsToZip).toHaveBeenCalledWith({transactionIDs: ['tx1']}); }); diff --git a/tests/unit/hooks/useSearchBulkActionsExportTest.ts b/tests/unit/hooks/useSearchBulkActionsExportTest.ts index af91676bd091..0183748c124e 100644 --- a/tests/unit/hooks/useSearchBulkActionsExportTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsExportTest.ts @@ -252,11 +252,13 @@ let mockSelectedTransactions: SelectedTransactions = {}; let mockSelectedReports: SelectedReports[] = []; let mockCurrentSearchResults: SearchResults | undefined; let mockAreAllMatchingItemsSelected = false; +let mockExcludedTransactions: SelectedTransactions = {}; let mockCurrentSearchKey: string | undefined; jest.mock('@components/Search/SearchContext', () => ({ useSearchSelectionContext: () => ({ selectedTransactions: mockSelectedTransactions, + excludedTransactions: mockExcludedTransactions, selectedReports: mockSelectedReports, areAllMatchingItemsSelected: mockAreAllMatchingItemsSelected, }), @@ -546,6 +548,7 @@ describe('useSearchBulkActions - export options', () => { // tests override with mockResolvedValueOnce to exercise the cancel path. mockShowConfirmModal.mockResolvedValue({action: 'CONFIRM'}); mockAreAllMatchingItemsSelected = false; + mockExcludedTransactions = {}; mockCurrentSearchKey = undefined; mockIsOffline = false; @@ -1303,6 +1306,29 @@ describe('useSearchBulkActions - export options', () => { expect(mockClearSelectedTransactions).toHaveBeenCalled(); }); + it('leaves the reports of unchecked expenses out of an all-matching mark-as-exported in the Reports view', async () => { + // Given every matching report selected in the Reports view, then one expense unchecked + mockAreAllMatchingItemsSelected = true; + mockCurrentSearchResults = makeSearchResults([makeSnapshotReport()]); + mockSelectedReports = [makeSelectedReport()]; + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + mockExcludedTransactions = {tx2: makeSelectedTransaction({reportID: 'report-with-unchecked-expense'})}; + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + await waitFor(() => { + expect(getExportSubMenuItems(result.current.headerButtonsOptions)?.some((item) => item.text === 'workspace.common.markAsExported')).toBe(true); + }); + + // When "Mark as exported" is selected + getExportSubMenuItems(result.current.headerButtonsOptions) + ?.find((item) => item.text === 'workspace.common.markAsExported') + ?.onSelected?.(); + + // Then the query sent leaves that report out, as Pay and Download PDF do, rather than marking it with the rest + await waitFor(() => { + expect(queueBulkMarkAsExported).toHaveBeenCalledWith(expect.stringContaining('-reportID:report-with-unchecked-expense'), CONST.POLICY.CONNECTIONS.NAME.NETSUITE, undefined); + }); + }); + it('passes qboIntegrationAlias so the backend can tell an IES connection apart from a regular QBO connection sharing the same connectionName', async () => { // Given: "Select all" is checked on a workspace connected to QBO with the Intuit Enterprise Suite scope. mockAreAllMatchingItemsSelected = true; diff --git a/tests/unit/hooks/useSearchBulkActionsPayTest.ts b/tests/unit/hooks/useSearchBulkActionsPayTest.ts index 42039125021b..78c7ba8c8e83 100644 --- a/tests/unit/hooks/useSearchBulkActionsPayTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsPayTest.ts @@ -235,6 +235,13 @@ const expenseReportQueryJSON: SearchQueryJSON = { filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: 'type', right: 'expense-report'}, }; +const expenseQueryJSON: SearchQueryJSON = { + ...expenseReportQueryJSON, + inputQuery: 'type:expense status:all', + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: 'type', right: 'expense'}, +}; + function makeSelectedTransaction(overrides: Partial = {}): SelectedTransactions[string] { return { isSelected: true, @@ -394,6 +401,40 @@ describe('useSearchBulkActions - Pay option', () => { expect(serializedQuery).toContain('-reportID:excluded-report'); }); + it('pays every matching report under Select all in an expense search with nothing unchecked', async () => { + // Given every matching expense selected on the Expenses search, none of them unchecked + mockAreAllMatchingItemsSelected = true; + mockBulkPayButtonOptions = [{text: 'Mark as paid', key: CONST.IOU.PAYMENT_TYPE.ELSEWHERE}]; + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseQueryJSON}), {wrapper: OnyxListItemProvider}); + await waitFor(() => { + expect(getPayOptionFromResult(result.current.headerButtonsOptions)).toBeDefined(); + }); + + // When Pay is selected + await act(async () => { + await getPayOptionFromResult(result.current.headerButtonsOptions)?.onSelected?.(); + }); + + // Then the search itself is sent, since it holds exactly what is selected + expect(queueBulkPayReports).toHaveBeenCalledTimes(1); + }); + + it('hides the Pay option under Select all in an expense search once an expense is unchecked, since paying its report would pay it too', async () => { + // Given every matching expense selected on the Expenses search, then one of them unchecked + mockAreAllMatchingItemsSelected = true; + mockExcludedTransactions = {tx2: makeSelectedTransaction({reportID: 'report-with-unchecked-expense'})}; + mockBulkPayButtonOptions = [{text: 'Mark as paid', key: CONST.IOU.PAYMENT_TYPE.ELSEWHERE}]; + + // When the bulk actions are built + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseQueryJSON}), {wrapper: OnyxListItemProvider}); + await waitFor(() => { + expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0); + }); + + // Then Pay is not offered, since it pays whole reports from the search and a report cannot leave out one of its expenses + expect(getPayOptionFromResult(result.current.headerButtonsOptions)).toBeUndefined(); + }); + it('keeps the Pay option under Select all when getPayOption rejects the loaded page', async () => { // Given "Select all" with at least one payable report loaded, but getPayOption rejecting the selection. // getPayOption compares getReportType() across the payable subset, which reads live Onyx and returns diff --git a/tests/unit/hooks/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts index d5c952ef1bbd..9c51216ce173 100644 --- a/tests/unit/hooks/useSearchBulkActionsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsTest.ts @@ -10,7 +10,7 @@ import {exportSearchItemsToCSV, getExportTemplates, queueExportSearchItemsToCSV, import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportAction} from '@src/types/onyx'; +import type {ReportAction, Transaction} from '@src/types/onyx'; import Onyx from 'react-native-onyx'; @@ -128,9 +128,10 @@ jest.mock('@hooks/useConfirmModal', () => ({ default: () => ({showConfirmModal: jest.fn()}), })); +let mockEnabledBetas: string[] = []; jest.mock('@hooks/usePermissions', () => ({ __esModule: true, - default: () => ({isBetaEnabled: () => false, isBetaEnabledOrUnknown: () => false}), + default: () => ({isBetaEnabled: (beta: string) => mockEnabledBetas.includes(beta), isBetaEnabledOrUnknown: (beta: string) => mockEnabledBetas.includes(beta)}), })); jest.mock('@hooks/useSelfDMReport', () => ({ @@ -730,3 +731,185 @@ describe('useSearchBulkActions - CSV export flow', () => { }); }); }); + +describe('useSearchBulkActions - a group checked through its header while its rows load', () => { + const groupKey = `${CONST.SEARCH.GROUP_PREFIX}acme`; + + function buildUnreportedExpense(transactionID: string): Transaction { + return {...createRandomTransaction(0), transactionID, reportID: CONST.REPORT.UNREPORTED_REPORT_ID}; + } + + function checkOnItsOwn(transactionID: string) { + mockSelectedTransactions[transactionID] = makeSelectedTransaction({transaction: buildUnreportedExpense(transactionID), reportID: CONST.REPORT.UNREPORTED_REPORT_ID}); + } + + function checkOnItsOwnWithReceipt(transactionID: string) { + const transaction = {...buildUnreportedExpense(transactionID), receipt: {state: CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE}}; + mockSelectedTransactions[transactionID] = makeSelectedTransaction({transaction, reportID: CONST.REPORT.UNREPORTED_REPORT_ID}); + } + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockIsOffline = false; + mockAreAllMatchingItemsSelected = false; + mockExcludedTransactions = {}; + mockSelectedReports = []; + mockEnabledBetas = [CONST.BETAS.BULK_EDIT]; + mockSelectedTransactions = { + tx1: makeSelectedTransaction({canHold: true, groupKey, isSelectedViaGroup: true, transaction: buildUnreportedExpense('tx1'), reportID: CONST.REPORT.UNREPORTED_REPORT_ID}), + tx2: makeSelectedTransaction({canHold: true, groupKey, isSelectedViaGroup: true, transaction: buildUnreportedExpense('tx2'), reportID: CONST.REPORT.UNREPORTED_REPORT_ID}), + }; + mockGetExportTemplates.mockReturnValue({customTemplates: [], defaultTemplates: []}); + await Onyx.clear(); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: CURRENT_USER_ACCOUNT_ID, email: 'test@example.com'}); + }); + + afterEach(async () => { + mockEnabledBetas = []; + await Onyx.clear(); + }); + + function renderWithGroupCount(count: number) { + const loadedExpenses = Object.fromEntries( + Object.values(mockSelectedTransactions).flatMap(({transaction}) => (transaction ? [[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction]] : [])), + ); + mockCurrentSearchResults = { + search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE}, + data: {...loadedExpenses, [groupKey]: {count, total: 12990, currency: CONST.CURRENCY.USD, merchant: 'Acme'}}, + }; + return renderHook(() => useSearchBulkActions({queryJSON: groupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); + } + + async function getOfferedActions(count: number) { + const {result} = renderWithGroupCount(count); + await waitFor(() => { + expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0); + }); + return result.current.headerButtonsOptions.map((option) => option.value); + } + + it('offers only the actions that cover the whole group, as for a group checked while collapsed', async () => { + // Given two loaded rows of a 692-expense group, both of which could be held, checked through the group's header + + // When the bulk actions are built + const values = await getOfferedActions(692); + + // Then Hold is not offered, since it would hold two expenses under a selection that reads 692, while Export, which covers the group, still is + expect(values).not.toContain(CONST.SEARCH.BULK_ACTION_TYPES.HOLD); + expect(values).toContain(CONST.SEARCH.BULK_ACTION_TYPES.EXPORT); + }); + + it('offers the row actions for a wholly selected group whose rows a refresh kept off the page', async () => { + // Given the group's two rows checked through its header and wholly selected, one of them kept off the page by a refresh that brought back only the first + mockSelectedTransactions = { + tx1: {...mockSelectedTransactions.tx1, isEntireGroupSelected: true}, + tx2: {...mockSelectedTransactions.tx2, isEntireGroupSelected: true, isKeptOffPage: true}, + }; + + // When the bulk actions are built + const values = await getOfferedActions(2); + + // Then Hold is still offered, since the kept row still names its expense; only the count and the total are taken from the server + expect(values).toContain(CONST.SEARCH.BULK_ACTION_TYPES.HOLD); + }); + + it('offers the row actions once every row of the group is loaded', async () => { + // Given the same two rows checked through the header, which are now the whole group + + // When the bulk actions are built + const values = await getOfferedActions(2); + + // Then Hold is offered, since holding the loaded rows holds the whole group + expect(values).toContain(CONST.SEARCH.BULK_ACTION_TYPES.HOLD); + }); + + it('does not offer Edit multiple for expenses checked next to a group that is still loading', async () => { + // Given two expenses checked on their own, which could be edited together, next to a 692-expense group checked through its header + checkOnItsOwn('tx3'); + checkOnItsOwn('tx4'); + + // When the bulk actions are built + const values = await getOfferedActions(692); + + // Then Edit multiple is not offered, since it would edit the two expenses and none of the group's + expect(values).not.toContain(CONST.SEARCH.BULK_ACTION_TYPES.EDIT); + }); + + it('offers Edit multiple for the same selection once every row of the group is loaded', async () => { + // Given the same two expenses next to the group, whose two checked rows are now the whole group + checkOnItsOwn('tx3'); + checkOnItsOwn('tx4'); + + // When the bulk actions are built + const values = await getOfferedActions(2); + + // Then Edit multiple is offered, since it edits every checked expense, the group's included + expect(values).toContain(CONST.SEARCH.BULK_ACTION_TYPES.EDIT); + }); + + it('does not offer Merge for an expense checked next to a group that is still loading', async () => { + // Given one expense checked on its own next to a 692-expense group checked through its header + checkOnItsOwn('tx3'); + + // When the bulk actions are built + const values = await getOfferedActions(692); + + // Then Merge is not offered, since it would merge the one expense as if nothing else were checked + expect(values).not.toContain(CONST.SEARCH.BULK_ACTION_TYPES.MERGE); + }); + + it('does not offer Download receipts for an expense checked next to a group that is still loading', async () => { + // Given one expense with a receipt checked on its own next to a 692-expense group checked through its header + checkOnItsOwnWithReceipt('tx3'); + + // When the bulk actions are built + const values = await getOfferedActions(692); + + // Then Download receipts is not offered, since it would download the one receipt and none of the group's + expect(values).not.toContain(CONST.SEARCH.BULK_ACTION_TYPES.DOWNLOAD_RECEIPTS); + }); + + it('offers Download receipts for the same selection once every row of the group is loaded', async () => { + // Given the same expense next to the group, whose two checked rows are now the whole group + checkOnItsOwnWithReceipt('tx3'); + + // When the bulk actions are built + const values = await getOfferedActions(2); + + // Then Download receipts is offered, since it reads every checked expense, the group's included + expect(values).toContain(CONST.SEARCH.BULK_ACTION_TYPES.DOWNLOAD_RECEIPTS); + }); + + it('offers Move under Select all next to a group that is still loading, since the move is sent as the query', async () => { + // Given every matching expense selected, with two movable rows of one submitter loaded from a 692-expense group checked through its header + mockAreAllMatchingItemsSelected = true; + mockSelectedTransactions = Object.fromEntries( + Object.entries(mockSelectedTransactions).map(([key, entry]) => [key, {...entry, canChangeReport: true, ownerAccountID: CURRENT_USER_ACCOUNT_ID}]), + ); + + // When the bulk actions are built + const values = await getOfferedActions(692); + + // Then Move is offered, since the query it sends reaches the rows not loaded + expect(values).toContain(CONST.SEARCH.BULK_ACTION_TYPES.CHANGE_REPORT); + }); + + it('keeps the selection it acts on the same across renders while a group is still loading, so nothing derived from it is rebuilt', async () => { + // Given two loaded rows of a 692-expense group checked through its header + const {result, rerender} = renderWithGroupCount(692); + await waitFor(() => { + expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0); + }); + const {selectedTransactionReportIDs} = result.current; + + // When the hook renders again with nothing changed + rerender({}); + + // Then what it derives from the selection is the same, since the group entry is not built again + expect(result.current.selectedTransactionReportIDs).toBe(selectedTransactionReportIDs); + }); +});