diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx index fd9d02ca9927..61256fbf8f5f 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx @@ -54,7 +54,7 @@ type MoneyRequestReportTransactionItemProps = { isSelectionModeEnabled: boolean; /** Callback function triggered upon pressing a transaction checkbox. */ - toggleTransaction: (transactionID: string) => void; + toggleTransaction: (transactionID: string, shiftKey?: boolean) => void; /** Callback function triggered upon pressing a transaction. */ handleOnPress: (transactionID: string) => void; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index d71b47309742..495e22914936 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -92,6 +92,7 @@ import MoneyRequestReportTransactionItem from './MoneyRequestReportTransactionIt import MoneyRequestReportTransactionLongPressModal from './MoneyRequestReportTransactionLongPressModal'; import MoneyRequestReportUnifiedList from './MoneyRequestReportUnifiedList'; import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyState'; +import useReportTransactionShiftRange from './useReportTransactionShiftRange'; type TransactionWithOptionalHighlight = OnyxTypes.Transaction & { /** Whether the transaction should be highlighted, when it is added to the report */ @@ -367,19 +368,6 @@ function MoneyRequestReportTransactionList({ useHandleSelectionMode(selectedTransactionIDs); const isMobileSelectionModeEnabled = useMobileSelectionMode(); - const toggleTransaction = useCallback( - (transactionID: string) => { - let newSelectedTransactionIDs = selectedTransactionIDs; - if (selectedTransactionIDs.includes(transactionID)) { - newSelectedTransactionIDs = selectedTransactionIDs.filter((t) => t !== transactionID); - } else { - newSelectedTransactionIDs = [...selectedTransactionIDs, transactionID]; - } - setSelectedTransactions(newSelectedTransactionIDs); - }, - [setSelectedTransactions, selectedTransactionIDs], - ); - const isTransactionSelected = useCallback((transactionID: string) => selectedTransactionIDs.includes(transactionID), [selectedTransactionIDs]); useFocusEffect( @@ -434,9 +422,7 @@ function MoneyRequestReportTransactionList({ useEffect(() => { clearSelectedTransactions(true); - // We don't want to run the effect on change of clearSelectedTransactions since it can cause an infinite loop. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reportID]); + }, [reportID, clearSelectedTransactions]); const [sortConfig, setSortConfig] = useState({ sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, @@ -579,12 +565,23 @@ function MoneyRequestReportTransactionList({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [resolvedTransactions, currentGroupBy, report?.reportID, report?.currency, localeCompare, shouldGroupTransactions]); - const visualOrderTransactionIDs = useMemo(() => { - if (!shouldGroupTransactions || groupedTransactions.length === 0) { - return sortedTransactions.filter((transaction) => !isTransactionPendingDelete(transaction)).map((transaction) => transaction.transactionID); - } - return groupedTransactions.flatMap((group) => group.transactions.filter((transaction) => !isTransactionPendingDelete(transaction)).map((transaction) => transaction.transactionID)); - }, [groupedTransactions, sortedTransactions, shouldGroupTransactions]); + const visualOrderTransactions = useMemo( + () => (shouldGroupTransactions && groupedTransactions.length > 0 ? groupedTransactions.flatMap((group) => group.transactions) : resolvedTransactions), + [groupedTransactions, resolvedTransactions, shouldGroupTransactions], + ); + + const visualOrderTransactionIDs = useMemo( + () => visualOrderTransactions.filter((transaction) => !isTransactionPendingDelete(transaction)).map((transaction) => transaction.transactionID), + [visualOrderTransactions], + ); + + const {toggleTransaction, toggleGroup, toggleAll} = useReportTransactionShiftRange({ + reportID, + transactions: visualOrderTransactions, + selectedTransactionIDs, + setSelectedTransactions, + clearSelectedTransactions, + }); // Primitive proxy for visualOrderTransactionIDs used as the effect dependency below. // Other callers (e.g. TransactionDuplicateReview.onPreviewPressed) can write to the same @@ -642,18 +639,9 @@ function MoneyRequestReportTransactionList({ if (!group) { return; } - const groupTransactionIDs = group.transactions.filter((t) => !isTransactionPendingDelete(t)).map((t) => t.transactionID); - const anySelected = groupTransactionIDs.some((id) => selectedTransactionIDs.includes(id)); - - let newSelectedTransactionIDs = selectedTransactionIDs; - if (anySelected) { - newSelectedTransactionIDs = selectedTransactionIDs.filter((id) => !groupTransactionIDs.includes(id)); - } else { - newSelectedTransactionIDs = [...selectedTransactionIDs, ...groupTransactionIDs]; - } - setSelectedTransactions(newSelectedTransactionIDs); + toggleGroup(group.transactions.filter((t) => !isTransactionPendingDelete(t)).map((t) => t.transactionID)); }, - [groupedTransactions, selectedTransactionIDs, setSelectedTransactions], + [groupedTransactions, toggleGroup], ); /** @@ -911,13 +899,7 @@ function MoneyRequestReportTransactionList({ ]} > { - if (selectedTransactionIDs.length !== 0) { - clearSelectedTransactions(true); - } else { - setSelectedTransactions(transactionsWithoutPendingDelete.map((t) => t.transactionID)); - } - }} + onPress={() => toggleAll(transactionsWithoutPendingDelete.map((t) => t.transactionID))} accessibilityLabel={translate('accessibilityHints.selectAllTransactions')} isIndeterminate={selectedTransactionIDs.length > 0 && selectedTransactionIDs.length !== transactionsWithoutPendingDelete.length} isChecked={selectedTransactionIDs.length > 0 && selectedTransactionIDs.length === transactionsWithoutPendingDelete.length} diff --git a/src/components/MoneyRequestReportView/useReportTransactionShiftRange.ts b/src/components/MoneyRequestReportView/useReportTransactionShiftRange.ts new file mode 100644 index 000000000000..3ff710789839 --- /dev/null +++ b/src/components/MoneyRequestReportView/useReportTransactionShiftRange.ts @@ -0,0 +1,101 @@ +/** + * The report list's selection gestures, so a click writes the selection and moves the range session together. + * Splitting them is how the two drift: a selection the session never saw is one a later shift+click cannot narrow. + */ +import useShiftRangeSelection from '@hooks/useShiftRangeSelection'; + +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; +import {isTransactionPendingDelete} from '@libs/TransactionUtils'; + +import type * as OnyxTypes from '@src/types/onyx'; + +import {useEffect} from 'react'; + +type ReportTransactionShiftRangeParams = { + /** Dropping the session with it, since the list is reused for the next report and a transaction can be on both */ + reportID: string | undefined; + + /** In the order they render, which is the order a range spans */ + transactions: OnyxTypes.Transaction[]; + + selectedTransactionIDs: string[]; + + setSelectedTransactions: (transactionIDs: string[]) => void; + + /** Clearing goes through its own action rather than an empty write, so the hook takes it to own both branches */ + clearSelectedTransactions: (shouldClearIDs: true) => void; +}; + +type ReportTransactionShiftRange = { + /** Extends a range when the click carried Shift, and toggles the one row otherwise */ + toggleTransaction: (transactionID: string, shiftKey?: boolean) => void; + + /** Toggles a whole group, and records it as the block the next shift+click may narrow */ + toggleGroup: (groupTransactionIDs: string[]) => void; + + /** Select All, and the clear that a second press means */ + toggleAll: (selectableTransactionIDs: string[]) => void; +}; + +function useReportTransactionShiftRange({ + reportID, + transactions, + selectedTransactionIDs, + setSelectedTransactions, + clearSelectedTransactions, +}: ReportTransactionShiftRangeParams): ReportTransactionShiftRange { + // The engine asks this per row while resolving an anchor, so the lookup has to be constant time. + const selectedTransactionIDsSet = new Set(selectedTransactionIDs); + const transactionsByID = new Map(transactions.map((transaction) => [transaction.transactionID, transaction])); + + const rangeApi = useShiftRangeSelection({ + items: transactions, + getItemKey: (transaction) => transaction.transactionID ?? null, + isItemSelected: (transaction) => selectedTransactionIDsSet.has(transaction.transactionID), + isDisabledItem: (transaction) => isTransactionPendingDelete(transaction), + onApplyRange: (batch) => setSelectedTransactions(applyShiftRangeBatchToKeySet(batch, selectedTransactionIDs, (transaction) => transaction.transactionID)), + }); + + useEffect(() => { + rangeApi.clearAnchor(); + }, [reportID, rangeApi]); + + const toggleTransaction = (transactionID: string, shiftKey?: boolean) => { + const item = transactionsByID.get(transactionID); + if (item && rangeApi.applyShiftClick(item, shiftKey)) { + return; + } + setSelectedTransactions(selectedTransactionIDsSet.has(transactionID) ? selectedTransactionIDs.filter((id) => id !== transactionID) : [...selectedTransactionIDs, transactionID]); + if (item) { + rangeApi.notifyAnchor(item); + } + }; + + const toggleGroup = (groupTransactionIDs: string[]) => { + const groupTransactionIDSet = new Set(groupTransactionIDs); + const anySelected = groupTransactionIDs.some((id) => selectedTransactionIDsSet.has(id)); + setSelectedTransactions(anySelected ? selectedTransactionIDs.filter((id) => !groupTransactionIDSet.has(id)) : [...selectedTransactionIDs, ...groupTransactionIDs]); + if (anySelected) { + // Deselecting paints no block, so reset instead of leaving a stale span to collapse. + rangeApi.clearAnchor(); + return; + } + // Just this block: seeding the whole selection would span unrelated rows and deselect them. + rangeApi.seedRangeFromSelection(groupTransactionIDs); + }; + + const toggleAll = (selectableTransactionIDs: string[]) => { + if (selectedTransactionIDs.length !== 0) { + clearSelectedTransactions(true); + rangeApi.clearAnchor(); + return; + } + setSelectedTransactions(selectableTransactionIDs); + // A full-list block, so the next shift+click collapses the selection onto the span it lands in. + rangeApi.seedFullRange(); + }; + + return {toggleTransaction, toggleGroup, toggleAll}; +} + +export default useReportTransactionShiftRange; diff --git a/src/components/Search/ExpenseGroupedSearchView.tsx b/src/components/Search/ExpenseGroupedSearchView.tsx index 57db036fc5bd..df2fa14aac38 100644 --- a/src/components/Search/ExpenseGroupedSearchView.tsx +++ b/src/components/Search/ExpenseGroupedSearchView.tsx @@ -21,6 +21,7 @@ import type {SearchListItem} from './SearchList/ListItem/types'; import type {CommonSearchViewProps, TransactionViewExtras} from './searchViewProps'; import type {SearchQueryJSON, SelectedTransactions} from './types'; +import {NO_OPEN_GROUPS} from './hooks/useOpenGroupsRegistry'; import useSearchListViewState from './hooks/useSearchListViewState'; import AnimatedExitRow from './primitives/AnimatedExitRow'; import SelectionTopBar from './primitives/SelectionTopBar'; @@ -29,6 +30,7 @@ import GroupChildrenContainer from './SearchList/ListItem/GroupChildrenContainer import GroupHeader from './SearchList/ListItem/GroupHeader'; import TransactionGroupListItem from './SearchList/ListItem/TransactionGroupListItem'; import {isGroupChildrenContainerItem, isGroupHeaderItem} from './SearchList/ListItem/types'; +import useOpenGroupsForShiftRange from './SearchList/ListItem/useOpenGroupsForShiftRange'; import SearchListViewLayout from './SearchListViewLayout'; type ExpenseGroupedSearchViewProps = CommonSearchViewProps & TransactionViewExtras; @@ -134,6 +136,9 @@ function ExpenseGroupedSearchView({ return next; }); + // Only the split layout renders children as their own rows. + useOpenGroupsForShiftRange(shouldSplit ? expandedGroups : NO_OPEN_GROUPS); + const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); const { diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx index 5413e5218741..702d1085f7a3 100644 --- a/src/components/Search/SearchContext.tsx +++ b/src/components/Search/SearchContext.tsx @@ -8,6 +8,7 @@ import { SearchRowSelectionActionsContext, SearchSelectionActionsContext, SearchSelectionContext, + SearchShiftRangeGroupsContext, } from './SearchContextDefinitions'; // Lightweight public surface for search contexts. @@ -43,6 +44,10 @@ function useSearchRowSelectionActions() { return useContext(SearchRowSelectionActionsContext); } +function useSearchShiftRangeGroups() { + return useContext(SearchShiftRangeGroupsContext); +} + export { SearchQueryContext, SearchQueryActionsContext, @@ -57,4 +62,5 @@ export { useSearchSelectionContext, useSearchSelectionActions, useSearchRowSelectionActions, + useSearchShiftRangeGroups, }; diff --git a/src/components/Search/SearchContextDefinitions.ts b/src/components/Search/SearchContextDefinitions.ts index e723e31dc141..95332d54662b 100644 --- a/src/components/Search/SearchContextDefinitions.ts +++ b/src/components/Search/SearchContextDefinitions.ts @@ -12,6 +12,7 @@ import type { SearchRowSelectionActionsValue, SearchSelectionActionsValue, SearchSelectionContextValue, + SearchShiftRangeGroupsActions, } from './types'; // This file holds the bare React.createContext() calls so they can be imported by `@hooks/useOnyx` @@ -62,6 +63,9 @@ const defaultSearchSelectionContext: SearchSelectionContextValue = { const defaultSearchSelectionActions: SearchSelectionActionsValue = { setSelectedTransactions: () => {}, + getSelectedTransactions: () => defaultSearchSelectionContext.selectedTransactions, + getExcludedTransactions: () => defaultSearchSelectionContext.excludedTransactions, + getAreAllMatchingItemsSelected: () => defaultSearchSelectionContext.areAllMatchingItemsSelected, applySelection: () => {}, setSelectedReports: () => {}, setCurrentSelectedTransactionReportID: () => {}, @@ -75,6 +79,12 @@ const defaultRowSelectionActions: SearchRowSelectionActionsValue = { toggleAll: () => {}, }; +const defaultSearchShiftRangeGroupsActions: SearchShiftRangeGroupsActions = { + addGroupToRange: () => {}, + removeGroupFromRange: () => {}, + registryGeneration: undefined, +}; + const SearchQueryContext = React.createContext(defaultSearchQueryContext); const SearchQueryActionsContext = React.createContext(defaultSearchQueryActions); const SearchResultsContext = React.createContext(defaultSearchResultsContext); @@ -82,6 +92,7 @@ const SearchResultsActionsContext = React.createContext(defaultSearchSelectionContext); const SearchSelectionActionsContext = React.createContext(defaultSearchSelectionActions); const SearchRowSelectionActionsContext = React.createContext(defaultRowSelectionActions); +const SearchShiftRangeGroupsContext = React.createContext(defaultSearchShiftRangeGroupsActions); export { EMPTY_TRANSACTIONS_BY_REPORT_ID, @@ -92,4 +103,5 @@ export { SearchSelectionContext, SearchSelectionActionsContext, SearchRowSelectionActionsContext, + SearchShiftRangeGroupsContext, }; diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index 36bca1806d6f..551d015572cb 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -352,9 +352,12 @@ function ExpenseReportListItemInner({ shouldShowMarkAsDoneCopy, ]); - const handleSelectionButtonPress = useCallback(() => { - onSelectionButtonPress?.(reportItem as unknown as TItem); - }, [onSelectionButtonPress, reportItem]); + const handleSelectionButtonPress = useCallback( + (shiftKey?: boolean) => { + onSelectionButtonPress?.(item, undefined, shiftKey); + }, + [onSelectionButtonPress, item], + ); const listItemPressableStyle = useMemo( () => [ diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx index dcdfece02e35..a3043ed5100a 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx @@ -6,6 +6,8 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getShiftKeyFromEvent} from '@libs/shiftRangeSelection'; + import CONST from '@src/CONST'; import React from 'react'; @@ -24,7 +26,7 @@ function ExpenseReportListItemRowNarrow({item, onCheckboxPress = () => {}, canSe {!!canSelectMultiple && ( onCheckboxPress(getShiftKeyFromEvent(event))} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} containerStyle={styles.m0} diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx index 92d99d60048a..38bed4836344 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx @@ -17,6 +17,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getBase62ReportID from '@libs/getBase62ReportID'; +import {getShiftKeyFromEvent} from '@libs/shiftRangeSelection'; import variables from '@styles/variables'; @@ -302,7 +303,7 @@ function ExpenseReportListItemRowWide({ {!!canSelectMultiple && ( onCheckboxPress(getShiftKeyFromEvent(event))} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} containerStyle={styles.m0} diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/types.ts b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/types.ts index 7fae10630f8c..2ab505146f66 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/types.ts +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/types.ts @@ -9,7 +9,7 @@ import type {OnyxEntry} from 'react-native-onyx'; type ExpenseReportListItemRowNarrowProps = { item: ExpenseReportListItemType; canSelectMultiple?: boolean; - onCheckboxPress?: () => void; + onCheckboxPress?: (shiftKey?: boolean) => void; isSelectAllChecked?: boolean; isIndeterminate?: boolean; isDisabledCheckbox?: boolean; diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx index b68c6466e5e3..6ef917e45d2f 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -375,7 +375,7 @@ function TransactionGroupListExpandedImpl({ shouldUseNarrowLayout={!isLargeScreenWidth} shouldShowCheckbox={!!canSelectMultiple} checkboxSentryLabel={CONST.SENTRY_LABEL.SEARCH.EXPANDED_TRANSACTION_ROW_CHECKBOX} - onCheckboxPress={() => onSelectionButtonPress?.(transaction as ListItem)} + onCheckboxPress={(_transactionID, shiftKey) => onSelectionButtonPress?.(transaction as ListItem, undefined, shiftKey)} columns={currentColumns} onButtonPress={(event) => handleButtonPress(transaction, event)} style={[styles.noBorderRadius, isLargeScreenWidth ? [styles.p3, styles.pv2, styles.tableRowHeight] : styles.p4, styles.flex1]} diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 0b68a51cd5de..16f71b36b6d3 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -66,6 +66,7 @@ import ReportListItemHeader from './ReportListItemHeader'; import TagListItemHeader from './TagListItemHeader'; import TransactionGroupListExpandedItem from './TransactionGroupListExpanded'; import useGroupChildren from './useGroupChildren'; +import useGroupOpenForShiftRange from './useGroupOpenForShiftRange'; import useLiveRowCapabilities from './useLiveRowCapabilities'; import WeekListItemHeader from './WeekListItemHeader'; import WithdrawalIDListItemHeader from './WithdrawalIDListItemHeader'; @@ -140,6 +141,10 @@ function TransactionGroupListItemImpl({ const [transactionsVisibleLimit, setTransactionsVisibleLimit] = useState(CONST.TRANSACTION.RESULTS_PAGE_SIZE as number); const [isExpanded, setIsExpanded] = useState(false); + + // Expense-report rows are already part of the list, so only group-by views need this. + useGroupOpenForShiftRange(groupItem.keyForList, isExpanded && !isExpenseReportType); + const {transactions, isSelectAllChecked, isIndeterminate} = useGroupChildren({ groupKey: groupItem.keyForList, groupTransactions: groupItem.transactions, @@ -278,8 +283,9 @@ function TransactionGroupListItemImpl({ onLongPressRow?.(transaction as ListItem); }; - const handleSelectionButtonPress = (val: ListItem) => { - onSelectionButtonPress?.(val, isExpenseReportType ? undefined : transactions); + // Group headers never send shiftKey, so only an expanded child row can start a range from here. + const handleSelectionButtonPress = (val: ListItem, _itemTransactions?: TransactionListItemType[], shiftKey?: boolean) => { + onSelectionButtonPress?.(val, isExpenseReportType ? undefined : transactions, shiftKey); }; const onExpandIconPress = () => { diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx index d1fbd1e9ed51..8fc8bda51d49 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx @@ -152,7 +152,7 @@ function TransactionListItemNarrow({ policy={transactionItem.policy} shouldShowTooltip={showTooltip} onButtonPress={handleActionButtonPress} - onCheckboxPress={() => onCheckboxPress?.(item)} + onCheckboxPress={(_transactionID, shiftKey) => onCheckboxPress?.(item, undefined, shiftKey)} shouldUseNarrowLayout isLargeScreenWidth={false} columns={columns} diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx index 188488030802..6852fee34b6e 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx @@ -186,7 +186,7 @@ function TransactionListItemWide({ policyTagLists={policyTagLists} shouldShowTooltip={showTooltip} onButtonPress={handleActionButtonPress} - onCheckboxPress={() => onCheckboxPress?.(item)} + onCheckboxPress={(_transactionID, shiftKey) => onCheckboxPress?.(item, undefined, shiftKey)} shouldUseNarrowLayout={false} shouldUseFullHeightEditableCellHoverTarget shouldSkipDeferRBR diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts b/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts index 57e823273cb4..d61874fb5136 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts @@ -1,3 +1,4 @@ +import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; import type {SearchColumnType} from '@components/Search/types'; import type {ListItemFocusEventHandler} from '@components/SelectionList/ListItem/types'; import type {ListItem} from '@components/SelectionList/types'; @@ -15,7 +16,7 @@ type TransactionListItemSharedProps = { isDisabled?: boolean | null; canSelectMultiple?: boolean; onSelectRow: (item: TItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; - onCheckboxPress?: (item: TItem) => void; + onCheckboxPress?: (item: TItem, itemTransactions?: TransactionListItemType[], shiftKey?: boolean) => void; onFocus?: ListItemFocusEventHandler; onLongPressRow?: (item: TItem) => void; shouldSyncFocus?: boolean; diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index cb350c9c2b18..eeb5fb99ad75 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -562,7 +562,7 @@ type GroupChildrenContentProps = { columns?: SearchColumnType[]; canSelectMultiple: boolean; onSelectRow: (item: SearchListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; - onCheckboxPress: (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => void; + onCheckboxPress: (item: SearchListItem, itemTransactions?: TransactionListItemType[], shiftKey?: boolean) => void; onLongPressRow?: (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => void; nonPersonalAndWorkspaceCards?: CardList; onUndelete?: (transaction: Transaction) => void; diff --git a/src/components/Search/SearchList/ListItem/useGroupOpenForShiftRange.ts b/src/components/Search/SearchList/ListItem/useGroupOpenForShiftRange.ts new file mode 100644 index 000000000000..1f7a2ac38c41 --- /dev/null +++ b/src/components/Search/SearchList/ListItem/useGroupOpenForShiftRange.ts @@ -0,0 +1,18 @@ +import {useSearchShiftRangeGroups} from '@components/Search/SearchContext'; + +import {useEffect} from 'react'; + +/** For a row that owns its own expanded state, so the group closes with it. */ +function useGroupOpenForShiftRange(groupKey: string, isOpen: boolean) { + const {addGroupToRange, removeGroupFromRange, registryGeneration} = useSearchShiftRangeGroups(); + // `registryGeneration` is a dependency and nothing else: the registry drops openness with the search, and this puts it back. + useEffect(() => { + if (!isOpen) { + return; + } + addGroupToRange(groupKey); + return () => removeGroupFromRange(groupKey); + }, [groupKey, isOpen, addGroupToRange, removeGroupFromRange, registryGeneration]); +} + +export default useGroupOpenForShiftRange; diff --git a/src/components/Search/SearchList/ListItem/useOpenGroupsForShiftRange.ts b/src/components/Search/SearchList/ListItem/useOpenGroupsForShiftRange.ts new file mode 100644 index 000000000000..7d8d5004a53b --- /dev/null +++ b/src/components/Search/SearchList/ListItem/useOpenGroupsForShiftRange.ts @@ -0,0 +1,43 @@ +import {NO_OPEN_GROUPS} from '@components/Search/hooks/useOpenGroupsRegistry'; +import {useSearchShiftRangeGroups} from '@components/Search/SearchContext'; + +import {useEffect, useRef} from 'react'; + +/** For a view that owns the expanded state on behalf of rows it may recycle. */ +function useOpenGroupsForShiftRange(openGroupKeys: ReadonlySet) { + const {addGroupToRange, removeGroupFromRange, registryGeneration} = useSearchShiftRangeGroups(); + + // The keys this hook opened, so expanding one group does not close and reopen every other one. + const openedKeysRef = useRef>(NO_OPEN_GROUPS); + const seenGenerationRef = useRef(registryGeneration); + + useEffect(() => { + // Anything opened under the previous search is already gone from the registry, so the diff below reopens it. + const opened = seenGenerationRef.current === registryGeneration ? openedKeysRef.current : NO_OPEN_GROUPS; + seenGenerationRef.current = registryGeneration; + + for (const key of opened) { + if (!openGroupKeys.has(key)) { + removeGroupFromRange(key); + } + } + for (const key of openGroupKeys) { + if (!opened.has(key)) { + addGroupToRange(key); + } + } + openedKeysRef.current = openGroupKeys; + }, [openGroupKeys, addGroupToRange, removeGroupFromRange, registryGeneration]); + + useEffect( + () => () => { + for (const key of openedKeysRef.current) { + removeGroupFromRange(key); + } + openedKeysRef.current = NO_OPEN_GROUPS; + }, + [removeGroupFromRange], + ); +} + +export default useOpenGroupsForShiftRange; diff --git a/src/components/Search/SearchSelectionProvider.tsx b/src/components/Search/SearchSelectionProvider.tsx index 6e2126122870..648df11f521d 100644 --- a/src/components/Search/SearchSelectionProvider.tsx +++ b/src/components/Search/SearchSelectionProvider.tsx @@ -1,7 +1,7 @@ import CONST from '@src/CONST'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useLayoutEffect, useRef, useState} from 'react'; import type {SearchData, SearchSelectionActionsValue, SearchSelectionContextValue, SelectedReports, SelectedTransactions} from './types'; @@ -38,21 +38,53 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { const {currentSearchHash, currentSearchQueryJSON} = useSearchQueryContext(); const isExpenseSearch = currentSearchQueryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE; - const areTransactionsEmpty = useRef(true); const [selectionState, setSelectionState] = useState(defaultSelectionState); - const currentSearchHashRef = useRef(currentSearchHash); - useEffect(() => { - currentSearchHashRef.current = currentSearchHash; - }, [currentSearchHash]); + const [{actions: selectionActionsValue, sync}] = useState(() => createSelectionActions(setSelectionState, currentSearchHash)); + + // Synced as one object, so a handler cannot read two slices of the selection at different freshness. + useLayoutEffect(() => { + sync(selectionState, currentSearchHash); + }); + + const hasSelectedTransactions = + (isExpenseSearch && selectionState.areAllMatchingItemsSelected) || + selectionState.selectedTransactionIDs.length > 0 || + Object.values(selectionState.selectedTransactions).some((t) => t.isSelected); + + const selectionValue: SearchSelectionContextValue = { + ...selectionState, + hasSelectedTransactions, + }; + + return ( + + {children} + + ); +} + +type SelectionActions = { + /** The context value, stable for the provider's lifetime */ + actions: SearchSelectionActionsValue; + + /** Pushes the latest render's values in, from the provider's layout effect */ + sync: (selectionState: SelectionState, currentSearchHash: number) => void; +}; + +/** Built once per provider, so a consumer may list any of these in an effect's dependencies. */ +function createSelectionActions(setSelectionState: React.Dispatch>, initialSearchHash: number): SelectionActions { + let latestSelectionState = defaultSelectionState; + // Seeded, since a child's layout effect runs before the sync below and may already clear against this hash. + let latestSearchHash = initialSearchHash; + let isTransactionIDListEmpty = true; const setSelectedTransactions: SearchSelectionActionsValue['setSelectedTransactions'] = (transactionIDs, data) => { if (transactionIDs instanceof Array) { - if (!transactionIDs.length && areTransactionsEmpty.current) { - areTransactionsEmpty.current = true; + if (!transactionIDs.length && isTransactionIDListEmpty) { return; } - areTransactionsEmpty.current = false; + isTransactionIDListEmpty = false; setSelectionState((prevState) => ({ ...prevState, selectedTransactionIDs: transactionIDs, @@ -172,7 +204,7 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { return; } - if (searchHashOrClearIDsFlag === currentSearchHashRef.current) { + if (searchHashOrClearIDsFlag === latestSearchHash) { return; } @@ -234,31 +266,24 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { }); }; - const hasSelectedTransactions = - (isExpenseSearch && selectionState.areAllMatchingItemsSelected) || - selectionState.selectedTransactionIDs.length > 0 || - Object.values(selectionState.selectedTransactions).some((t) => t.isSelected); - - const selectionValue: SearchSelectionContextValue = { - ...selectionState, - hasSelectedTransactions, - }; - - const selectionActionsValue: SearchSelectionActionsValue = { - setSelectedTransactions, - applySelection, - setSelectedReports, - setCurrentSelectedTransactionReportID, - clearSelectedTransactions, - removeTransaction, - selectAllMatchingItems, + return { + actions: { + setSelectedTransactions, + applySelection, + getSelectedTransactions: () => latestSelectionState.selectedTransactions, + getExcludedTransactions: () => latestSelectionState.excludedTransactions, + getAreAllMatchingItemsSelected: () => latestSelectionState.areAllMatchingItemsSelected, + setSelectedReports, + setCurrentSelectedTransactionReportID, + clearSelectedTransactions, + removeTransaction, + selectAllMatchingItems, + }, + sync: (selectionState, currentSearchHash) => { + latestSelectionState = selectionState; + latestSearchHash = currentSearchHash; + }, }; - - return ( - - {children} - - ); } /** diff --git a/src/components/Search/SearchWriteActionsProvider.tsx b/src/components/Search/SearchWriteActionsProvider.tsx index e8bd76549847..cab571b3370a 100644 --- a/src/components/Search/SearchWriteActionsProvider.tsx +++ b/src/components/Search/SearchWriteActionsProvider.tsx @@ -3,10 +3,21 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSelfDMReport from '@hooks/useSelfDMReport'; +import useShiftRangeSelection from '@hooks/useShiftRangeSelection'; import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {canRejectReportAction} from '@libs/ReportUtils'; -import {isGroupedItemArray, isReportActionListItemType, isTaskListItemType, isTransactionListItemType} from '@libs/SearchUIUtils'; +import { + isGroupedItemArray, + isReportActionListItemType, + isReportEntry, + isTaskListItemType, + isTransactionEntry, + isTransactionGroupListItemType, + isTransactionListItemType, + isTransactionReportGroupListItemType, +} from '@libs/SearchUIUtils'; +import type {ShiftRangeBatch} from '@libs/shiftRangeSelection'; import {isTransactionPendingDelete} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; @@ -19,25 +30,33 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useIsFocused} from '@react-navigation/native'; import {deepEqual} from 'fast-equals'; -import React, {useEffect} from 'react'; +import React, {useEffect, useLayoutEffect, useRef} from 'react'; +import type {SearchListItem, TransactionListItemType} from './SearchList/ListItem/types'; import type {SearchData, SearchRowSelectionActionsValue, SelectedTransactionInfo, SelectedTransactions} from './types'; +import useOpenGroupsRegistry from './hooks/useOpenGroupsRegistry'; import {useSearchSelectionActions, useSearchSelectionContext} from './SearchContext'; -import {SearchRowSelectionActionsContext} from './SearchContextDefinitions'; +import {SearchRowSelectionActionsContext, SearchShiftRangeGroupsContext} from './SearchContextDefinitions'; import {useSyncSelectedReports} from './SearchSelectionProvider'; -import {mapEmptyReportToSelectedEntry, mapTransactionItemToSelectedEntry, prepareTransactionsList} from './selectionBuilders'; +import {buildShiftRangeSource, isGroupSelected, isRowChecked, mapEmptyReportToSelectedEntry, mapTransactionItemToSelectedEntry, prepareTransactionsList} from './selectionBuilders'; type SearchWriteActionsProviderProps = { /** The currently displayed (filtered, grouped) rows. Screen-derived; the provider cannot recompute it. */ filteredData: SearchData; + /** As rendered, so a range spans on-screen order rather than the pre-sort `filteredData` */ + renderedData: SearchListItem[]; + /** Keeps "select all matching" in lock-step: select-all unchecks once the selection no longer covers every item. */ totalSelectableItemsCount: number; /** The raw search snapshot, read for denormalized transaction/report lookups. */ searchResults: SearchResults | undefined; + /** Everything scoped to one search is keyed on it */ + searchHash: number; + /** The live TRANSACTION collection, subscribed by `` and passed down. */ transactions: OnyxCollection; @@ -136,6 +155,7 @@ function useReconcileSelectionWithData({ } const newTransactionList: SelectedTransactions = {}; const liveSelectionEntries = new Map(); + const presentGroupKeys = new Set(); if (areItemsGrouped) { for (const transactionGroup of filteredData) { if (!Object.hasOwn(transactionGroup, 'transactions') || !('transactions' in transactionGroup)) { @@ -143,6 +163,10 @@ function useReconcileSelectionWithData({ } const reportKey = transactionGroup.keyForList; + // Only groups carrying no rows: a row missing from a group that has them is gone for real. + if (reportKey && !isExpenseReportType && transactionGroup.transactions.length === 0 && transactionGroup.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + presentGroupKeys.add(reportKey); + } if (shouldReconcileExcludedTransactions && reportKey && transactionGroup.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { const [, groupSelection] = mapEmptyReportToSelectedEntry(transactionGroup); liveSelectionEntries.set(reportKey, groupSelection); @@ -175,8 +199,8 @@ function useReconcileSelectionWithData({ for (const transactionItem of transactionGroup.transactions) { const listKey = transactionItem.keyForList ?? transactionItem.transactionID; const isDirectlyExcluded = Object.hasOwn(excludedTransactions, listKey) || Object.hasOwn(excludedTransactions, transactionItem.transactionID); - const isExcluded = isParentGroupExcluded || isDirectlyExcluded; const isSelected = listKey in selectedTransactions || transactionItem.transactionID in selectedTransactions; + const isExcluded = !isSelected && (isParentGroupExcluded || isDirectlyExcluded); // 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); @@ -192,7 +216,6 @@ function useReconcileSelectionWithData({ const itemParentReport = searchResultsData?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.report?.parentReportID}`] as OnyxEntry; const previousSelection = selectedTransactions[listKey] ?? selectedTransactions[transactionItem.transactionID]; - // The overrides below are what reconcile computes differently from a toggle — keep them. const [, baseEntry] = mapTransactionItemToSelectedEntry({ item: transactionItem, itemTransaction, @@ -264,6 +287,23 @@ function useReconcileSelectionWithData({ } } + // A lazy group's children never reach `filteredData`, so the group's presence is what keeps them. + if (areItemsGrouped) { + for (const [key, selectedTransaction] of Object.entries(selectedTransactions)) { + const parentGroupKey = selectedTransaction.groupKey; + if ( + !parentGroupKey || + Object.hasOwn(newTransactionList, key) || + Object.hasOwn(excludedTransactions, key) || + Object.hasOwn(excludedTransactions, parentGroupKey) || + !presentGroupKeys.has(parentGroupKey) + ) { + continue; + } + newTransactionList[key] = liveSelectionEntries.get(key) ?? selectedTransaction; + } + } + let reconciledExcludedTransactions = excludedTransactions; if (shouldReconcileExcludedTransactions && areAllMatchingItemsSelected && !isEmptyObject(excludedTransactions)) { const nextExcludedTransactions: SelectedTransactions = {}; @@ -279,8 +319,7 @@ function useReconcileSelectionWithData({ continue; } - // Lazy group children are held in a separate snapshot. Keep their exclusions while the parent - // group is still present; if the parent disappears, the child no longer matches this search. + // A lazy group's children live in a separate snapshot, so the parent's presence is what proves they still match. if (excludedTransaction.groupKey && liveSelectionEntries.has(excludedTransaction.groupKey)) { nextExcludedTransactions[key] = excludedTransaction; } @@ -378,8 +417,10 @@ function useSyncMobileSelectionModeWithScreenSize({ // `selectedTransactions`, so dispatching one re-renders neither this provider's stable children nor the rows. function SearchWriteActionsProvider({ filteredData, + renderedData, totalSelectableItemsCount, searchResults, + searchHash, transactions, isMobileSelectionModeEnabled, type, @@ -394,145 +435,389 @@ function SearchWriteActionsProvider({ const selfDMReport = useSelfDMReport(); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); - const {applySelection} = useSearchSelectionActions(); + const {applySelection, getSelectedTransactions, getExcludedTransactions, getAreAllMatchingItemsSelected} = useSearchSelectionActions(); + + const {openGroupKeys, shiftRangeGroupsActions} = useOpenGroupsRegistry(searchHash); + + // Read at the gesture: closing over it would give every row a new `toggle` each time a group opens. + const groupKeyByChildKeyRef = useRef>(new Map()); + const childrenByGroupKeyRef = useRef>(new Map()); const searchResultsData = searchResults?.data; const currentUserEmail = email ?? ''; const currentUserLogin = login ?? ''; - const toggle: SearchRowSelectionActionsValue['toggle'] = (item, itemTransactions) => { - if (isReportActionListItemType(item) || isTaskListItemType(item)) { - return; + // 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; + }; - if (isTransactionListItemType(item)) { - if (!item.keyForList || isTransactionPendingDelete(item)) { - return; - } - applySelection( - (selectedTransactions) => { - const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${item.transactionID}`] as OnyxEntry; - const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - const itemParentReport = searchResultsData?.[`${ONYXKEYS.COLLECTION.REPORT}${item.report?.parentReportID}`] as OnyxEntry; - const updatedTransactions = prepareTransactionsList({ - item, - itemTransaction, - originalItemTransaction, - selectedTransactions, - currentUserLogin: currentUserEmail, - currentUserAccountID: accountID, - reportNameValuePairs, - outstandingReportsByPolicyID, - selfDMReport, - parentReport: itemParentReport, - }); + 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); + return mapTransactionItemToSelectedEntry({ + item, + itemTransaction, + originalItemTransaction, + currentUserLogin: currentUserEmail, + currentUserAccountID: accountID, + reportNameValuePairs, + outstandingReportsByPolicyID, + selfDMReport, + allowNegativeAmount: true, + parentReport, + }); + }; + + const commitOptions = { + totalSelectableItemsCount, + shouldPreserveAllMatchingSelection: type === CONST.SEARCH.DATA_TYPES.EXPENSE, + shouldClearAllMatchingSelectionWhenEmpty: isOffline || searchResults?.search?.hasMoreResults === false, + }; - if (areItemsGrouped && isGroupedItemArray(filteredData)) { - const parentGroup = filteredData.find((group) => group.transactions.some((transaction) => transaction.keyForList === item.keyForList)); - const groupKey = selectedTransactions[item.keyForList]?.groupKey ?? parentGroup?.keyForList; - // Toggling one expense makes this group a partial selection, so export the remaining expenses individually. - if (groupKey) { - for (const [key, transaction] of Object.entries(updatedTransactions)) { - if (transaction.groupKey === groupKey && transaction.isSelectedViaGroup) { - updatedTransactions[key] = {...transaction, isSelectedViaGroup: false}; - } + // Expense-report rows are the selectable unit, so only group-by rows are headers whose children flatten in. + const hasValidGroupBy = areItemsGrouped && !isExpenseReportType; + const {items: flattenedShiftRangeItems, childrenByGroupKey, groupKeyByChildKey} = buildShiftRangeSource(renderedData, openGroupKeys, hasValidGroupBy); + useLayoutEffect(() => { + groupKeyByChildKeyRef.current = groupKeyByChildKey; + childrenByGroupKeyRef.current = childrenByGroupKey; + }, [groupKeyByChildKey, childrenByGroupKey]); + const isShiftRangeHeaderItem = (item: SearchData[number]) => isTransactionGroupListItemType(item) && hasValidGroupBy; + + // Undefined under select-all-matching, where the group is selected without its rows being known. + const resolveGroupBlock = (selection: SelectedTransactions, childKey: string) => { + const groupKey = groupKeyByChildKeyRef.current.get(childKey); + if (!groupKey || getAreAllMatchingItemsSelected() || !selection[groupKey]?.isSelected) { + return undefined; + } + return {groupKey, loaded: childrenByGroupKeyRef.current.get(groupKey) ?? []}; + }; + + // A group selected before its children loaded lives under its own key, so dropping one child means writing it out first. + const spellOutGroupSelection = (selection: SelectedTransactions, childKey: string): SelectedTransactions => { + const block = resolveGroupBlock(selection, childKey); + // Counted the same way the loop writes, so writing out can never delete the entry and put nothing back. + const selectable = block?.loaded.filter((child) => !isTransactionPendingDelete(child)) ?? []; + if (!block || selectable.length === 0) { + return selection; + } + const {groupKey} = block; + const spelledOut: SelectedTransactions = {...selection}; + delete spelledOut[groupKey]; + for (const child of selectable) { + const [key, info] = buildSelectedEntry(child); + // No `isSelectedViaGroup`: the caller is about to drop one of these, so the group stops being a whole-group selection. + spelledOut[key] = {...info, groupKey}; + } + return spelledOut; + }; + + // Defaults to the refs, so asking whether a group is checked never re-renders this provider. + const groupSelectionParams = (groupKey: string | undefined, groupChildren: TransactionListItemType[], selectedTransactions = getSelectedTransactions()) => ({ + groupKey, + children: groupChildren, + selectedTransactions, + excludedTransactions: getExcludedTransactions(), + areAllMatchingItemsSelected: getAreAllMatchingItemsSelected(), + }); + + const applyShiftRangeBatch = (batch: ShiftRangeBatch) => { + applySelection( + (selectedTransactions) => { + let updated: SelectedTransactions = {...selectedTransactions}; + // Returning the given map unchanged is what lets the commit bail on identity rather than re-render every row. + let hasWritten = false; + // Whole wins over partial, since that is the gesture a header click makes. + const partialGroupKeys = new Set(); + const wholeGroupKeys = new Set(); + const dropKey = (key: string) => { + if (!Object.hasOwn(updated, key)) { + return; + } + delete updated[key]; + hasWritten = true; + }; + // Set only when a whole group row joins the range, which is what makes its children narrowable later. + const addTransaction = (tx: TransactionListItemType, blockGroupKey: string | undefined) => { + if (!tx.keyForList || isTransactionPendingDelete(tx)) { + return; + } + updated = spellOutGroupSelection(updated, tx.keyForList); + const [key, info] = buildSelectedEntry(tx); + const parentGroupKey = blockGroupKey ?? groupKeyByChildKeyRef.current.get(tx.keyForList); + if (parentGroupKey) { + (blockGroupKey ? wholeGroupKeys : partialGroupKeys).add(parentGroupKey); + } + const entry = parentGroupKey ? {...info, groupKey: parentGroupKey, isSelectedViaGroup: !!blockGroupKey} : info; + // Extending a range re-covers rows it already holds, so an equal entry must not count as a write. + if (deepEqual(updated[key], entry)) { + return; + } + updated[key] = entry; + hasWritten = true; + }; + const removeRow = (row: SearchData[number]) => { + if (isTransactionListItemType(row) || (isTransactionReportGroupListItemType(row) && row.transactions.length === 0)) { + if (row.keyForList) { + const parentGroupKey = groupKeyByChildKeyRef.current.get(row.keyForList); + if (parentGroupKey) { + partialGroupKeys.add(parentGroupKey); } + updated = spellOutGroupSelection(updated, row.keyForList); + dropKey(row.keyForList); } - // If the clicked expense is still selected, keep its parent group key. - if (groupKey && updatedTransactions[item.keyForList]) { - updatedTransactions[item.keyForList] = {...updatedTransactions[item.keyForList], groupKey}; + return; + } + if (isTransactionGroupListItemType(row)) { + // A group can hold an entry under its own key as well as under its children's. + if (row.keyForList) { + dropKey(row.keyForList); + } + for (const child of row.transactions ?? []) { + if (child.keyForList) { + dropKey(child.keyForList); + } + } + } + }; + const addRow = (row: SearchData[number]) => { + if (isTransactionListItemType(row)) { + addTransaction(row, undefined); + } else if (isTransactionReportGroupListItemType(row) && row.transactions.length === 0) { + if (row.keyForList && row.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + const [key, info] = mapEmptyReportToSelectedEntry(row); + if (!deepEqual(updated[key], info)) { + updated[key] = info; + hasWritten = true; + } + } + } else if (isTransactionGroupListItemType(row)) { + const selectable = (row.transactions ?? []).filter((child) => !isTransactionPendingDelete(child)); + if (selectable.length === 0) { + return; } + // The children carry the selection from here, so the group's own key would count it twice. + if (row.keyForList) { + dropKey(row.keyForList); + } + for (const child of selectable) { + addTransaction(child, row.keyForList); + } + } + }; + for (const row of batch.toDeselect) { + removeRow(row); + } + for (const row of batch.toSelect) { + addRow(row); + } + // Rows left behind must stop claiming the group covers them, or an export sends a whole-group filter. + for (const [key, transaction] of Object.entries(updated)) { + if (transaction.isSelectedViaGroup && transaction.groupKey && partialGroupKeys.has(transaction.groupKey) && !wholeGroupKeys.has(transaction.groupKey)) { + updated[key] = {...transaction, isSelectedViaGroup: false}; + hasWritten = true; } + } + return hasWritten ? updated : selectedTransactions; + }, + {...commitOptions, data: filteredData}, + ); + }; - return updatedTransactions; - }, - { - totalSelectableItemsCount, - shouldPreserveAllMatchingSelection: type === CONST.SEARCH.DATA_TYPES.EXPENSE, - shouldClearAllMatchingSelectionWhenEmpty: isOffline || searchResults?.search?.hasMoreResults === false, - }, + // The same predicate the checkbox renders from, so a range reaches exactly the rows the user sees checked. + const isRowVisiblyChecked = (item: SearchData[number]) => { + const selectedTransactions = getSelectedTransactions(); + const excludedTransactions = getExcludedTransactions(); + const areAllMatchingItemsSelected = getAreAllMatchingItemsSelected(); + if (isTransactionGroupListItemType(item) && item.transactions.length > 0) { + return item.transactions.some((transaction) => + isRowChecked({rowKey: transaction.keyForList, parentGroupKey: item.keyForList, selectedTransactions, excludedTransactions, areAllMatchingItemsSelected}), ); - return; } + if (!item.keyForList) { + return false; + } + return isRowChecked({ + rowKey: item.keyForList, + parentGroupKey: groupKeyByChildKeyRef.current.get(item.keyForList), + selectedTransactions, + excludedTransactions, + areAllMatchingItemsSelected, + }); + }; - const currentTransactions = itemTransactions ?? item.transactions; + // A row checked through a group header belongs to that block, so a range may take it back. + const isRowHandPicked = (item: SearchData[number]) => { + const selectedTransactions = getSelectedTransactions(); + // A report row is the row the user clicked, so any selected child makes it hand-picked. + if (isTransactionGroupListItemType(item) && item.transactions.length > 0) { + return item.transactions.some((transaction) => selectedTransactions[transaction.keyForList]?.isSelected); + } + const entry = item.keyForList ? selectedTransactions[item.keyForList] : undefined; + return !!entry?.isSelected && !entry.isSelectedViaGroup; + }; - applySelection( - (selectedTransactions) => { - if (currentTransactions.length === 0 && item.keyForList) { - const reportKey = item.keyForList; + const rangeApi = useShiftRangeSelection({ + items: flattenedShiftRangeItems, + getItemKey: (item) => item.keyForList, + isItemSelected: isRowVisiblyChecked, + isItemProtected: isRowHandPicked, + isDisabledItem: (item) => (isTransactionListItemType(item) ? isTransactionPendingDelete(item) : item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), + onApplyRange: applyShiftRangeBatch, + isHeaderItem: isShiftRangeHeaderItem, + }); - if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - return selectedTransactions; - } + // The session belongs to one search, since a row can match both queries. + useEffect(() => { + rangeApi.clearAnchor(); + }, [searchHash, rangeApi]); - if (selectedTransactions[reportKey]?.isSelected) { - const reducedSelectedTransactions: SelectedTransactions = {...selectedTransactions}; - delete reducedSelectedTransactions[reportKey]; - return reducedSelectedTransactions; - } + const seedGroup = (groupKey: string) => rangeApi.seedRangeFromSelection((childKey) => groupKeyByChildKeyRef.current.get(childKey) === groupKey); - const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(item); - return {...selectedTransactions, [reportKey]: emptyReportSelection}; - } + const toggle: SearchRowSelectionActionsValue['toggle'] = (item, itemTransactions, shiftKey) => { + if (isReportActionListItemType(item) || isTaskListItemType(item)) { + return; + } - // 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; - const isGroupKeySelected = !!(groupKey && selectedTransactions[groupKey]?.isSelected); + // The hook rejects headers as range targets, so shift+click on one falls through to the group toggle. + if (rangeApi.applyShiftClick(item, shiftKey)) { + return; + } - if (isGroupKeySelected || currentTransactions.some((transaction) => selectedTransactions[transaction.keyForList]?.isSelected)) { - const reducedSelectedTransactions: SelectedTransactions = {...selectedTransactions}; + // One children source for the seed and the selection, so a group can't seed a different block than it selects. + const groupTransactions = isTransactionGroupListItemType(item) ? (itemTransactions ?? item.transactions ?? []) : []; + + if (isTransactionGroupListItemType(item) && isShiftRangeHeaderItem(item)) { + if (isGroupSelected(groupSelectionParams(item.keyForList, groupTransactions))) { + // Deselecting paints no block, so reset instead of leaving a stale span to collapse. + rangeApi.clearAnchor(); + } else if (groupTransactions.length === 0 || groupTransactions.some((transactionItem) => !isTransactionPendingDelete(transactionItem))) { + // Just this block: seeding the whole selection would span unrelated rows and deselect them. + seedGroup(item.keyForList); + } + } else if (!isShiftRangeHeaderItem(item)) { + // Seed the anchor so a later shift+click continues from here. The hook ignores rows a range can't reach. + rangeApi.notifyAnchor(item); + } + + if (isTransactionListItemType(item)) { + if (!item.keyForList || isTransactionPendingDelete(item)) { + return; + } + applySelection((selectedTransactions) => { + const {itemTransaction, originalItemTransaction, parentReport: itemParentReport} = resolveTransactionRefs(item); + const baseSelection = spellOutGroupSelection(selectedTransactions, item.keyForList); + const updatedTransactions = prepareTransactionsList({ + item, + itemTransaction, + originalItemTransaction, + selectedTransactions: baseSelection, + currentUserLogin: currentUserEmail, + currentUserAccountID: accountID, + reportNameValuePairs, + outstandingReportsByPolicyID, + selfDMReport, + parentReport: itemParentReport, + }); + + if (areItemsGrouped && isGroupedItemArray(filteredData)) { + const groupKey = + baseSelection[item.keyForList]?.groupKey ?? + groupKeyByChildKeyRef.current.get(item.keyForList) ?? + filteredData.find((group) => group.transactions.some((transaction) => transaction.keyForList === item.keyForList))?.keyForList; + // Toggling one expense makes this group a partial selection, so export the remaining expenses individually. if (groupKey) { - delete reducedSelectedTransactions[groupKey]; + for (const [key, transaction] of Object.entries(updatedTransactions)) { + if (transaction.groupKey === groupKey && transaction.isSelectedViaGroup) { + updatedTransactions[key] = {...transaction, isSelectedViaGroup: false}; + } + } } - for (const transaction of currentTransactions) { - delete reducedSelectedTransactions[transaction.keyForList]; + if (groupKey && updatedTransactions[item.keyForList]) { + updatedTransactions[item.keyForList] = {...updatedTransactions[item.keyForList], groupKey}; } + } + + return updatedTransactions; + }, commitOptions); + return; + } + + applySelection((selectedTransactions) => { + if (groupTransactions.length === 0 && item.keyForList) { + const reportKey = item.keyForList; + + if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return selectedTransactions; + } + + if (selectedTransactions[reportKey]?.isSelected) { + const reducedSelectedTransactions: SelectedTransactions = {...selectedTransactions}; + delete reducedSelectedTransactions[reportKey]; return reducedSelectedTransactions; } - return { - ...selectedTransactions, - ...Object.fromEntries( - currentTransactions - .filter((t) => !isTransactionPendingDelete(t)) - .map((transactionItem) => { - 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 [key, entry] = mapTransactionItemToSelectedEntry({ - item: transactionItem, - itemTransaction, - originalItemTransaction, - currentUserLogin: currentUserEmail, - currentUserAccountID: accountID, - reportNameValuePairs, - outstandingReportsByPolicyID, - selfDMReport, - allowNegativeAmount: true, - parentReport: itemParentReport, - }); - return [key, {...entry, groupKey: item.keyForList, isSelectedViaGroup: !!item.keyForList}]; - }), - ), - }; - }, - { - totalSelectableItemsCount, - shouldPreserveAllMatchingSelection: type === CONST.SEARCH.DATA_TYPES.EXPENSE, - shouldClearAllMatchingSelectionWhenEmpty: isOffline || searchResults?.search?.hasMoreResults === false, - }, - ); + const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(item); + return {...selectedTransactions, [reportKey]: emptyReportSelection}; + } + + // 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; + + if (isGroupSelected(groupSelectionParams(groupKey, groupTransactions, selectedTransactions))) { + const reducedSelectedTransactions: SelectedTransactions = {...selectedTransactions}; + if (groupKey) { + delete reducedSelectedTransactions[groupKey]; + } + for (const transaction of groupTransactions) { + delete reducedSelectedTransactions[transaction.keyForList]; + } + return reducedSelectedTransactions; + } + + const selectableTransactions = groupTransactions.filter((transactionItem) => !isTransactionPendingDelete(transactionItem)); + // Same map, not an equal one: the commit bails on identity, so a group with nothing to select must not re-render every row. + if (selectableTransactions.length === 0) { + return selectedTransactions; + } + return { + ...selectedTransactions, + ...Object.fromEntries( + selectableTransactions.map((transactionItem) => { + const [key, entry] = buildSelectedEntry(transactionItem); + return [key, {...entry, groupKey: item.keyForList, isSelectedViaGroup: !!item.keyForList}]; + }), + ), + }; + }, commitOptions); }; const toggleAll: SearchRowSelectionActionsValue['toggleAll'] = () => { + // Read once, so the session and the selection cannot act on two different answers. + const isClearing = Object.keys(getSelectedTransactions()).length > 0; + if (isClearing) { + rangeApi.clearAnchor(); + } else { + rangeApi.seedFullRange(); + } applySelection( - (selectedTransactions) => { - if (Object.keys(selectedTransactions).length > 0) { + () => { + if (isClearing) { return {}; } @@ -549,21 +834,7 @@ function SearchWriteActionsProvider({ if (isTransactionPendingDelete(transactionItem)) { continue; } - const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; - const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - const itemParentReport = searchResultsData?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.report?.parentReportID}`] as OnyxEntry; - const [key, entry] = mapTransactionItemToSelectedEntry({ - item: transactionItem, - itemTransaction, - originalItemTransaction, - currentUserLogin: currentUserEmail, - currentUserAccountID: accountID, - reportNameValuePairs, - outstandingReportsByPolicyID, - selfDMReport, - allowNegativeAmount: true, - parentReport: itemParentReport, - }); + const [key, entry] = buildSelectedEntry(transactionItem); entries.push([key, {...entry, groupKey: item.keyForList, isSelectedViaGroup: !!item.keyForList}]); } return entries; @@ -580,23 +851,7 @@ function SearchWriteActionsProvider({ if (isTransactionPendingDelete(transactionItem)) { 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; - entries.push( - mapTransactionItemToSelectedEntry({ - item: transactionItem, - itemTransaction, - originalItemTransaction, - currentUserLogin: currentUserEmail, - currentUserAccountID: accountID, - reportNameValuePairs, - outstandingReportsByPolicyID, - selfDMReport, - allowNegativeAmount: true, - parentReport: itemParentReport, - }), - ); + entries.push(buildSelectedEntry(transactionItem)); } return Object.fromEntries(entries); }, @@ -625,7 +880,11 @@ function SearchWriteActionsProvider({ const rowSelectionActionsValue: SearchRowSelectionActionsValue = {toggle, toggleAll}; - return {children}; + return ( + + {children} + + ); } export default SearchWriteActionsProvider; diff --git a/src/components/Search/hooks/useOpenGroupsRegistry.ts b/src/components/Search/hooks/useOpenGroupsRegistry.ts new file mode 100644 index 000000000000..4cfe6562c2db --- /dev/null +++ b/src/components/Search/hooks/useOpenGroupsRegistry.ts @@ -0,0 +1,55 @@ +/** + * Which groups a shift+click range may reach into. Whoever owns a group's expanded state owns that answer, which is + * the one thing the rows cannot tell the provider. Scoped to one search, so a group left open across a query change + * cannot range over the previous results. + */ +import type {SearchShiftRangeGroupsActions} from '@components/Search/types'; + +import {useState} from 'react'; + +const NO_OPEN_GROUPS: ReadonlySet = new Set(); + +type OpenGroupsRegistry = { + /** The groups currently rendering their children as rows */ + openGroupKeys: ReadonlySet; + + shiftRangeGroupsActions: SearchShiftRangeGroupsActions; +}; + +function useOpenGroupsRegistry(searchHash: number): OpenGroupsRegistry { + const [openGroupKeys, setOpenGroupKeys] = useState>(NO_OPEN_GROUPS); + + const [registryHash, setRegistryHash] = useState(searchHash); + if (registryHash !== searchHash) { + setRegistryHash(searchHash); + setOpenGroupKeys(NO_OPEN_GROUPS); + } + + // Built once (by construction, not by React Compiler) so the subscribing effects can't loop. + const [methods] = useState>(() => ({ + addGroupToRange: (groupKey) => + setOpenGroupKeys((prev) => { + if (prev.has(groupKey)) { + return prev; + } + const next = new Set(prev); + next.add(groupKey); + return next; + }), + removeGroupFromRange: (groupKey) => + setOpenGroupKeys((prev) => { + if (!prev.has(groupKey)) { + return prev; + } + const next = new Set(prev); + next.delete(groupKey); + return next; + }), + })); + + // Only the container changes when the registry is dropped. The methods keep their identity, so subscribers stay put. + return {openGroupKeys, shiftRangeGroupsActions: {...methods, registryGeneration: registryHash}}; +} + +export default useOpenGroupsRegistry; +export {NO_OPEN_GROUPS}; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 13e67b10632b..3a55ca559a77 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1223,8 +1223,10 @@ function Search({ !isTransactionPendingDelete(child)); + if (children.length > 0 && selectable.length === 0) { + return false; + } + if (groupKey && isRowChecked({rowKey: groupKey, parentGroupKey: undefined, selectedTransactions, excludedTransactions, areAllMatchingItemsSelected})) { + return true; + } + return selectable.some((child) => isRowChecked({rowKey: child.keyForList, parentGroupKey: groupKey, selectedTransactions, excludedTransactions, areAllMatchingItemsSelected})); +} + /** 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): { isSelectAllChecked: boolean; @@ -373,4 +385,54 @@ function isRowChecked({rowKey, parentGroupKey, selectedTransactions, excludedTra return areAllMatchingItemsSelected || !!(parentGroupKey && selectedTransactions[parentGroupKey]?.isSelected); } -export {mapTransactionItemToSelectedEntry, mapEmptyReportToSelectedEntry, prepareTransactionsList, deriveSelectedReports, getGroupCheckboxState, isRowChecked}; +/** Openness is the gate, not the rows: a closed group still carries the ones it loaded. */ +function resolveGroupChildren(group: TransactionGroupListItemType, openGroupKeys: ReadonlySet): TransactionListItemType[] { + return openGroupKeys.has(group.keyForList) ? group.transactions : []; +} + +type ShiftRangeSource = { + /** Each group header followed by the rows it carries, in visual order */ + items: SearchListItem[]; + + childrenByGroupKey: Map; + + /** So a child's selection is stored and removed under the right parent */ + groupKeyByChildKey: Map; +}; + +/** One pass, so what a range spans and who owns each row cannot disagree. Flattens only in group-by views. */ +function buildShiftRangeSource(sortedData: SearchListItem[], openGroupKeys: ReadonlySet, groupsAreHeaders: boolean): ShiftRangeSource { + const childrenByGroupKey = new Map(); + const groupKeyByChildKey = new Map(); + if (!groupsAreHeaders || !isGroupedItemArray(sortedData)) { + return {items: sortedData, childrenByGroupKey, groupKeyByChildKey}; + } + + const items: SearchListItem[] = []; + for (const group of sortedData) { + items.push(group); + if (!group.keyForList) { + continue; + } + const children = resolveGroupChildren(group, openGroupKeys); + childrenByGroupKey.set(group.keyForList, children); + for (const child of children) { + items.push(child); + if (child.keyForList) { + groupKeyByChildKey.set(child.keyForList, group.keyForList); + } + } + } + return {items, childrenByGroupKey, groupKeyByChildKey}; +} + +export { + mapTransactionItemToSelectedEntry, + mapEmptyReportToSelectedEntry, + prepareTransactionsList, + deriveSelectedReports, + buildShiftRangeSource, + isGroupSelected, + getGroupCheckboxState, + isRowChecked, +}; diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index a255ff30f41a..22ddd0ca68ff 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -256,6 +256,10 @@ type SearchSelectionActionsValue = { reconciledExcludedTransactions?: SelectedTransactions; }, ) => void; + /** Read on demand without subscribing, so a handler can anchor from the live selection without re-rendering every row */ + getSelectedTransactions: () => SelectedTransactions; + getExcludedTransactions: () => SelectedTransactions; + getAreAllMatchingItemsSelected: () => boolean; setSelectedReports: (reports: SelectedReports[]) => void; setCurrentSelectedTransactionReportID: (reportID: string | undefined) => void; /** If you want to clear `selectedTransactionIDs`, pass `true` as the first argument */ @@ -277,12 +281,20 @@ type SearchData = TransactionListItemType[] | TransactionGroupListItemType[] | R * never re-renders consumers that only need to dispatch. */ type SearchRowSelectionActionsValue = { - /** Toggle selection of a single transaction row or a group (report / grouped rows). */ - toggle: (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => void; + /** Toggle selection of a single transaction row or a group (report / grouped rows). `shiftKey` extends a range. */ + toggle: (item: SearchListItem, itemTransactions?: TransactionListItemType[], shiftKey?: boolean) => void; /** Toggle selection of all currently selectable items. */ toggleAll: () => void; }; +/** Lets whoever owns a group's expanded state say whether a shift+click range may reach the rows it renders. */ +type SearchShiftRangeGroupsActions = { + addGroupToRange: (groupKey: string) => void; + removeGroupFromRange: (groupKey: string) => void; + /** Changes when the registry is dropped for a new search, so a group left open across the change reopens */ + registryGeneration: number | undefined; +}; + /** Composed value of all three Search state contexts. Kept as a union for callers that need the full bag shape (e.g. test fixtures, action `searchContext` payloads). */ type SearchStateContextValue = SearchQueryContextValue & SearchResultsContextValue & SearchSelectionContextValue; @@ -504,6 +516,7 @@ export type { SearchSelectionActionsValue, SearchData, SearchRowSelectionActionsValue, + SearchShiftRangeGroupsActions, ASTNode, QueryFilter, Filter, diff --git a/src/components/SelectionList/ListItem/types.ts b/src/components/SelectionList/ListItem/types.ts index 06ebc43889da..6523586a3122 100644 --- a/src/components/SelectionList/ListItem/types.ts +++ b/src/components/SelectionList/ListItem/types.ts @@ -257,7 +257,7 @@ type ListItemProps = CommonListItemProps & { item: TItem; /** Callback to fire when the selection button is pressed */ - onSelectionButtonPress?: (item: TItem, itemTransactions?: TransactionListItemType[]) => void; + onSelectionButtonPress?: (item: TItem, itemTransactions?: TransactionListItemType[], shiftKey?: boolean) => void; /** Which side of the row to render the selection button on */ selectionButtonPosition?: ValueOf; @@ -388,7 +388,7 @@ type SpendRuleListItemType = ListItem & { */ type SelectableListItemProps = BaseListItemProps & { /** Callback to fire when the selection button is pressed */ - onSelectionButtonPress?: (item: TItem, itemTransactions?: TransactionListItemType[]) => void; + onSelectionButtonPress?: (item: TItem, itemTransactions?: TransactionListItemType[], shiftKey?: boolean) => void; /** Which side of the row to render the selection button on */ selectionButtonPosition?: ValueOf; diff --git a/src/hooks/useShiftRangeSelection.ts b/src/hooks/useShiftRangeSelection.ts index 7dfc25085c7c..47626697ce4b 100644 --- a/src/hooks/useShiftRangeSelection.ts +++ b/src/hooks/useShiftRangeSelection.ts @@ -1,39 +1,57 @@ import type {ShiftRangeBatch} from '@libs/shiftRangeSelection'; -import {useEffect, useRef, useState} from 'react'; +import {useLayoutEffect, useRef, useState} from 'react'; type Params = { + /** In the order they appear on screen, which is the order a range spans */ items: TItem[]; - // Keys must be unique within items; a null/undefined key keeps the row out of ranges. + + /** Unique within `items`. A null key keeps the row out of ranges */ getItemKey: (item: TItem) => string | null | undefined; + isItemSelected: (item: TItem) => boolean; + + /** Rows a range must never deselect. Defaults to `isItemSelected` */ + isItemProtected?: (item: TItem) => boolean; + + /** Never part of a range, and never anchors one */ isHeaderItem?: (item: TItem) => boolean; + + /** Excluded from ranges, anchors and targets */ isDisabledItem?: (item: TItem) => boolean; + onApplyRange?: (batch: ShiftRangeBatch) => void; }; type Api = { + /** Returns whether it handled the click */ applyShiftClick: (item: TItem, shiftKey?: boolean) => boolean; + notifyAnchor: (item: TItem) => void; - // No caller in this PR — the Search provider's group toggles consume it in the follow-up. - seedRangeFromSelection: (selectedKeys: ReadonlySet | readonly string[]) => void; + + /** Lets a selection made elsewhere be narrowed by the next shift+click. Pass a test where the rows may still be loading */ + seedRangeFromSelection: (members: ReadonlySet | readonly string[] | ((key: string) => boolean)) => void; + seedFullRange: () => void; - // No caller in this PR — the Search provider's clear-all consumes it in the follow-up. + clearAnchor: () => void; }; -// Shift+click always selects — a deselect mode left Shift looking dead. painted: keys the session selected, tracked by key so re-sorts and removals can't misattribute the collapse. -// Selected rows the session didn't paint are protected from collapse (derived per click, see protectedKeys); a seeded session paints its whole span, so Select All still collapses. -type SessionState = {kind: 'idle'} | {kind: 'anchored'; anchor: string} | {kind: 'ranging'; anchor: string; painted: ReadonlySet}; +/** `painted` is held by key, so reordering the list cannot confuse what shrinking a range gives back. */ +type ResolvedSession = {kind: 'idle'} | {kind: 'anchored'; anchor: string} | {kind: 'ranging'; anchor: string; painted: ReadonlySet}; + +/** A seeded block resolves at the next shift+click, so rows that had not loaded when it was seeded still join. */ +type SessionState = ResolvedSession | {kind: 'seeded'; isMember: (key: string) => boolean}; + +const IDLE: ResolvedSession = {kind: 'idle'}; -const IDLE: SessionState = {kind: 'idle'}; const NO_KEYS: ReadonlySet = new Set(); /** Shift+click range selection. Consumers notify on plain clicks / select-all so the hook can resolve an anchor for the next shift+click. */ function useShiftRangeSelection(params: Params): Api { - // The api methods are built once but read the latest params through this ref, refreshed after every commit. + // Refreshed during the commit, so a click in the same frame as a re-render sees the current rows. const paramsRef = useRef(params); - useEffect(() => { + useLayoutEffect(() => { paramsRef.current = params; }); @@ -57,21 +75,30 @@ function useShiftRangeSelection(params: Params): Api { return true; }, notifyAnchor: (item) => { - const key = keyOf(paramsRef.current, item); - if (key == null) { + const currentParams = paramsRef.current; + const key = keyOf(currentParams, item); + // Keeping the last reachable anchor beats storing one that sends the next shift+click to the top of the list. + if (key == null || !canAnchor(currentParams, key)) { return; } sessionRef.current = {kind: 'anchored', anchor: key}; }, - seedRangeFromSelection: (selectedKeys) => { - // Keys are passed in because the caller's selection is optimistic — reading it here would see the pre-toggle set. - const currentParams = paramsRef.current; - const set = selectedKeys instanceof Set ? selectedKeys : new Set(selectedKeys); - sessionRef.current = seedRangeState(currentParams, (key) => set.has(key)); + seedRangeFromSelection: (members) => { + // Recorded, not resolved: the rows may not be in the list yet. + if (typeof members === 'function') { + sessionRef.current = {kind: 'seeded', isMember: members}; + return; + } + const set = members instanceof Set ? members : new Set(members); + // An empty block replaces nothing, so the session it would have replaced is still the truth. + if (set.size === 0) { + return; + } + sessionRef.current = {kind: 'seeded', isMember: (key) => set.has(key)}; }, seedFullRange: () => { // After Select All: seed a full-list range so the next shift+click collapses the selection to the clicked sub-range. - sessionRef.current = seedRangeState(paramsRef.current, () => true); + sessionRef.current = {kind: 'seeded', isMember: () => true}; }, clearAnchor: () => { sessionRef.current = IDLE; @@ -99,8 +126,7 @@ function buildKeyIndex(params: Params): Map { return keyToIndex; } -/** Builds a `ranging` session anchored at the first selectable item passing `isIncluded` and painting every passing key, or `IDLE` when none qualify. */ -function seedRangeState(params: Params, isIncluded: (key: string) => boolean): SessionState { +function seedRangeState(params: Params, isIncluded: (key: string) => boolean): ResolvedSession | null { let anchor: string | null = null; const painted = new Set(); for (const item of params.items) { @@ -117,15 +143,35 @@ function seedRangeState(params: Params, isIncluded: (key: string) if (anchor !== null) { return {kind: 'ranging', anchor, painted}; } - return IDLE; + return null; +} + +/** Rows selected without being picked on their own came from a block, which a range may narrow. */ +function adoptUnprotectedBlock(params: Params): ReadonlySet { + const keys = new Set(); + const isProtected = params.isItemProtected ?? params.isItemSelected; + for (const row of params.items) { + if (isExcluded(params, row)) { + continue; + } + const key = keyOf(params, row); + if (key != null && params.isItemSelected(row) && !isProtected(row)) { + keys.add(key); + } + } + return keys; } /** Selected keys the session didn't paint — derived fresh each click so protection tracks the live selection; the session never deselects these. */ function protectedKeys(params: Params, painted: ReadonlySet): ReadonlySet { const keys = new Set(); + const isProtected = params.isItemProtected ?? params.isItemSelected; for (const row of params.items) { + if (isExcluded(params, row)) { + continue; + } const key = keyOf(params, row); - if (key != null && !painted.has(key) && params.isItemSelected(row)) { + if (key != null && !painted.has(key) && isProtected(row)) { keys.add(key); } } @@ -140,15 +186,25 @@ function computeShiftRange(params: Params, state: SessionState, ta const keyToIndex = buildKeyIndex(params); - const seed = state.kind === 'idle' ? null : state.anchor; + // With none of a seeded block on screen there is nothing to narrow, so the click starts a range where it landed. + const resolved: ResolvedSession = state.kind === 'seeded' ? (seedRangeState(params, state.isMember) ?? {kind: 'anchored', anchor: targetKey}) : state; + + const seed = resolved.kind === 'idle' ? null : resolved.anchor; const anchor = resolveAnchor(params, keyToIndex, seed); if (anchor == null) { return null; } // The session survives only while the same anchor does; a re-resolved or cold anchor starts fresh. - const sameAnchor = state.kind !== 'idle' && anchor === state.anchor; - const continuing = state.kind === 'ranging' && sameAnchor; - const prevPainted: ReadonlySet = continuing ? state.painted : NO_KEYS; + const sameAnchor = resolved.kind !== 'idle' && anchor === resolved.anchor; + const continuing = resolved.kind === 'ranging' && sameAnchor; + let prevPainted: ReadonlySet; + if (continuing) { + prevPainted = resolved.painted; + } else if (sameAnchor) { + prevPainted = NO_KEYS; + } else { + prevPainted = adoptUnprotectedBlock(params); + } const preSelected = protectedKeys(params, prevPainted); const anchorIdx = keyToIndex.get(anchor); @@ -222,6 +278,11 @@ function keyOf(params: Params, item: TItem | null | undefined): st return params.getItemKey(item) ?? null; } +/** Matched by key, since callers pass clones. */ +function canAnchor(params: Params, key: string): boolean { + return params.items.some((row) => keyOf(params, row) === key && !isExcluded(params, row)); +} + function isExcluded(params: Params, item: TItem | null | undefined): boolean { if (item == null) { return true; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 72bbf98004cd..f6537d2f06ae 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -6981,6 +6981,8 @@ export { isTransactionQuarterGroupListItemType, isGroupedItemArray, isGroupEntry, + isReportEntry, + isTransactionEntry, isSearchResultsEmpty, isTransactionListItemType, isReportActionListItemType, diff --git a/tests/ui/CategoryListItemHeaderTest.tsx b/tests/ui/CategoryListItemHeaderTest.tsx index 35b4915bc71a..7baebfa8e3f4 100644 --- a/tests/ui/CategoryListItemHeaderTest.tsx +++ b/tests/ui/CategoryListItemHeaderTest.tsx @@ -56,6 +56,9 @@ const mockSearchActionsContext = { setLastSearchType: jest.fn(), setCurrentSelectedTransactionReportID: jest.fn(), setSelectedTransactions: jest.fn(), + getSelectedTransactions: jest.fn(() => ({})), + getExcludedTransactions: () => ({}), + getAreAllMatchingItemsSelected: () => false, applySelection: jest.fn(), setSelectedReports: jest.fn(), removeTransaction: jest.fn(), diff --git a/tests/ui/GroupHeaderTest.tsx b/tests/ui/GroupHeaderTest.tsx index c2a13e804f98..56435149abbc 100644 --- a/tests/ui/GroupHeaderTest.tsx +++ b/tests/ui/GroupHeaderTest.tsx @@ -84,6 +84,9 @@ const baseActions = { setLastSearchType: jest.fn(), setCurrentSelectedTransactionReportID: jest.fn(), setSelectedTransactions: jest.fn(), + getSelectedTransactions: jest.fn(() => ({})), + getExcludedTransactions: () => ({}), + getAreAllMatchingItemsSelected: () => false, applySelection: jest.fn(), setSelectedReports: jest.fn(), removeTransaction: jest.fn(), diff --git a/tests/ui/MerchantListItemHeaderTest.tsx b/tests/ui/MerchantListItemHeaderTest.tsx index 7e350f87812c..93768e5f4d88 100644 --- a/tests/ui/MerchantListItemHeaderTest.tsx +++ b/tests/ui/MerchantListItemHeaderTest.tsx @@ -56,6 +56,9 @@ const mockSearchActionsContext = { setLastSearchType: jest.fn(), setCurrentSelectedTransactionReportID: jest.fn(), setSelectedTransactions: jest.fn(), + getSelectedTransactions: jest.fn(() => ({})), + getExcludedTransactions: () => ({}), + getAreAllMatchingItemsSelected: () => false, applySelection: jest.fn(), setSelectedReports: jest.fn(), removeTransaction: jest.fn(), diff --git a/tests/ui/MonthListItemHeaderTest.tsx b/tests/ui/MonthListItemHeaderTest.tsx index 922d2ba79f46..1474f7226845 100644 --- a/tests/ui/MonthListItemHeaderTest.tsx +++ b/tests/ui/MonthListItemHeaderTest.tsx @@ -56,6 +56,9 @@ const mockSearchActionsContext = { setLastSearchType: jest.fn(), setCurrentSelectedTransactionReportID: jest.fn(), setSelectedTransactions: jest.fn(), + getSelectedTransactions: jest.fn(() => ({})), + getExcludedTransactions: () => ({}), + getAreAllMatchingItemsSelected: () => false, applySelection: jest.fn(), setSelectedReports: jest.fn(), removeTransaction: jest.fn(), diff --git a/tests/ui/ReportListItemHeaderTest.tsx b/tests/ui/ReportListItemHeaderTest.tsx index a18354ba2524..887e85956333 100644 --- a/tests/ui/ReportListItemHeaderTest.tsx +++ b/tests/ui/ReportListItemHeaderTest.tsx @@ -62,6 +62,9 @@ const mockSearchActionsContext = { setLastSearchType: jest.fn(), setCurrentSelectedTransactionReportID: jest.fn(), setSelectedTransactions: jest.fn(), + getSelectedTransactions: jest.fn(() => ({})), + getExcludedTransactions: () => ({}), + getAreAllMatchingItemsSelected: () => false, applySelection: jest.fn(), setSelectedReports: jest.fn(), setShouldShowFiltersBarLoading: jest.fn(), diff --git a/tests/ui/WeekListItemHeaderTest.tsx b/tests/ui/WeekListItemHeaderTest.tsx index 23f78427d678..d521877a3993 100644 --- a/tests/ui/WeekListItemHeaderTest.tsx +++ b/tests/ui/WeekListItemHeaderTest.tsx @@ -55,6 +55,9 @@ const mockSearchActionsContext = { setLastSearchType: jest.fn(), setCurrentSelectedTransactionReportID: jest.fn(), setSelectedTransactions: jest.fn(), + getSelectedTransactions: jest.fn(() => ({})), + getExcludedTransactions: () => ({}), + getAreAllMatchingItemsSelected: () => false, applySelection: jest.fn(), setSelectedReports: jest.fn(), removeTransaction: jest.fn(), diff --git a/tests/ui/YearListItemHeaderTest.tsx b/tests/ui/YearListItemHeaderTest.tsx index b4860545a3e4..fbc4b682aa41 100644 --- a/tests/ui/YearListItemHeaderTest.tsx +++ b/tests/ui/YearListItemHeaderTest.tsx @@ -56,6 +56,9 @@ const mockSearchActionsContext = { setLastSearchType: jest.fn(), setCurrentSelectedTransactionReportID: jest.fn(), setSelectedTransactions: jest.fn(), + getSelectedTransactions: jest.fn(() => ({})), + getExcludedTransactions: () => ({}), + getAreAllMatchingItemsSelected: () => false, applySelection: jest.fn(), setSelectedReports: jest.fn(), removeTransaction: jest.fn(), diff --git a/tests/unit/MoneyRequestReportShiftRangeTest.tsx b/tests/unit/MoneyRequestReportShiftRangeTest.tsx new file mode 100644 index 000000000000..9e2d5584ae7c --- /dev/null +++ b/tests/unit/MoneyRequestReportShiftRangeTest.tsx @@ -0,0 +1,171 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useReportTransactionShiftRange from '@components/MoneyRequestReportView/useReportTransactionShiftRange'; + +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +import createRandomTransaction from '../utils/collections/transaction'; + +const REPORT_ID = '777'; + +function buildTransaction(transactionID: string, overrides: Partial = {}): OnyxTypes.Transaction { + return {...createRandomTransaction(Number(transactionID)), transactionID, reportID: REPORT_ID, ...overrides}; +} + +const rows = [buildTransaction('1'), buildTransaction('2'), buildTransaction('3'), buildTransaction('4')]; + +/** Drives the hook the way the list does, holding the selection the component reads from context. */ +function renderShiftRange(initialTransactions: OnyxTypes.Transaction[] = rows) { + const state = {selectedTransactionIDs: [] as string[], transactions: initialTransactions, reportID: REPORT_ID}; + const setSelectedTransactions = jest.fn((transactionIDs: string[]) => { + state.selectedTransactionIDs = transactionIDs; + }); + const clearSelectedTransactions = jest.fn(() => { + state.selectedTransactionIDs = []; + }); + + const rendered = renderHook(() => + useReportTransactionShiftRange({ + reportID: state.reportID, + transactions: state.transactions, + selectedTransactionIDs: state.selectedTransactionIDs, + setSelectedTransactions, + clearSelectedTransactions, + }), + ); + + /** The hook reads the selection from its params, so a commit has to be handed back before the next gesture. */ + const settle = () => rendered.rerender({}); + + return {...rendered, state, settle, setSelectedTransactions, clearSelectedTransactions}; +} + +describe('MoneyRequestReport shift+click', () => { + it('selects the rows between the clicked one and the last one clicked plainly', () => { + const {result, state, settle} = renderShiftRange(); + + act(() => result.current.toggleTransaction('2')); + settle(); + act(() => result.current.toggleTransaction('4', true)); + + expect(state.selectedTransactionIDs).toEqual(['2', '3', '4']); + }); + + it('gives back the rows a shrinking range no longer covers', () => { + const {result, state, settle} = renderShiftRange(); + + act(() => result.current.toggleTransaction('1')); + settle(); + act(() => result.current.toggleTransaction('4', true)); + settle(); + act(() => result.current.toggleTransaction('2', true)); + + expect(state.selectedTransactionIDs).toEqual(['1', '2']); + }); + + it('leaves a row being deleted out of the range it spans', () => { + const withDeleted = [rows.at(0), buildTransaction('2', {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}), rows.at(2)].filter((row) => !!row); + const {result, state, settle} = renderShiftRange(withDeleted); + + act(() => result.current.toggleTransaction('1')); + settle(); + act(() => result.current.toggleTransaction('3', true)); + + expect(state.selectedTransactionIDs).toEqual(['1', '3']); + }); + + it('runs a cold shift+click from the top of the list, since nothing is selected for it to anchor on', () => { + const {result, state} = renderShiftRange(); + + act(() => result.current.toggleTransaction('3', true)); + + expect(state.selectedTransactionIDs).toEqual(['1', '2', '3']); + }); + + it('toggles one row off without disturbing the rest', () => { + const {result, state, settle} = renderShiftRange(); + + act(() => result.current.toggleTransaction('1')); + settle(); + act(() => result.current.toggleTransaction('3', true)); + settle(); + act(() => result.current.toggleTransaction('2')); + + expect(state.selectedTransactionIDs).toEqual(['1', '3']); + }); + + it('narrows a group selected from its header, since the header records it as a block', () => { + const {result, state, settle} = renderShiftRange(); + + act(() => result.current.toggleGroup(['1', '2', '3'])); + settle(); + expect(state.selectedTransactionIDs).toEqual(['1', '2', '3']); + + act(() => result.current.toggleTransaction('2', true)); + + expect(state.selectedTransactionIDs).toEqual(['1', '2']); + }); + + it('drops the block when a group is deselected, so a later shift+click cannot collapse onto it', () => { + const {result, state, settle} = renderShiftRange(); + + act(() => result.current.toggleGroup(['1', '2', '3'])); + settle(); + act(() => result.current.toggleGroup(['1', '2', '3'])); + settle(); + expect(state.selectedTransactionIDs).toEqual([]); + + // Cold again, so the click runs from the top rather than collapsing the block the group had seeded + act(() => result.current.toggleTransaction('3', true)); + + expect(state.selectedTransactionIDs).toEqual(['1', '2', '3']); + }); + + it('collapses a Select All onto the span the next shift+click lands in', () => { + const {result, state, settle} = renderShiftRange(); + + act(() => result.current.toggleAll(['1', '2', '3', '4'])); + settle(); + expect(state.selectedTransactionIDs).toEqual(['1', '2', '3', '4']); + + act(() => result.current.toggleTransaction('2', true)); + + expect(state.selectedTransactionIDs).toEqual(['1', '2']); + }); + + it('clears through the clearing action rather than an empty write, and forgets the session with it', () => { + const {result, state, settle, clearSelectedTransactions} = renderShiftRange(); + + act(() => result.current.toggleAll(['1', '2', '3', '4'])); + settle(); + act(() => result.current.toggleAll(['1', '2', '3', '4'])); + settle(); + expect(clearSelectedTransactions).toHaveBeenCalledWith(true); + + // The full-list block went with it, so the next click runs from the top rather than collapsing onto itself + act(() => result.current.toggleTransaction('3', true)); + + expect(state.selectedTransactionIDs).toEqual(['1', '2', '3']); + }); + + it('forgets the session when the list is reused for the next report, so a range cannot shrink across the change', () => { + const {result, state, settle, rerender} = renderShiftRange(); + + // Given a range painted across every row, which is what a shrink would give back + act(() => result.current.toggleTransaction('1')); + settle(); + act(() => result.current.toggleTransaction('4', true)); + settle(); + expect(state.selectedTransactionIDs).toEqual(['1', '2', '3', '4']); + + // When the list is handed the next report + state.reportID = '888'; + rerender({}); + + // Then the click that would have shrunk that range keeps every row, since the session it would shrink is gone + act(() => result.current.toggleTransaction('2', true)); + + expect(state.selectedTransactionIDs).toEqual(['1', '2', '3', '4']); + }); +}); diff --git a/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx b/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx index 7d9cea2dface..485e680560d4 100644 --- a/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx +++ b/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx @@ -113,7 +113,8 @@ const mockToggleAll = jest.fn(); const mockSelectedTransactions: {current: Record} = {current: {}}; jest.mock('@components/Search/SearchContext', () => ({ useSearchRowSelectionActions: () => ({toggle: mockToggle, toggleAll: mockToggleAll}), - useSearchSelectionContext: () => ({selectedTransactions: mockSelectedTransactions.current}), + useSearchSelectionContext: () => ({selectedTransactions: mockSelectedTransactions.current, excludedTransactions: {}, areAllMatchingItemsSelected: false}), + useSearchShiftRangeGroups: () => ({addGroupToRange: jest.fn(), removeGroupFromRange: jest.fn()}), })); function selectKeys(...keys: string[]): Record { diff --git a/tests/unit/Search/LazyGroupSelectionTest.tsx b/tests/unit/Search/LazyGroupSelectionTest.tsx index f0f47c33d71b..5edce3a853d2 100644 --- a/tests/unit/Search/LazyGroupSelectionTest.tsx +++ b/tests/unit/Search/LazyGroupSelectionTest.tsx @@ -1,9 +1,10 @@ import {act, renderHook} from '@testing-library/react-native'; -import {useSearchRowSelectionActions, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; +import {useSearchRowSelectionActions, useSearchSelectionActions, useSearchSelectionContext, useSearchShiftRangeGroups} from '@components/Search/SearchContext'; import {SearchContextProvider} from '@components/Search/SearchContextProvider'; -import type {TransactionCategoryGroupListItemType, TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; +import type {TransactionGroupListItemType, TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; import SearchWriteActionsProvider from '@components/Search/SearchWriteActionsProvider'; +import {isRowChecked, mapEmptyReportToSelectedEntry} from '@components/Search/selectionBuilders'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; @@ -16,6 +17,7 @@ import type * as ReactNavigation from '@react-navigation/native'; import React from 'react'; import Onyx from 'react-native-onyx'; +import {buildCategoryGroup, buildReportGroup, buildTransactionRow} from '../../utils/collections/searchListItems'; import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; jest.mock('@react-navigation/native', () => ({ @@ -32,29 +34,112 @@ jest.mock('@react-navigation/native', () => ({ const GROUP_KEY = 'Advertising'; +/** The query the rows belong to. Everything scoped to one search is keyed on it. */ +const SEARCH_HASH = 1; + +/** A child as it looks once its group has been expanded and the snapshot has loaded. */ +const buildChild = (index: number, key: string) => buildTransactionRow(index, key, {currency: 'USD', amount: -642, report: {reportID: '11'}}); + /** * A `group-by:category` group. Its children are fetched into a separate snapshot only once the row is expanded, * so `transactions` stays empty on the group itself for the whole lifetime of the list. */ -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- minimal fixture: only the fields the selection logic reads are needed -const categoryGroup = { - groupedBy: CONST.SEARCH.GROUP_BY.CATEGORY, - category: 'Advertising', - formattedCategory: 'Advertising', - count: 2, - total: -1284, - currency: 'USD', - transactions: [], - transactionsQueryJSON: buildSearchQueryJSON('type:expense category:Advertising'), - keyForList: GROUP_KEY, -} as unknown as TransactionCategoryGroupListItemType; - -/** The children as they look once the group has been expanded and its snapshot has loaded. */ -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- minimal fixture: only the fields the selection logic reads are needed -const loadedChildren = [ - {transactionID: '1', keyForList: '1', currency: 'USD', amount: -642, report: {reportID: '11'}}, - {transactionID: '2', keyForList: '2', currency: 'USD', amount: -642, report: {reportID: '11'}}, -] as unknown as TransactionListItemType[]; +const categoryGroup = buildCategoryGroup(GROUP_KEY, [], buildSearchQueryJSON('type:expense category:Advertising')); + +const loadedChildren = [buildChild(1, '1'), buildChild(2, '2')]; + +/** The same group with a third child, for ranges that leave a row untouched on either side. */ +const threeLoadedChildren = [...loadedChildren, buildChild(5, '5')]; + +/** The same group as the server sees it: five rows in total, of which only the first page has loaded. */ +const partiallyLoadedGroup = {...categoryGroup, count: 5}; + +/** The same group with its sub-snapshot cached, so it carries its first page while the rest are unloaded. */ +const cachedPartialGroup = {...categoryGroup, count: 5, transactions: loadedChildren}; + +/** A search with every page in, which is what lets select-all-matching be turned off. */ +const settledGroupedResults: SearchResults = { + ...makeFlatSearchResults(undefined), + search: {...makeFlatSearchResults(undefined).search, hasMoreResults: false}, +}; + +/** The same group, in a search with every page in. */ +function SettledGroupWrapper({children}: {children: React.ReactNode}) { + return ( + + + {children} + + + ); +} + +const EARLIER_GROUP_KEY = 'Office'; + +/** A group rendered above `categoryGroup`. */ +const earlierGroup = buildCategoryGroup(EARLIER_GROUP_KEY, [], buildSearchQueryJSON('type:expense category:Office')); + +/** The earlier group's children, expanded and loaded. */ +const earlierChildren = [buildChild(3, '3'), buildChild(4, '4')]; + +/** The same group as the list sees it: empty at first, carrying the loaded rows afterwards. */ +let pagingGroup: TransactionGroupListItemType = partiallyLoadedGroup; + +function PagingWrapper({children}: {children: React.ReactNode}) { + return ( + + + {children} + + + ); +} + +function TwoGroupWrapper({children}: {children: React.ReactNode}) { + return ( + + + {children} + + + ); +} const FLAT_TRANSACTION_ID = 'flat-1'; @@ -95,13 +180,41 @@ let flatExpense = makeFlatExpense(-3000); let flatFilteredData: TransactionListItemType[] = [flatExpense]; let flatSearchResults = makeFlatSearchResults(flatExpense); +let groupedSearchResults: SearchResults | undefined; +let groupedSearchHash = SEARCH_HASH; + +/** The same group under a search that can change identity, for the rows a registry must not carry across searches. */ +function SearchChangeWrapper({children}: {children: React.ReactNode}) { + return ( + + + {children} + + + ); +} + function Wrapper({children}: {children: React.ReactNode}) { return ( +/** Expense-report views make the report row the selectable unit, so a range spans reports. */ +const reportGroups = [buildReportGroup(6, 'report-1', [buildChild(6, '6')]), buildReportGroup(7, 'report-2', [buildChild(7, '7')]), buildReportGroup(8, 'report-3', [])]; + +function ExpenseReportWrapper({children}: {children: React.ReactNode}) { + return ( + + + {children} + + + ); +} + +const renderSelection = (wrapper: React.ComponentType<{children: React.ReactNode}> = Wrapper) => renderHook( () => ({ ...useSearchSelectionContext(), + ...useSearchSelectionActions(), ...useSearchRowSelectionActions(), + ...useSearchShiftRangeGroups(), }), - {wrapper: Wrapper}, + {wrapper}, ); const renderFlatSelection = () => @@ -154,6 +296,27 @@ const renderFlatSelection = () => {wrapper: FlatWrapper}, ); +/** + * Every fixture that stands for the same group. A group's rows reach a range through the list itself, so making them + * arrive means putting them on the group the wrapper under test renders. + */ +const groupFixturesByKey: Record = { + [GROUP_KEY]: [categoryGroup, partiallyLoadedGroup, cachedPartialGroup], + [EARLIER_GROUP_KEY]: [earlierGroup], +}; + +/** The rows a group carries once its page has arrived. Mutated in place: the provider holds the fixture, not a copy of it. */ +function carryRows(groupKey: string, children: TransactionListItemType[]) { + for (const fixture of groupFixturesByKey[groupKey] ?? []) { + fixture.transactions = children; + } +} + +function expandGroup(result: ReturnType['result'], groupKey: string, children: TransactionListItemType[]) { + carryRows(groupKey, children); + result.current.addGroupToRange(groupKey); +} + async function excludeFlatExpense(result: ReturnType['result']) { await act(async () => { result.current.toggleAll(); @@ -170,6 +333,14 @@ describe('Lazily loaded group selection', () => { beforeAll(() => Onyx.init({keys: ONYXKEYS})); beforeEach(async () => { + groupedSearchResults = undefined; + groupedSearchHash = SEARCH_HASH; + // The fixtures are mutated as their pages arrive, so each test starts from the state its name describes. + categoryGroup.transactions = []; + earlierGroup.transactions = []; + partiallyLoadedGroup.transactions = []; + cachedPartialGroup.transactions = loadedChildren; + pagingGroup = partiallyLoadedGroup; flatExpense = makeFlatExpense(-3000); flatFilteredData = [flatExpense]; flatSearchResults = makeFlatSearchResults(flatExpense); @@ -179,6 +350,22 @@ describe('Lazily loaded group selection', () => { }); }); + it('keeps the row actions stable when a group opens, so expanding one does not re-render every row', async () => { + const {result} = renderSelection(); + const toggleBefore = result.current.toggle; + const toggleAllBefore = result.current.toggleAll; + + // When a group opens, which changes the rows a range spans and the parent each belongs to + await act(async () => { + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the actions every row holds are the same functions, since the write path reads that index at the gesture + expect(result.current.toggle).toBe(toggleBefore); + expect(result.current.toggleAll).toBe(toggleAllBefore); + }); + it('stores the selection under the group key while the children are still unknown', async () => { const {result} = renderSelection(); @@ -213,47 +400,973 @@ describe('Lazily loaded group selection', () => { expect(Object.keys(result.current.selectedTransactions)).toHaveLength(0); }); - it('selects every child of a group that was not already selected once its children loaded', async () => { + it('narrows a selection held under the group key when shift+click collapses the range onto one child', async () => { const {result} = renderSelection(); + const [firstChild] = loadedChildren; - // When the checkbox is pressed on an expanded, unselected group whose children have loaded + // Given Select All while the group still carries no rows, so the selection lands under its key await act(async () => { - result.current.toggle(categoryGroup, loadedChildren); + result.current.toggleAll(); await waitForBatchedUpdatesWithAct(); }); + expect(result.current.selectedTransactions[GROUP_KEY]?.isSelected).toBe(true); - // Then each child is selected individually, and the group key is not used + // When its rows arrive and a shift+click collapses the seeded range onto the first child + await act(async () => { + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(firstChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the group entry is spelled out into its children, so the rest can be dropped + expect(result.current.selectedTransactions[GROUP_KEY]).toBeUndefined(); + expect(result.current.selectedTransactions['1']?.isSelected).toBe(true); + expect(result.current.selectedTransactions['2']).toBeUndefined(); + }); + + it('deselects a single child of a group that was selected before its children loaded', async () => { + const {result} = renderSelection(); + const [firstChild] = loadedChildren; + + // Given a group selected while it was still collapsed, whose children have since loaded and been registered + await act(async () => { + result.current.toggle(categoryGroup, []); + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions[GROUP_KEY]?.isSelected).toBe(true); + + // When one of those children, which renders as selected through the group, is clicked + await act(async () => { + result.current.toggle(firstChild); + await waitForBatchedUpdatesWithAct(); + }); + + // Then only that child is dropped and the rest of the group stays selected + expect(result.current.selectedTransactions[GROUP_KEY]).toBeUndefined(); + expect(result.current.selectedTransactions['1']).toBeUndefined(); + expect(result.current.selectedTransactions['2']?.isSelected).toBe(true); + }); + + it('narrows a group selected before its children loaded once shift+click shrinks the range', async () => { + const {result} = renderSelection(); + const [firstChild, secondChild] = loadedChildren; + + // Given a group selected while it was still collapsed, whose children have since loaded and been registered + await act(async () => { + result.current.toggle(categoryGroup, []); + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // When a shift+click covers both children and a second one shrinks the range back to the first + await act(async () => { + result.current.toggle(secondChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(firstChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the second child drops out, rather than staying selected through the group expect(result.current.selectedTransactions[GROUP_KEY]).toBeUndefined(); expect(result.current.selectedTransactions['1']?.isSelected).toBe(true); + expect(result.current.selectedTransactions['2']).toBeUndefined(); + }); + + it('anchors inside the selected group rather than at the top of the list, with an expanded group above it', async () => { + const {result} = renderSelection(TwoGroupWrapper); + const [, secondChild] = loadedChildren; + + // Given an expanded, unselected group above a group that was selected while it was still collapsed + await act(async () => { + expandGroup(result, EARLIER_GROUP_KEY, earlierChildren); + result.current.toggle(categoryGroup, []); + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // When shift+click lands on the second child of the selected group + await act(async () => { + result.current.toggle(secondChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the range stays inside that group and never reaches the group above + expect(result.current.selectedTransactions['1']?.isSelected).toBe(true); expect(result.current.selectedTransactions['2']?.isSelected).toBe(true); + expect(result.current.selectedTransactions['3']).toBeUndefined(); + expect(result.current.selectedTransactions['4']).toBeUndefined(); }); - it('refreshes an excluded expense when its live row changes', async () => { - const {result, rerender} = renderFlatSelection(); - await excludeFlatExpense(result); - expect(result.current.excludedTransactions[FLAT_TRANSACTION_ID]?.groupAmount).toBe(-3000); + it('leaves no untouched child behind when a range narrows a group of three', async () => { + const {result} = renderSelection(); + const [firstChild, secondChild] = threeLoadedChildren; - flatExpense = makeFlatExpense(-5000); - flatFilteredData = [flatExpense]; - flatSearchResults = makeFlatSearchResults(flatExpense); + // Given a group selected while it was still collapsed, whose three children have since loaded and been registered + await act(async () => { + result.current.toggle(categoryGroup, []); + expandGroup(result, GROUP_KEY, threeLoadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // When a shift+click covers the first two children and a second one shrinks the range back to the first + await act(async () => { + result.current.toggle(secondChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(firstChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then only the clicked child survives, rather than the third child hanging on because no range ever covered it + expect(result.current.selectedTransactions[GROUP_KEY]).toBeUndefined(); + expect(result.current.selectedTransactions['1']?.isSelected).toBe(true); + expect(result.current.selectedTransactions['2']).toBeUndefined(); + expect(result.current.selectedTransactions['5']).toBeUndefined(); + }); + + it('drops the group entry when a range covers every child, rather than holding both', async () => { + const {result} = renderSelection(); + const [, secondChild] = loadedChildren; + + // Given a group selected while it was still collapsed, whose children have since loaded and been registered + await act(async () => { + result.current.toggle(categoryGroup, []); + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // When one shift+click covers the whole group, so nothing is deselected + await act(async () => { + result.current.toggle(secondChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the selection is the children alone, not the children plus the group they were already selected through + expect(result.current.selectedTransactions[GROUP_KEY]).toBeUndefined(); + expect(Object.keys(result.current.selectedTransactions)).toEqual(['1', '2']); + }); + + it('stops marking a group as covering the rows a range left on the far side of the anchor', async () => { + const {result} = renderSelection(TwoGroupWrapper); + const [earlierFirstChild] = earlierChildren; + const [, secondChild] = loadedChildren; + + // Given the lower group selected from its header, so both of its rows are marked as covered by the group + await act(async () => { + expandGroup(result, EARLIER_GROUP_KEY, earlierChildren); + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(categoryGroup, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions[secondChild.keyForList]?.isSelectedViaGroup).toBe(true); + + // When a shift+click reaches up into the group above, so the block's second row is left on the far side of the anchor + await act(async () => { + result.current.toggle(earlierFirstChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions[secondChild.keyForList]?.isSelected).toBe(true); + + // Then that row stops claiming its group covers it, or an export would send a whole-group filter and reach every row in it + expect(result.current.selectedTransactions[secondChild.keyForList]?.isSelectedViaGroup).toBeFalsy(); + }); + + it('writes a group out into the rows that arrived when one of them is clicked, rather than refusing the click', async () => { + const {result, rerender} = renderSelection(PagingWrapper); + const [firstChild, secondChild] = loadedChildren; + + // Given a group of five selected while collapsed, so the selection is held under its key + await act(async () => { + result.current.toggle(pagingGroup, []); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions[GROUP_KEY]?.isSelected).toBe(true); + + // When its first page arrives and one of those rows is clicked + pagingGroup = cachedPartialGroup; rerender({}); - await act(async () => waitForBatchedUpdatesWithAct()); + await act(async () => { + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(firstChild); + await waitForBatchedUpdatesWithAct(); + }); - expect(result.current.excludedTransactions[FLAT_TRANSACTION_ID]?.groupAmount).toBe(-5000); - expect(result.current.areAllMatchingItemsSelected).toBe(true); + // Then the click lands: the rows that arrived carry the selection and the clicked one is out of it + expect(result.current.selectedTransactions[GROUP_KEY]).toBeUndefined(); + expect(result.current.selectedTransactions[firstChild.keyForList]).toBeUndefined(); + expect(result.current.selectedTransactions[secondChild.keyForList]?.isSelected).toBe(true); }); - it('prunes an excluded expense after it leaves the settled search results', async () => { - const {result, rerender} = renderFlatSelection(); - await excludeFlatExpense(result); - expect(result.current.excludedTransactions[FLAT_TRANSACTION_ID]).toBeDefined(); + it('unchecks a child of a group selected while collapsed, once that group’s first page reaches the list', async () => { + pagingGroup = partiallyLoadedGroup; + const {result, rerender} = renderSelection(PagingWrapper); + const [firstChild] = loadedChildren; - flatFilteredData = []; - flatSearchResults = makeFlatSearchResults(undefined); + // Given a group of five selected while collapsed, so the selection is held under its own key + await act(async () => { + result.current.toggle(pagingGroup, []); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions[GROUP_KEY]?.isSelected).toBe(true); + + // When its first page arrives, which is the same commit that makes those rows clickable + pagingGroup = cachedPartialGroup; rerender({}); - await act(async () => waitForBatchedUpdatesWithAct()); + await act(async () => { + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); - expect(result.current.excludedTransactions).toEqual({}); + // Then the group has been written out into the rows that arrived, rather than staying an unnameable block + expect(result.current.selectedTransactions[GROUP_KEY]).toBeUndefined(); + expect(result.current.selectedTransactions[firstChild.keyForList]?.isSelected).toBe(true); + + // And clicking one of them unchecks it, rather than the paging refusal making the checkbox dead + await act(async () => { + result.current.toggle(firstChild); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions[firstChild.keyForList]).toBeUndefined(); + }); + + it('narrows a group that is still paging in down to the rows a range keeps', async () => { + const {result, rerender} = renderSelection(PagingWrapper); + const [firstChild, secondChild] = loadedChildren; + + // Given a group of five selected while collapsed, whose first page has since arrived + await act(async () => { + result.current.toggle(pagingGroup, []); + await waitForBatchedUpdatesWithAct(); + }); + pagingGroup = cachedPartialGroup; + rerender({}); + await act(async () => { + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // When a range is drawn across the loaded rows and then pulled back onto the first + await act(async () => { + result.current.toggle(secondChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(firstChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the range gives back the row it no longer covers, the same as it would in a group with nothing left to page in + expect(result.current.selectedTransactions[firstChild.keyForList]?.isSelected).toBe(true); + expect(result.current.selectedTransactions[secondChild.keyForList]).toBeUndefined(); + }); + + it('anchors in the group just selected, not at a row selected earlier in another group', async () => { + const {result} = renderSelection(TwoGroupWrapper); + const [earlierFirstChild] = earlierChildren; + const [, secondChild] = loadedChildren; + + // Given a child selected in the group above, then the lower group selected while it was still collapsed + await act(async () => { + expandGroup(result, EARLIER_GROUP_KEY, earlierChildren); + result.current.toggle(earlierFirstChild); + result.current.toggle(categoryGroup, []); + await waitForBatchedUpdatesWithAct(); + }); + + // When that group's children load and a shift+click lands on its second child + await act(async () => { + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(secondChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the range stays inside the group the user just selected, leaving the other group's second child alone + expect(result.current.selectedTransactions['4']).toBeUndefined(); + }); + + it('leaves another group’s rows alone when the block seeded for the next shift+click never loaded', async () => { + const {result} = renderSelection(TwoGroupWrapper); + const [earlierFirstChild] = earlierChildren; + + // Given the group above selected as a block, then the group below selected while it was still collapsed + await act(async () => { + expandGroup(result, EARLIER_GROUP_KEY, earlierChildren); + result.current.toggle(earlierGroup, earlierChildren); + result.current.toggle(categoryGroup, []); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions['4']?.isSelected).toBe(true); + + // When a shift+click lands in the group above, with nothing of the seeded block on screen for it to narrow + await act(async () => { + result.current.toggle(earlierFirstChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then it starts a range at the row it landed on, rather than collapsing a block it was never pointed at + expect(result.current.selectedTransactions['4']?.isSelected).toBe(true); + }); + + it('narrows a group selected while collapsed with a cached snapshot, whose children were stored individually', async () => { + const {result} = renderSelection(); + const [firstChild] = loadedChildren; + + // Given the group selected while collapsed but already cached, so the header passes its children and they are stored one by one + await act(async () => { + result.current.toggle(categoryGroup, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions['2']?.isSelected).toBe(true); + + // When the group is re-expanded and a shift+click collapses the range onto the first child + await act(async () => { + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(firstChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the range narrows it, rather than the header's own rows counting as hand-picked and resisting the collapse + expect(result.current.selectedTransactions['1']?.isSelected).toBe(true); + expect(result.current.selectedTransactions['2']).toBeUndefined(); + }); + + it('never records a whole group as excluded when a range narrows under select-all-matching', async () => { + const {result} = renderSelection(TwoGroupWrapper); + const [firstChild, secondChild] = loadedChildren; + + const [earlierFirstChild] = earlierChildren; + + // Given a range that covers every row, and every matching item selected + await act(async () => { + expandGroup(result, EARLIER_GROUP_KEY, earlierChildren); + expandGroup(result, GROUP_KEY, loadedChildren); + result.current.toggle(earlierFirstChild); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(secondChild, undefined, true); + result.current.selectAllMatchingItems(true); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.areAllMatchingItemsSelected).toBe(true); + + // When a shift+click shrinks that range so the last rows fall out of it + await act(async () => { + result.current.toggle(firstChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then no group is written off as excluded, which would drop every one of its rows from a bulk action + expect(Object.keys(result.current.excludedTransactions)).not.toContain(GROUP_KEY); + expect(Object.keys(result.current.excludedTransactions)).not.toContain(EARLIER_GROUP_KEY); + }); + + it('selects every child of a group that was not already selected once its children loaded', async () => { + const {result} = renderSelection(); + + // When the checkbox is pressed on an expanded, unselected group whose children have loaded + await act(async () => { + result.current.toggle(categoryGroup, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // Then each child is selected individually, and the group key is not used + expect(result.current.selectedTransactions[GROUP_KEY]).toBeUndefined(); + expect(result.current.selectedTransactions['1']?.isSelected).toBe(true); + expect(result.current.selectedTransactions['2']?.isSelected).toBe(true); + }); + + it('refreshes an excluded expense when its live row changes', async () => { + const {result, rerender} = renderFlatSelection(); + await excludeFlatExpense(result); + expect(result.current.excludedTransactions[FLAT_TRANSACTION_ID]?.groupAmount).toBe(-3000); + + flatExpense = makeFlatExpense(-5000); + flatFilteredData = [flatExpense]; + flatSearchResults = makeFlatSearchResults(flatExpense); + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + + expect(result.current.excludedTransactions[FLAT_TRANSACTION_ID]?.groupAmount).toBe(-5000); + expect(result.current.areAllMatchingItemsSelected).toBe(true); + }); + + it('prunes an excluded expense after it leaves the settled search results', async () => { + const {result, rerender} = renderFlatSelection(); + await excludeFlatExpense(result); + expect(result.current.excludedTransactions[FLAT_TRANSACTION_ID]).toBeDefined(); + + flatFilteredData = []; + flatSearchResults = makeFlatSearchResults(undefined); + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + + expect(result.current.excludedTransactions).toEqual({}); + expect(result.current.areAllMatchingItemsSelected).toBe(true); + }); + + it('forgets a block held for the next shift+click once an ordinary click starts a session of its own', async () => { + const {result} = renderSelection(TwoGroupWrapper); + const [earlierFirstChild] = earlierChildren; + const [, secondChild] = loadedChildren; + + // Given a group clicked while its children were still unknown, which holds the block for the next shift+click + await act(async () => { + result.current.toggle(categoryGroup, []); + expandGroup(result, GROUP_KEY, loadedChildren); + expandGroup(result, EARLIER_GROUP_KEY, earlierChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // When an ordinary click lands elsewhere first, and only then a shift+click + await act(async () => { + result.current.toggle(earlierFirstChild); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(secondChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the range runs from the row just clicked, rather than the stale block resurrecting and collapsing onto it + expect(result.current.selectedTransactions['3']?.isSelected).toBe(true); + expect(result.current.selectedTransactions['4']?.isSelected).toBe(true); + expect(result.current.selectedTransactions['1']?.isSelected).toBe(true); + }); + + it('selects every report a range covers in an expense-report view', async () => { + const {result} = renderSelection(ExpenseReportWrapper); + const [firstReport, secondReport] = reportGroups; + + // Given the first report clicked, then a shift+click on the second + await act(async () => { + result.current.toggle(firstReport, firstReport.transactions); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(secondReport, secondReport.transactions, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then both reports are selected through their child transactions, which is where a report row keeps its selection + expect(result.current.selectedTransactions['6']?.isSelected).toBe(true); + expect(result.current.selectedTransactions['7']?.isSelected).toBe(true); + // And each row records the report it came in with, the same way clicking the report row records it + expect(result.current.selectedTransactions['7']?.groupKey).toBe(secondReport.keyForList); + expect(result.current.selectedTransactions['7']?.isSelectedViaGroup).toBe(true); + }); + + it('drops a report’s own entry when a range covers the rows that arrived under it', async () => { + const {result} = renderSelection(ExpenseReportWrapper); + const [firstReport] = reportGroups; + + // Given a report selected while it had no expenses of its own, whose expenses have since arrived + await act(async () => { + const [reportKey, reportEntry] = mapEmptyReportToSelectedEntry(firstReport); + result.current.setSelectedTransactions({[reportKey]: reportEntry}); + await waitForBatchedUpdatesWithAct(); + }); + + // When a range covers that report row + await act(async () => { + result.current.toggle(firstReport, firstReport.transactions, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then its rows carry the selection and the report's own entry goes, rather than the two being counted separately + expect(result.current.selectedTransactions['6']?.isSelected).toBe(true); + expect(result.current.selectedTransactions[firstReport.keyForList]).toBeUndefined(); + }); + + it('gives back the reports a range no longer covers in an expense-report view', async () => { + const {result} = renderSelection(ExpenseReportWrapper); + const [firstReport, secondReport, emptyReport] = reportGroups; + + // Given a range stretched across all three reports + await act(async () => { + result.current.toggle(firstReport, firstReport.transactions); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(emptyReport, emptyReport.transactions, true); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions['7']?.isSelected).toBe(true); + + // When a second shift+click pulls the range back to the second report + await act(async () => { + result.current.toggle(secondReport, secondReport.transactions, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the report that fell out of the range is given back, rather than staying selected behind the range + expect(result.current.selectedTransactions['6']?.isSelected).toBe(true); + expect(result.current.selectedTransactions['7']?.isSelected).toBe(true); + expect(result.current.selectedTransactions['report-3']).toBeUndefined(); + }); + + it('keeps the remaining children selected when the data refreshes after a group is written out', async () => { + const {result, rerender} = renderSelection(); + const [firstChild] = loadedChildren; + + // Given a group selected while collapsed, its children since published, and one of them unchecked + await act(async () => { + result.current.toggle(categoryGroup, []); + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(firstChild); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions['2']?.isSelected).toBe(true); + + // When a data push re-runs the reconcile + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + + // Then the child that is still selected survives it + expect(result.current.selectedTransactions['2']?.isSelected).toBe(true); + }); + + it('commits nothing when a shift+click re-covers the rows it already holds', async () => { + const {result} = renderSelection(); + const [firstChild, secondChild] = loadedChildren; + + // Given a range already covering both of a group's rows + 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, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + const selectionAfterRange = result.current.selectedTransactions; + + // When the same endpoint is shift+clicked again, so every row it covers is already selected the same way + await act(async () => { + result.current.toggle(secondChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the selection is the same object, so the commit bails and no row re-renders + expect(result.current.selectedTransactions).toBe(selectionAfterRange); + }); + + it('never anchors a cold shift+click on a row the user unchecked', async () => { + const {result, rerender} = renderSelection(); + const [firstChild, , thirdChild] = threeLoadedChildren; + + // Given a group covered by select-all-matching with its first child taken back out + await act(async () => { + result.current.selectAllMatchingItems(true); + expandGroup(result, GROUP_KEY, threeLoadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(firstChild); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.excludedTransactions[firstChild.keyForList]).toBeDefined(); + + // And no session left to continue, since the query changed after that click + groupedSearchHash = SEARCH_HASH + 1; + rerender({}); + await act(async () => { + expandGroup(result, GROUP_KEY, threeLoadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // When the next gesture is a shift+click, so the anchor has to be resolved from the rows themselves + await act(async () => { + result.current.toggle(thirdChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the range starts at the first row that reads as checked, rather than sweeping the unchecked row back in + expect(result.current.excludedTransactions[firstChild.keyForList]).toBeDefined(); + expect(result.current.selectedTransactions[firstChild.keyForList]).toBeUndefined(); + }); + + it('unchecks a group in one click when select-all-matching is what checked it', async () => { + const {result} = renderSelection(); + const [firstChild, secondChild] = loadedChildren; + + // Given every matching item selected from the menu, so the group reads checked without a single entry behind it + await act(async () => { + result.current.selectAllMatchingItems(true); + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // When the group's header checkbox is pressed once + await act(async () => { + result.current.toggle(categoryGroup, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // Then its rows stop reading as checked, rather than the click reading the group as unselected and selecting it again + const isChecked = (rowKey: string) => + isRowChecked({ + rowKey, + parentGroupKey: GROUP_KEY, + selectedTransactions: result.current.selectedTransactions, + excludedTransactions: result.current.excludedTransactions, + areAllMatchingItemsSelected: result.current.areAllMatchingItemsSelected, + }); + expect(isChecked(firstChild.keyForList)).toBe(false); + expect(isChecked(secondChild.keyForList)).toBe(false); + // And each row is named, since the group carries all of them and nothing is left for its own key to stand for + expect(result.current.excludedTransactions[firstChild.keyForList]).toBeDefined(); + expect(result.current.excludedTransactions[secondChild.keyForList]).toBeDefined(); + }); + + it('unchecks a group holding none of its rows, when select-all-matching is what checked it', async () => { + const {result} = renderSelection(); + + // Given every matching item selected, and a group whose children have never loaded + await act(async () => { + result.current.selectAllMatchingItems(true); + await waitForBatchedUpdatesWithAct(); + }); + + // When its header checkbox is pressed once + await act(async () => { + result.current.toggle(categoryGroup, []); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the group is recorded as excluded, rather than the click reading it as unselected and selecting it outright + expect(result.current.excludedTransactions[GROUP_KEY]).toBeDefined(); + expect(result.current.selectedTransactions[GROUP_KEY]).toBeUndefined(); + }); + + it('leaves the selection untouched when a group has no row it can select', async () => { + const {result} = renderSelection(); + const [firstLoadedChild] = loadedChildren; + const deletedChild = {...firstLoadedChild, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}; + + // Given a group whose only row is being deleted, so its checkbox reads unchecked with nothing to check + await act(async () => { + expandGroup(result, GROUP_KEY, [deletedChild]); + await waitForBatchedUpdatesWithAct(); + }); + const before = result.current.selectedTransactions; + + // When its header is pressed + await act(async () => { + result.current.toggle(categoryGroup, [deletedChild]); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the commit is skipped entirely, rather than replacing the map with an equal one and re-rendering every row + expect(result.current.selectedTransactions).toBe(before); + }); + + it('turns select-all-matching off once every group has been unchecked', async () => { + const {result} = renderSelection(SettledGroupWrapper); + + // Given every matching item selected in a grouped search of one group + await act(async () => { + result.current.selectAllMatchingItems(true); + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); expect(result.current.areAllMatchingItemsSelected).toBe(true); + + // When that group is unchecked, so nothing the search can select is left + await act(async () => { + result.current.toggle(cachedPartialGroup, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the flag goes off, rather than a footer advertising every match over a selection the user emptied + expect(result.current.areAllMatchingItemsSelected).toBe(false); + }); + + it('records what a narrowing dropped, so keeping select-all-matching on cannot silently re-include it', async () => { + const {result} = renderSelection(); + const [firstChild, secondChild] = loadedChildren; + + // Given a range across both children, with every matching item selected + await act(async () => { + expandGroup(result, GROUP_KEY, loadedChildren); + result.current.toggle(firstChild); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(secondChild, undefined, true); + result.current.selectAllMatchingItems(true); + await waitForBatchedUpdatesWithAct(); + }); + + // When a shift+click narrows that range back to the first child + await act(async () => { + result.current.toggle(firstChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the flag stays on, which is correct only because the dropped row is recorded as excluded + expect(result.current.areAllMatchingItemsSelected).toBe(true); + expect(result.current.excludedTransactions['2']).toBeDefined(); + expect(result.current.selectedTransactions['1']?.isSelected).toBe(true); + }); + + it('treats a shift+click on a group header as an ordinary header click, reaching no group between it and the last one', async () => { + const {result} = renderSelection(); + + // Given two collapsed groups, where the headers are the only rows carrying a checkbox + await act(async () => { + result.current.toggle(earlierGroup, []); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions[EARLIER_GROUP_KEY]?.isSelected).toBe(true); + + // When the second header is shift+clicked + await act(async () => { + result.current.toggle(categoryGroup, [], true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then it selects itself and nothing spans between the two, which is what design settled on + expect(result.current.selectedTransactions[GROUP_KEY]?.isSelected).toBe(true); + expect(result.current.selectedTransactions[EARLIER_GROUP_KEY]?.isSelected).toBe(true); + expect(Object.keys(result.current.selectedTransactions).sort()).toEqual([EARLIER_GROUP_KEY, GROUP_KEY].sort()); + }); + + it('leaves a select-all-matching selection alone when a shift+click lands in it, since narrowing it would need exclusions for rows never on screen', async () => { + const {result} = renderSelection(); + const [firstChild, secondChild] = loadedChildren; + + // Given every matching item selected from the menu, which the reconcile pass writes out as an entry per visible row + await act(async () => { + result.current.selectAllMatchingItems(true); + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // When a shift+click lands on the first row + await act(async () => { + result.current.toggle(firstChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then both rows stay checked: those entries carry no group, so they read as hand-picked and a range may not take them back + const isChecked = (rowKey: string) => + isRowChecked({ + rowKey, + parentGroupKey: GROUP_KEY, + selectedTransactions: result.current.selectedTransactions, + excludedTransactions: result.current.excludedTransactions, + areAllMatchingItemsSelected: result.current.areAllMatchingItemsSelected, + }); + expect(isChecked(firstChild.keyForList)).toBe(true); + expect(isChecked(secondChild.keyForList)).toBe(true); + }); + + it('turns select-all-matching off when the header checkbox clears the selection', async () => { + const {result} = renderFlatSelection(); + + // Given every matching item selected + await act(async () => { + result.current.toggleAll(); + result.current.selectAllMatchingItems(true); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.areAllMatchingItemsSelected).toBe(true); + + // When the header checkbox is pressed again to clear everything + await act(async () => { + result.current.toggleAll(); + await waitForBatchedUpdatesWithAct(); + }); + + // Then nothing is selected any more, rather than every unloaded match staying selected behind an empty page + expect(result.current.areAllMatchingItemsSelected).toBe(false); + expect(result.current.excludedTransactions).toEqual({}); + }); + + it('collapses a Select All onto the span the next shift+click lands in', async () => { + const {result} = renderSelection(ExpenseReportWrapper); + const [firstReport] = reportGroups; + + // Given Select All, which seeds a block covering the whole list + await act(async () => { + result.current.toggleAll(); + await waitForBatchedUpdatesWithAct(); + }); + expect(Object.keys(result.current.selectedTransactions).length).toBeGreaterThan(1); + + // When a shift+click lands on the first report + await act(async () => { + result.current.toggle(firstReport, firstReport.transactions, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the selection narrows onto it, which is what makes an overshoot recoverable in one click + expect(Object.keys(result.current.selectedTransactions)).toEqual(['6']); + }); + + it('drops a group’s published rows when the search changes, so a range cannot reach the previous results', async () => { + groupedSearchResults = makeFlatSearchResults(undefined); + const {result, rerender} = renderSelection(SearchChangeWrapper); + const [, secondChild] = loadedChildren; + + // Given a group open with its rows published under one search + await act(async () => { + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // When the search changes and the group has not published anything yet under the new one + groupedSearchHash = SEARCH_HASH + 1; + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + + // Then a shift+click reaches only the row it landed on, rather than sweeping in rows that belonged to the previous search + await act(async () => { + result.current.toggle(secondChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions['2']?.isSelected).toBe(true); + expect(result.current.selectedTransactions['1']).toBeUndefined(); + }); + + it('starts a cold session when the search changes, so an old span cannot collapse rows in the new results', async () => { + groupedSearchResults = makeFlatSearchResults(undefined); + const {result, rerender} = renderSelection(SearchChangeWrapper); + const [firstChild, secondChild] = loadedChildren; + + // Given a range painted across both children under one search + 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, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions['2']?.isSelected).toBe(true); + + // When the query changes and the same rows come back, since a transaction can match both searches + groupedSearchHash = SEARCH_HASH + 1; + rerender({}); + await act(async () => { + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // Then a shift+click starts fresh rather than continuing the previous search's span and collapsing the row that fell out of it + await act(async () => { + result.current.toggle(firstChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions['2']?.isSelected).toBe(true); + }); + + it('drops a group’s rows from ranges once they are gone, rather than ranging over transactions that no longer exist', async () => { + const {result, rerender} = renderSelection(); + const [firstChild, , thirdChild] = threeLoadedChildren; + + // Given a group carrying its rows, and open + await act(async () => { + expandGroup(result, GROUP_KEY, threeLoadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + // When every one of its transactions goes away, so the group carries none + carryRows(GROUP_KEY, []); + rerender({}); + await act(async () => waitForBatchedUpdatesWithAct()); + + // Then a range reaches none of them, rather than writing selections for rows that are no longer there + await act(async () => { + result.current.toggle(firstChild); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(thirdChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.selectedTransactions['2']).toBeUndefined(); + }); + + it('still ranges a group reopened before its rows were published again', async () => { + const {result} = renderSelection(); + const [firstChild, secondChild] = loadedChildren; + + // Given a group whose children loaded, then collapsed and reopened without the row republishing them + await act(async () => { + expandGroup(result, GROUP_KEY, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.removeGroupFromRange(GROUP_KEY); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.addGroupToRange(GROUP_KEY); + await waitForBatchedUpdatesWithAct(); + }); + + // When a range runs across its rows + await act(async () => { + result.current.toggle(firstChild); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(secondChild, undefined, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then they are still reachable, because closing a group drops only its openness and not the rows it had published + expect(result.current.selectedTransactions['1']?.isSelected).toBe(true); + expect(result.current.selectedTransactions['2']?.isSelected).toBe(true); + }); + + it('selects a report with no expenses of its own when a range reaches it', async () => { + const {result} = renderSelection(ExpenseReportWrapper); + const [firstReport, , emptyReport] = reportGroups; + + // When a range stretches from the first report onto one that carries no expenses + await act(async () => { + result.current.toggle(firstReport, firstReport.transactions); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(emptyReport, emptyReport.transactions, true); + await waitForBatchedUpdatesWithAct(); + }); + + // Then it is selected under its own key, which is the only key an empty report has + expect(result.current.selectedTransactions['report-3']?.isSelected).toBe(true); }); }); diff --git a/tests/unit/Search/buildShiftRangeSource.test.ts b/tests/unit/Search/buildShiftRangeSource.test.ts new file mode 100644 index 000000000000..e1b78250ff07 --- /dev/null +++ b/tests/unit/Search/buildShiftRangeSource.test.ts @@ -0,0 +1,148 @@ +import {buildShiftRangeSource, getGroupCheckboxState, isGroupSelected, mapEmptyReportToSelectedEntry} from '@components/Search/selectionBuilders'; +import type {SearchData, SelectedTransactions} from '@components/Search/types'; + +import CONST from '@src/CONST'; + +import {buildCategoryGroup as makeGroup, buildTransactionRow as makeChild} from '../../utils/collections/searchListItems'; + +const openGroups = (...keys: string[]): ReadonlySet => new Set(keys); + +const NO_OPEN_GROUPS = openGroups(); + +describe('buildShiftRangeSource: the rows a range spans', () => { + it('passes flat (non-grouped) data through unchanged', () => { + const filteredData: SearchData = [makeChild(1, 't1'), makeChild(2, 't2')]; + + expect(buildShiftRangeSource(filteredData, NO_OPEN_GROUPS, false).items).toBe(filteredData); + }); + + it('follows each open group with the rows it carries, in the order the list renders them', () => { + const childA1 = makeChild(1, 'a1'); + const childA2 = makeChild(2, 'a2'); + const childB1 = makeChild(3, 'b1'); + const groupA = makeGroup('groupA', [childA1, childA2]); + const groupB = makeGroup('groupB', [childB1]); + const filteredData: SearchData = [groupA, groupB]; + + expect(buildShiftRangeSource(filteredData, openGroups('groupA', 'groupB'), true).items).toEqual([groupA, childA1, childA2, groupB, childB1]); + }); + + it('skips the rows a closed group still carries, since a range must not reach what is off screen', () => { + const group = makeGroup('groupA', [makeChild(1, 'x'), makeChild(2, 'y')]); + const filteredData: SearchData = [group]; + + expect(buildShiftRangeSource(filteredData, NO_OPEN_GROUPS, true).items).toEqual([group]); + }); + + it('resolves each group independently, so an open group contributes its rows and a closed one contributes none', () => { + const openChild = makeChild(1, 'open1'); + const openGroup = makeGroup('groupA', [openChild]); + const closedGroup = makeGroup('groupB', [makeChild(2, 'closed1')]); + const filteredData: SearchData = [openGroup, closedGroup]; + + expect(buildShiftRangeSource(filteredData, openGroups('groupA'), true).items).toEqual([openGroup, openChild, closedGroup]); + }); + + it('contributes nothing for an open group whose rows have not arrived, rather than guessing at them', () => { + const group = makeGroup('groupA'); + const filteredData: SearchData = [group]; + + expect(buildShiftRangeSource(filteredData, openGroups('groupA'), true).items).toEqual([group]); + }); + + it('does not flatten when groups are the selectable unit (groupsAreHeaders=false, e.g. expense-report views): rows pass through unchanged', () => { + const filteredData: SearchData = [makeGroup('groupA', [makeChild(1, 'a1')])]; + + expect(buildShiftRangeSource(filteredData, openGroups('groupA'), false).items).toBe(filteredData); + }); +}); + +describe('isGroupSelected', () => { + const child = makeChild(1, 'c1'); + + /** Entries are only ever read for `isSelected`, so the empty-report builder supplies a fully typed one. */ + function selectionOf(...keys: string[]): SelectedTransactions { + const [, entry] = mapEmptyReportToSelectedEntry(makeGroup('anyGroup')); + return Object.fromEntries(keys.map((key) => [key, entry])); + } + + const groupOf = (selectedTransactions: SelectedTransactions, overrides: Partial[0]> = {}) => ({ + groupKey: 'groupA', + children: [child], + selectedTransactions, + excludedTransactions: {}, + areAllMatchingItemsSelected: false, + ...overrides, + }); + + it('counts a group selected under its own key, which is how it is stored before its children load', () => { + expect(isGroupSelected(groupOf(selectionOf('groupA')))).toBe(true); + }); + + it('counts a group with any child selected', () => { + expect(isGroupSelected(groupOf(selectionOf('c1')))).toBe(true); + }); + + it('does not count a group whose key and children are both unselected', () => { + expect(isGroupSelected(groupOf(selectionOf('other')))).toBe(false); + }); + + it('counts a group whose rows are checked by select-all-matching alone, which is what the user is looking at', () => { + expect(isGroupSelected(groupOf({}, {areAllMatchingItemsSelected: true}))).toBe(true); + }); + + it('counts a group with no loaded rows that select-all-matching covers, the same as its checkbox does', () => { + expect(isGroupSelected(groupOf({}, {children: [], areAllMatchingItemsSelected: true}))).toBe(true); + }); + + it('ignores a row being deleted, so clicking the header cannot mean deselect while the checkbox reads unchecked', () => { + const deletedChild = {...child, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}; + const params = groupOf(selectionOf('c1'), {children: [deletedChild]}); + expect(isGroupSelected(params)).toBe(false); + expect(getGroupCheckboxState(params).isSelectAllChecked).toBe(false); + }); + + it('stops answering from its own key once it carries rows, so a group holding only deleted ones reads the same to both', () => { + const deletedChild = {...child, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}; + const params = groupOf(selectionOf('groupA'), {children: [deletedChild]}); + expect(isGroupSelected(params)).toBe(false); + expect(getGroupCheckboxState(params).isSelectAllChecked).toBe(false); + }); +}); + +describe('buildShiftRangeSource: who owns each row', () => { + it('indexes each child against the group it is rendered under, and indexes nothing for a closed group', () => { + const openChild1 = makeChild(1, 'open1'); + const openChild2 = makeChild(2, 'open2'); + const openGroup = makeGroup('groupA', [openChild1, openChild2]); + const closedGroup = makeGroup('groupB', [makeChild(3, 'closed1')]); + const filteredData: SearchData = [openGroup, closedGroup]; + + const {childrenByGroupKey, groupKeyByChildKey} = buildShiftRangeSource(filteredData, openGroups('groupA'), true); + + expect(childrenByGroupKey.get('groupA')).toEqual([openChild1, openChild2]); + expect(childrenByGroupKey.get('groupB')).toEqual([]); + expect(groupKeyByChildKey.get('open1')).toBe('groupA'); + expect(groupKeyByChildKey.get('open2')).toBe('groupA'); + expect(groupKeyByChildKey.has('closed1')).toBe(false); + }); + + it('indexes the same children the range spans, so the two cannot disagree about who owns a row', () => { + const child = makeChild(1, 'c1'); + const filteredData: SearchData = [makeGroup('groupA', [child])]; + + const {groupKeyByChildKey} = buildShiftRangeSource(filteredData, openGroups('groupA'), true); + + expect(buildShiftRangeSource(filteredData, openGroups('groupA'), true).items.at(-1)).toBe(child); + expect(groupKeyByChildKey.get('c1')).toBe('groupA'); + }); + + it('is empty where groups are the selectable unit, since those rows own no children in the list', () => { + const filteredData: SearchData = [makeGroup('groupA', [makeChild(1, 'a1')])]; + + const {childrenByGroupKey, groupKeyByChildKey} = buildShiftRangeSource(filteredData, openGroups('groupA'), false); + + expect(childrenByGroupKey.size).toBe(0); + expect(groupKeyByChildKey.size).toBe(0); + }); +}); diff --git a/tests/unit/Search/useGroupOpenForShiftRange.test.tsx b/tests/unit/Search/useGroupOpenForShiftRange.test.tsx new file mode 100644 index 000000000000..2b44766cabdb --- /dev/null +++ b/tests/unit/Search/useGroupOpenForShiftRange.test.tsx @@ -0,0 +1,54 @@ +import {renderHook} from '@testing-library/react-native'; + +import {SearchShiftRangeGroupsContext} from '@components/Search/SearchContextDefinitions'; +import useGroupOpenForShiftRange from '@components/Search/SearchList/ListItem/useGroupOpenForShiftRange'; + +import React from 'react'; + +function setup() { + const addGroupToRange = jest.fn(); + const removeGroupFromRange = jest.fn(); + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + return {addGroupToRange, removeGroupFromRange, wrapper}; +} + +describe('useGroupOpenForShiftRange', () => { + it('opens the group while it is expanded', () => { + const {addGroupToRange, removeGroupFromRange, wrapper} = setup(); + renderHook(() => useGroupOpenForShiftRange('group-1', true), {wrapper}); + expect(addGroupToRange).toHaveBeenCalledWith('group-1'); + expect(removeGroupFromRange).not.toHaveBeenCalled(); + }); + + it('opens nothing while it is collapsed', () => { + const {addGroupToRange, removeGroupFromRange, wrapper} = setup(); + renderHook(() => useGroupOpenForShiftRange('group-1', false), {wrapper}); + expect(addGroupToRange).not.toHaveBeenCalled(); + expect(removeGroupFromRange).not.toHaveBeenCalled(); + }); + + it('closes the group when it collapses', () => { + const {removeGroupFromRange, wrapper} = setup(); + const {rerender} = renderHook(({isOpen}) => useGroupOpenForShiftRange('group-1', isOpen), {wrapper, initialProps: {isOpen: true}}); + rerender({isOpen: false}); + expect(removeGroupFromRange).toHaveBeenCalledWith('group-1'); + }); + + it('closes the group when the row goes away, since its expanded state goes with it', () => { + const {removeGroupFromRange, wrapper} = setup(); + const {unmount} = renderHook(() => useGroupOpenForShiftRange('group-1', true), {wrapper}); + expect(removeGroupFromRange).not.toHaveBeenCalled(); + unmount(); + expect(removeGroupFromRange).toHaveBeenCalledWith('group-1'); + }); + + it('closes the group it had open when the row is recycled to render another', () => { + const {addGroupToRange, removeGroupFromRange, wrapper} = setup(); + const {rerender} = renderHook(({groupKey}) => useGroupOpenForShiftRange(groupKey, true), {wrapper, initialProps: {groupKey: 'group-1'}}); + rerender({groupKey: 'group-2'}); + expect(removeGroupFromRange).toHaveBeenCalledWith('group-1'); + expect(addGroupToRange).toHaveBeenLastCalledWith('group-2'); + }); +}); diff --git a/tests/unit/Search/useOpenGroupsForShiftRange.test.tsx b/tests/unit/Search/useOpenGroupsForShiftRange.test.tsx new file mode 100644 index 000000000000..8203ae07b855 --- /dev/null +++ b/tests/unit/Search/useOpenGroupsForShiftRange.test.tsx @@ -0,0 +1,90 @@ +import {renderHook} from '@testing-library/react-native'; + +import {SearchShiftRangeGroupsContext} from '@components/Search/SearchContextDefinitions'; +import useOpenGroupsForShiftRange from '@components/Search/SearchList/ListItem/useOpenGroupsForShiftRange'; + +import React from 'react'; + +function setup() { + const addGroupToRange = jest.fn(); + const removeGroupFromRange = jest.fn(); + let registryGeneration: number | undefined = 1; + const dropRegistry = () => { + registryGeneration = (registryGeneration ?? 0) + 1; + }; + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + return {addGroupToRange, removeGroupFromRange, dropRegistry, wrapper}; +} + +describe('useOpenGroupsForShiftRange', () => { + it('opens every group in the set', () => { + const {addGroupToRange, wrapper} = setup(); + renderHook(() => useOpenGroupsForShiftRange(new Set(['group-1', 'group-2'])), {wrapper}); + expect(addGroupToRange).toHaveBeenCalledWith('group-1'); + expect(addGroupToRange).toHaveBeenCalledWith('group-2'); + }); + + it('closes only the group that collapsed, leaving the ones that stayed open alone', () => { + const {addGroupToRange, removeGroupFromRange, wrapper} = setup(); + const {rerender} = renderHook(({openGroupKeys}) => useOpenGroupsForShiftRange(openGroupKeys), { + wrapper, + initialProps: {openGroupKeys: new Set(['group-1', 'group-2'])}, + }); + addGroupToRange.mockClear(); + rerender({openGroupKeys: new Set(['group-2'])}); + expect(removeGroupFromRange).toHaveBeenCalledWith('group-1'); + expect(removeGroupFromRange).not.toHaveBeenCalledWith('group-2'); + expect(addGroupToRange).not.toHaveBeenCalled(); + }); + + it('opens only the group that expanded, rather than churning the whole set', () => { + const {addGroupToRange, removeGroupFromRange, wrapper} = setup(); + const {rerender} = renderHook(({openGroupKeys}) => useOpenGroupsForShiftRange(openGroupKeys), { + wrapper, + initialProps: {openGroupKeys: new Set(['group-1', 'group-2'])}, + }); + addGroupToRange.mockClear(); + rerender({openGroupKeys: new Set(['group-1', 'group-2', 'group-3'])}); + expect(addGroupToRange).toHaveBeenCalledTimes(1); + expect(addGroupToRange).toHaveBeenCalledWith('group-3'); + expect(removeGroupFromRange).not.toHaveBeenCalled(); + }); + + it('opens its groups again when the registry is dropped for a new search', () => { + const {addGroupToRange, dropRegistry, wrapper} = setup(); + const openGroupKeys = new Set(['group-1']); + const {rerender} = renderHook(({groupKeys}) => useOpenGroupsForShiftRange(groupKeys), {wrapper, initialProps: {groupKeys: openGroupKeys}}); + addGroupToRange.mockClear(); + dropRegistry(); + rerender({groupKeys: openGroupKeys}); + expect(addGroupToRange).toHaveBeenCalledWith('group-1'); + }); + + it('closes every open group when the view goes away, since the provider outlives it', () => { + const {removeGroupFromRange, wrapper} = setup(); + const {unmount} = renderHook(() => useOpenGroupsForShiftRange(new Set(['group-1', 'group-2'])), {wrapper}); + expect(removeGroupFromRange).not.toHaveBeenCalled(); + unmount(); + expect(removeGroupFromRange).toHaveBeenCalledWith('group-1'); + expect(removeGroupFromRange).toHaveBeenCalledWith('group-2'); + }); + + it('closes everything when the layout stops rendering children as rows', () => { + const {removeGroupFromRange, wrapper} = setup(); + const openGroupKeys = new Set(['group-1', 'group-2']); + const {rerender} = renderHook(({groupKeys}) => useOpenGroupsForShiftRange(groupKeys), {wrapper, initialProps: {groupKeys: openGroupKeys}}); + rerender({groupKeys: new Set()}); + expect(removeGroupFromRange).toHaveBeenCalledWith('group-1'); + expect(removeGroupFromRange).toHaveBeenCalledWith('group-2'); + }); + + it('leaves the open groups alone while the set holds still', () => { + const {removeGroupFromRange, wrapper} = setup(); + const openGroupKeys = new Set(['group-1']); + const {rerender} = renderHook(({groupKeys}) => useOpenGroupsForShiftRange(groupKeys), {wrapper, initialProps: {groupKeys: openGroupKeys}}); + rerender({groupKeys: openGroupKeys}); + expect(removeGroupFromRange).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/Search/useOpenGroupsRegistryTest.tsx b/tests/unit/Search/useOpenGroupsRegistryTest.tsx new file mode 100644 index 000000000000..cdd6ac381c44 --- /dev/null +++ b/tests/unit/Search/useOpenGroupsRegistryTest.tsx @@ -0,0 +1,59 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useOpenGroupsRegistry from '@components/Search/hooks/useOpenGroupsRegistry'; + +const SEARCH_HASH = 111; + +const renderRegistry = () => renderHook(({searchHash}) => useOpenGroupsRegistry(searchHash), {initialProps: {searchHash: SEARCH_HASH}}); + +describe('useOpenGroupsRegistry', () => { + it('opens a group and closes it again', () => { + const {result} = renderRegistry(); + + act(() => result.current.shiftRangeGroupsActions.addGroupToRange('group-1')); + expect([...result.current.openGroupKeys]).toEqual(['group-1']); + + act(() => result.current.shiftRangeGroupsActions.removeGroupFromRange('group-1')); + expect([...result.current.openGroupKeys]).toEqual([]); + }); + + it('returns the same set when a group is opened twice, so a republish re-renders nothing', () => { + const {result} = renderRegistry(); + + act(() => result.current.shiftRangeGroupsActions.addGroupToRange('group-1')); + const openedOnce = result.current.openGroupKeys; + + act(() => result.current.shiftRangeGroupsActions.addGroupToRange('group-1')); + expect(result.current.openGroupKeys).toBe(openedOnce); + }); + + it('drops every open group when the search changes, so a range cannot reach the previous results', () => { + const {result, rerender} = renderRegistry(); + + act(() => result.current.shiftRangeGroupsActions.addGroupToRange('group-1')); + expect([...result.current.openGroupKeys]).toEqual(['group-1']); + + rerender({searchHash: 222}); + expect([...result.current.openGroupKeys]).toEqual([]); + }); + + it('changes the generation with the search, which is how a subscriber knows to open its group again', () => { + const {result, rerender} = renderRegistry(); + const generationBefore = result.current.shiftRangeGroupsActions.registryGeneration; + + rerender({searchHash: 222}); + + expect(result.current.shiftRangeGroupsActions.registryGeneration).not.toBe(generationBefore); + }); + + it('keeps the methods across a search change, since a subscriber depending on them would otherwise loop', () => { + const {result, rerender} = renderRegistry(); + const {addGroupToRange, removeGroupFromRange} = result.current.shiftRangeGroupsActions; + + act(() => result.current.shiftRangeGroupsActions.addGroupToRange('group-1')); + rerender({searchHash: 222}); + + expect(result.current.shiftRangeGroupsActions.addGroupToRange).toBe(addGroupToRange); + expect(result.current.shiftRangeGroupsActions.removeGroupFromRange).toBe(removeGroupFromRange); + }); +}); diff --git a/tests/unit/Search/useRowSelectionTest.tsx b/tests/unit/Search/useRowSelectionTest.tsx index a6acb762045b..d66f52caeebd 100644 --- a/tests/unit/Search/useRowSelectionTest.tsx +++ b/tests/unit/Search/useRowSelectionTest.tsx @@ -21,6 +21,9 @@ const baseSelectionContext = { const noopSelectionActions: SearchSelectionActionsValue = { setCurrentSelectedTransactionReportID: () => {}, setSelectedTransactions: () => {}, + getSelectedTransactions: () => ({}), + getExcludedTransactions: () => ({}), + getAreAllMatchingItemsSelected: () => false, applySelection: () => {}, setSelectedReports: () => {}, removeTransaction: () => {}, diff --git a/tests/unit/Search/useSyncSelectedReportsTest.tsx b/tests/unit/Search/useSyncSelectedReportsTest.tsx index 9c4b8f2a0650..2030b12d039e 100644 --- a/tests/unit/Search/useSyncSelectedReportsTest.tsx +++ b/tests/unit/Search/useSyncSelectedReportsTest.tsx @@ -102,6 +102,9 @@ function renderHarness({ () => ({ setCurrentSelectedTransactionReportID: () => {}, setSelectedTransactions: () => {}, + getSelectedTransactions: () => ({}), + getExcludedTransactions: () => ({}), + getAreAllMatchingItemsSelected: () => false, applySelection: () => {}, setSelectedReports, removeTransaction: () => {}, diff --git a/tests/unit/TransactionGroupListItemTest.tsx b/tests/unit/TransactionGroupListItemTest.tsx index 018465698a13..568cf7d82fab 100644 --- a/tests/unit/TransactionGroupListItemTest.tsx +++ b/tests/unit/TransactionGroupListItemTest.tsx @@ -499,9 +499,9 @@ describe('Empty Report Selection', () => { fireEvent.press(checkbox); await waitForBatchedUpdatesWithAct(); - // Then onCheckboxPress should be called with the empty report and undefined (for groupBy reports) + // Then onCheckboxPress should be called with the empty report, undefined transactions, and no shiftKey expect(mockOnCheckboxPress).toHaveBeenCalledTimes(1); - expect(mockOnCheckboxPress).toHaveBeenCalledWith(mockEmptyReport, undefined); + expect(mockOnCheckboxPress).toHaveBeenCalledWith(mockEmptyReport, undefined, undefined); }); it('should call onCheckboxPress multiple times when checkbox is clicked multiple times', async () => { @@ -547,7 +547,7 @@ describe('Empty Report Selection', () => { await waitForBatchedUpdatesWithAct(); expect(mockOnCheckboxPress).toHaveBeenCalledTimes(1); - expect(mockOnCheckboxPress).toHaveBeenCalledWith(mockEmptyReport, undefined); + expect(mockOnCheckboxPress).toHaveBeenCalledWith(mockEmptyReport, undefined, undefined); unmountEmpty(); mockOnCheckboxPress.mockClear(); @@ -568,7 +568,7 @@ describe('Empty Report Selection', () => { await waitForBatchedUpdatesWithAct(); expect(mockOnCheckboxPress).toHaveBeenCalledTimes(1); - expect(mockOnCheckboxPress).toHaveBeenCalledWith(mockNonEmptyReport, undefined); + expect(mockOnCheckboxPress).toHaveBeenCalledWith(mockNonEmptyReport, undefined, undefined); unmountNonEmpty(); }); @@ -585,9 +585,9 @@ describe('Empty Report Selection', () => { expect(mockOnCheckboxPress).toHaveBeenCalledTimes(i); } - expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(1, mockEmptyReport, undefined); - expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(2, mockEmptyReport, undefined); - expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(3, mockEmptyReport, undefined); + expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(1, mockEmptyReport, undefined, undefined); + expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(2, mockEmptyReport, undefined, undefined); + expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(3, mockEmptyReport, undefined, undefined); }); it('should show expandable content for non-empty reports', async () => { @@ -713,7 +713,7 @@ describe('Lazily loaded group selection', () => { // Then the group should be selected expect(mockOnCheckboxPress).toHaveBeenCalledTimes(1); - expect(mockOnCheckboxPress).toHaveBeenCalledWith(mockCategoryGroup, []); + expect(mockOnCheckboxPress).toHaveBeenCalledWith(mockCategoryGroup, [], undefined); }); it('should expand the group instead of selecting it when tapping the expand arrow', async () => { diff --git a/tests/unit/hooks/useShiftRangeSelection.test.ts b/tests/unit/hooks/useShiftRangeSelection.test.ts index 071706fcbf76..98a37aedcb28 100644 --- a/tests/unit/hooks/useShiftRangeSelection.test.ts +++ b/tests/unit/hooks/useShiftRangeSelection.test.ts @@ -205,6 +205,28 @@ describe('useShiftRangeSelection', () => { }); expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'c', 'e'], toDeselect: []}); }); + + it('keeps the last usable anchor when notifyAnchor is passed a row the list does not contain', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROW_B)); + // A row that can never be a range endpoint, such as an expanded child in a report list + act(() => result.current.notifyAnchor({keyForList: 'not-in-the-list'})); + act(() => { + result.current.applyShiftClick(ROW_D, true); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c', 'd'], toDeselect: []}); + }); + + it('matches notifyAnchor by key, so a re-rendered copy of a row still anchors', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor({...ROW_B})); + act(() => { + result.current.applyShiftClick(ROW_D, true); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c', 'd'], toDeselect: []}); + }); }); describe('range computation', () => { @@ -451,15 +473,50 @@ describe('useShiftRangeSelection', () => { expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c'], toDeselect: ['d']}); }); - it('clears the session when the selection is empty, so the next shift+click resolves a cold anchor', () => { + it('leaves the session alone when the block turns out to hold no rows, since nothing was selected', () => { const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROW_B)); act(() => result.current.seedRangeFromSelection([])); act(() => { - // Session cleared → cold shift+click resolves the anchor from the first selectable row (a). + result.current.applyShiftClick(ROW_D, true); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c', 'd'], toDeselect: []}); + }); + + it('starts at the clicked row when a seeded block resolves to no rows on screen, rather than sweeping from the top', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.seedRangeFromSelection((key) => key === 'not-in-this-list')); + act(() => { result.current.applyShiftClick(ROW_C, true); }); - expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: []}); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['c'], toDeselect: []}); + }); + + it('resolves a seeded block against the rows the list holds at shift+click, not the ones it held when seeded', () => { + const onApplyRange = makeApplyMock(); + // Seeded while only `a` is in the list, so none of the block's rows can be resolved yet. + const {result, rerender} = renderHook((props: {items: Row[]}) => useShiftRangeSelection(makeParams({onApplyRange, items: props.items})), {initialProps: {items: [ROW_A]}}); + act(() => result.current.seedRangeFromSelection(['b', 'c', 'd'])); + + // The rest of the block arrives, the way a group's children do once it is expanded. + rerender({items: ROWS}); + act(() => { + result.current.applyShiftClick(ROW_C, true); + }); + + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c'], toDeselect: ['d']}); + }); + + it('accepts a membership test, so a block can be seeded before any of its rows are known', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.seedRangeFromSelection((key) => key === 'b' || key === 'c' || key === 'd')); + act(() => { + result.current.applyShiftClick(ROW_C, true); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c'], toDeselect: ['d']}); }); }); @@ -488,6 +545,62 @@ describe('useShiftRangeSelection', () => { expect(nthBatchKeys(onApplyRange, 1)).toEqual({toSelect: ['b', 'c'], toDeselect: ['d', 'e']}); }); + it('adopts an unprotected block on a cold click, anchoring in it and narrowing it in one go', () => { + const onApplyRange = makeApplyMock(); + // Rows b..e read as selected but none of them were picked on their own, which is how a group-level selection looks. + const {result} = renderHook(() => + useShiftRangeSelection( + makeParams({ + isItemSelected: (row) => row.keyForList !== 'a', + isItemProtected: () => false, + onApplyRange, + }), + ), + ); + act(() => { + result.current.applyShiftClick(ROW_D, true); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c', 'd'], toDeselect: ['e']}); + act(() => { + result.current.applyShiftClick(ROW_C, true); + }); + expect(nthBatchKeys(onApplyRange, 1)).toEqual({toSelect: ['b', 'c'], toDeselect: ['d']}); + }); + + it('adopts the block when the remembered anchor is gone, since that session is over', () => { + const onApplyRange = makeApplyMock(); + const {result, rerender} = renderHook( + ({items}: {items: Row[]}) => useShiftRangeSelection(makeParams({items, onApplyRange, isItemSelected: (row) => row.keyForList !== 'a', isItemProtected: () => false})), + {initialProps: {items: [...ROWS]}}, + ); + // Anchored on 'a', which the next render drops from the list + act(() => result.current.notifyAnchor(ROW_A)); + rerender({items: ROWS.slice(1)}); + act(() => { + result.current.applyShiftClick(ROW_C, true); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c'], toDeselect: ['d', 'e']}); + }); + + it('leaves a block alone when the session already has an anchor, so an unrelated range cannot dissolve it', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => + useShiftRangeSelection( + makeParams({ + isItemSelected: (row) => row.keyForList === 'd' || row.keyForList === 'e', + isItemProtected: () => false, + onApplyRange, + }), + ), + ); + // A plain click starts the session, so the block was not part of it. + act(() => result.current.notifyAnchor(ROW_A)); + act(() => { + result.current.applyShiftClick(ROW_B, true); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b'], toDeselect: []}); + }); + it('selects on a cold shift+click even when every row is already selected', () => { const onApplyRange = makeApplyMock(); const {isItemSelected} = makeSelection('a', 'b', 'c', 'd', 'e'); diff --git a/tests/utils/MockSearchContextProvider.tsx b/tests/utils/MockSearchContextProvider.tsx index a27821d03796..df38b4220140 100644 --- a/tests/utils/MockSearchContextProvider.tsx +++ b/tests/utils/MockSearchContextProvider.tsx @@ -75,6 +75,9 @@ function splitActions(value: SearchActionsContextValue): { }, selection: { setSelectedTransactions: value.setSelectedTransactions, + getSelectedTransactions: value.getSelectedTransactions, + getExcludedTransactions: value.getExcludedTransactions, + getAreAllMatchingItemsSelected: value.getAreAllMatchingItemsSelected, applySelection: value.applySelection, setSelectedReports: value.setSelectedReports, setCurrentSelectedTransactionReportID: value.setCurrentSelectedTransactionReportID, @@ -88,13 +91,20 @@ function splitActions(value: SearchActionsContextValue): { function MockSearchContextProvider({state, actions, children}: MockSearchContextProviderProps) { const stateSlices = splitState(state); const actionsSlices = splitActions(actions); + // Answered from the state the checkboxes render from, so the range and the rows cannot read different selections. + const selectionActions: SearchSelectionActionsValue = { + ...actionsSlices.selection, + getSelectedTransactions: actions.getSelectedTransactions ?? (() => stateSlices.selection.selectedTransactions), + getExcludedTransactions: actions.getExcludedTransactions ?? (() => stateSlices.selection.excludedTransactions), + getAreAllMatchingItemsSelected: actions.getAreAllMatchingItemsSelected ?? (() => stateSlices.selection.areAllMatchingItemsSelected), + }; return ( - {children} + {children}