Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
febd5a9
feat: Shift+Click Range Selection Not Supported in Multi-Select Lists
TaduJR May 31, 2026
e7eccfb
fix: shift-range session continuity (deselect-jump, shift-shrink-stuck)
TaduJR May 31, 2026
479ed37
fix: restore Reports view shift+click (gate flatten + isHeaderItem on…
TaduJR May 31, 2026
3fd430f
fix: shift+click on report group header + add hook unit tests
TaduJR May 31, 2026
d7200e1
fix: guard stale prevEnd in shift-range session
TaduJR May 31, 2026
55f063e
fix: shift+click on Search group header + resolve anchor from real se…
TaduJR May 31, 2026
efdfd4a
fix: anchor resolution + Select All + native Shift+Arrow trigger
TaduJR May 31, 2026
55b4e76
fix: restore selection-button onSelectRow fallback when onSelectionBu…
TaduJR May 31, 2026
000258e
fix: flatten Reports view children into shift-range items
TaduJR May 31, 2026
28bd608
chore: rephrase comment to satisfy cspell
TaduJR May 31, 2026
de19a1a
test: update header + group-item assertions for forwarded shiftKey arg
TaduJR May 31, 2026
4a60e0a
Merge branch 'main' of https://github.com/TaduJR/App into feat-New-Fe…
TaduJR Jun 2, 2026
e70bb1e
chore: remove Shift+Arrow keyboard nav
TaduJR Jun 2, 2026
c9eb6c7
feat: additive shift+click (Shift+Cmd / Shift+Ctrl) for range selection
TaduJR Jun 2, 2026
27055cb
refactor: hook architecture, header generics
TaduJR Jun 2, 2026
3e8a13e
fix: preserve additive modifier in group-header shift+click
TaduJR Jun 2, 2026
25aea5e
fix: exclude non-empty group rows from shift-range in Reports view
TaduJR Jun 2, 2026
4516bb0
fix: tag shift-range child transactions with parent groupKey
TaduJR Jun 2, 2026
e546186
Merge branch 'main' of https://github.com/TaduJR/App into feat-New-Fe…
TaduJR Jun 2, 2026
d5d1838
Merge branch 'main' of https://github.com/TaduJR/App into feat-New-Fe…
TaduJR Jun 3, 2026
3a4ae64
fix: useRef for session state; getAnchorKey mirrors applyShiftClick r…
TaduJR Jun 3, 2026
769f535
fix: resolve stale ranging anchor before computing a shift+click range
TaduJR Jun 3, 2026
f88da98
Merge branch 'main' of https://github.com/TaduJR/App into feat-New-Fe…
TaduJR Jun 3, 2026
9bb0141
Merge branch 'main' of https://github.com/TaduJR/App into feat-New-Fe…
TaduJR Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import type {GestureResponderEvent} from 'react-native';
import {View} from 'react-native';
import Checkbox from '@components/Checkbox';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
Expand All @@ -10,6 +11,8 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {getDecodedLeafCategoryName} from '@libs/CategoryUtils';
import {getCommaSeparatedTagNameWithSanitizedColons} from '@libs/PolicyUtils';
import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection';
import type {Modifiers} from '@libs/shiftRangeSelection';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import type {GroupedTransactions} from '@src/types/onyx';
Expand Down Expand Up @@ -41,7 +44,7 @@ type MoneyRequestReportGroupHeaderProps = {
isDisabled?: boolean;

/** Callback when group checkbox is toggled - receives groupKey */
onToggleSelection?: (groupKey: string) => void;
onToggleSelection?: (groupKey: string, options?: Partial<Modifiers>) => void;

/** Pending action for offline feedback styling (Pattern B - Optimistic WITH Feedback) */
pendingAction?: PendingAction;
Expand Down Expand Up @@ -77,8 +80,8 @@ function MoneyRequestReportGroupHeader({

const textStyle = shouldUseNarrowLayout ? {fontSize: variables.fontSizeLabel, lineHeight: 16} : [styles.labelStrong];

const handleToggleSelection = () => {
onToggleSelection?.(groupKey);
const handleToggleSelection = (event?: GestureResponderEvent | KeyboardEvent) => {
onToggleSelection?.(groupKey, getModifierKeysFromEvent(event));
};

const groupHeaderStyle = !shouldUseNarrowLayout
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import useTransactionInlineEdit from '@hooks/useTransactionInlineEdit';
import ControlSelection from '@libs/ControlSelection';
import canUseTouchScreen from '@libs/DeviceCapabilities/canUseTouchScreen';
import {hasFlexColumn} from '@libs/SearchUIUtils';
import type {Modifiers} from '@libs/shiftRangeSelection';
import {getTransactionPendingAction, isTransactionPendingDelete} from '@libs/TransactionUtils';
import variables from '@styles/variables';
import CONST from '@src/CONST';
Expand All @@ -41,7 +42,7 @@ type MoneyRequestReportTransactionItemProps = {
isSelectionModeEnabled: boolean;

/** Callback function triggered upon pressing a transaction checkbox. */
toggleTransaction: (transactionID: string) => void;
toggleTransaction: (transactionID: string, options?: Partial<Modifiers>) => void;

/** Callback function triggered upon pressing a transaction. */
handleOnPress: (transactionID: string) => void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import useOnyx from '@hooks/useOnyx';
import useReportIsArchived from '@hooks/useReportIsArchived';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP';
import useShiftRangeSelection from '@hooks/useShiftRangeSelection';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
Expand Down Expand Up @@ -62,6 +63,8 @@ import {
} from '@libs/ReportUtils';
import type {SortableColumnName} from '@libs/ReportUtils';
import {compareValues, getColumnsToShow, getTableMinWidth, hasFlexColumn, isTransactionAmountTooLong, isTransactionTaxAmountTooLong} from '@libs/SearchUIUtils';
import {applyShiftRangeBatchToKeySet, farthestEndFromAnchor} from '@libs/shiftRangeSelection';
import type {Modifiers} from '@libs/shiftRangeSelection';
import {getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction';
import {transactionHasRBR} from '@libs/TransactionPreviewUtils';
import {getTransactionPendingAction, getVisibleTransactionViolations, isTransactionPendingDelete, shouldShowExpenseBreakdown} from '@libs/TransactionUtils';
Expand Down Expand Up @@ -251,19 +254,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(
Expand Down Expand Up @@ -451,12 +441,46 @@ function MoneyRequestReportTransactionList({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resolvedTransactions, currentGroupBy, report?.reportID, report?.currency, localeCompare, shouldShowGroupedTransactions]);

const visualOrderTransactionIDs = useMemo(() => {
if (!shouldShowGroupedTransactions || 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, shouldShowGroupedTransactions]);
// Visual order, not the prop's DB-insertion order.
const visualOrderTransactions = useMemo(
() => (shouldShowGroupedTransactions && groupedTransactions.length > 0 ? groupedTransactions.flatMap((group) => group.transactions) : resolvedTransactions),
[groupedTransactions, resolvedTransactions, shouldShowGroupedTransactions],
);

const visualOrderTransactionIDs = useMemo(
() => visualOrderTransactions.filter((transaction) => !isTransactionPendingDelete(transaction)).map((transaction) => transaction.transactionID),
[visualOrderTransactions],
);

const rangeApi = useShiftRangeSelection<OnyxTypes.Transaction>({
items: visualOrderTransactions,
getItemKey: (t) => t.transactionID ?? null,
getSelectedKeys: () => selectedTransactionIDs,
isDisabledItem: (t) => isTransactionPendingDelete(t),
onApplyRange: (batch) =>
setSelectedTransactions(
applyShiftRangeBatchToKeySet(
batch,
selectedTransactionIDs,
(t) => t.transactionID,
(t) => !isTransactionPendingDelete(t),
),
),
});

const toggleTransaction = useCallback(
(transactionID: string, options?: Partial<Modifiers>) => {
const item = visualOrderTransactions.find((t) => t.transactionID === transactionID);
if (item && rangeApi.applyShiftClick(item, options)) {
return;
}
setSelectedTransactions(selectedTransactionIDs.includes(transactionID) ? selectedTransactionIDs.filter((t) => t !== transactionID) : [...selectedTransactionIDs, transactionID]);
if (item) {
rangeApi.notifyAnchor(item);
}
},
[setSelectedTransactions, selectedTransactionIDs, visualOrderTransactions, rangeApi],
);

// Primitive proxy for visualOrderTransactionIDs used as the effect dependency below.
// Other callers (e.g. TransactionDuplicateReview.onPreviewPressed) can write to the same
Expand Down Expand Up @@ -502,23 +526,37 @@ function MoneyRequestReportTransactionList({
}, [groupedTransactions, selectedTransactionIDs]);

const toggleGroupSelection = useCallback(
(groupKey: string) => {
(groupKey: string, options?: Partial<Modifiers>) => {
const group = groupedTransactions.find((g) => g.groupKey === groupKey);
if (!group) {
return;
}
const groupTransactionIDs = group.transactions.filter((t) => !isTransactionPendingDelete(t)).map((t) => t.transactionID);
const anySelected = groupTransactionIDs.some((id) => selectedTransactionIDs.includes(id));
const selectableChildren = group.transactions.filter((t) => !isTransactionPendingDelete(t));

if (options?.shiftKey && selectableChildren.length > 0) {
const firstChild = selectableChildren.at(0);
const lastChild = selectableChildren.at(-1);
if (firstChild && lastChild) {
const anchorKey = rangeApi.getAnchorKey();
const anchorIdx = anchorKey ? visualOrderTransactions.findIndex((t) => t.transactionID === anchorKey) : -1;
const firstIdx = visualOrderTransactions.findIndex((t) => t.transactionID === firstChild.transactionID);
const lastIdx = visualOrderTransactions.findIndex((t) => t.transactionID === lastChild.transactionID);
const target = farthestEndFromAnchor(firstIdx, lastIdx, anchorIdx) === 'first' ? firstChild : lastChild;
Comment thread
TaduJR marked this conversation as resolved.
if (rangeApi.applyShiftClick(target, options)) {
return;
}
}
}

let newSelectedTransactionIDs = selectedTransactionIDs;
if (anySelected) {
newSelectedTransactionIDs = selectedTransactionIDs.filter((id) => !groupTransactionIDs.includes(id));
} else {
newSelectedTransactionIDs = [...selectedTransactionIDs, ...groupTransactionIDs];
const groupTransactionIDs = selectableChildren.map((t) => t.transactionID);
const anySelected = groupTransactionIDs.some((id) => selectedTransactionIDs.includes(id));
setSelectedTransactions(anySelected ? selectedTransactionIDs.filter((id) => !groupTransactionIDs.includes(id)) : [...selectedTransactionIDs, ...groupTransactionIDs]);
const firstChild = selectableChildren.at(0);
if (firstChild) {
rangeApi.notifyAnchor(firstChild);
}
setSelectedTransactions(newSelectedTransactionIDs);
},
[groupedTransactions, selectedTransactionIDs, setSelectedTransactions],
[groupedTransactions, selectedTransactionIDs, setSelectedTransactions, rangeApi, visualOrderTransactions],
);

/**
Expand Down Expand Up @@ -794,6 +832,7 @@ function MoneyRequestReportTransactionList({
} else {
setSelectedTransactions(transactionsWithoutPendingDelete.map((t) => t.transactionID));
}
rangeApi.clearAnchor();
}}
accessibilityLabel={translate('accessibilityHints.selectAllTransactions')}
isIndeterminate={selectedTransactionIDs.length > 0 && selectedTransactionIDs.length !== transactionsWithoutPendingDelete.length}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import React from 'react';
import {View} from 'react-native';
import Checkbox from '@components/Checkbox';
import type {SearchColumnType} from '@components/Search/types';
import type {ListItem} from '@components/SelectionList/types';
import TextWithTooltip from '@components/TextWithTooltip';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection';
import type {Modifiers} from '@libs/shiftRangeSelection';
import CONST from '@src/CONST';
import ExpandCollapseArrowButton from './ExpandCollapseArrowButton';
import TextCell from './TextCell';
Expand Down Expand Up @@ -46,7 +47,7 @@ type ColumnStyleKey =
| typeof CONST.SEARCH.TABLE_COLUMNS.GROUP_YEAR
| typeof CONST.SEARCH.TABLE_COLUMNS.GROUP_QUARTER;

type BaseListItemHeaderProps<TItem extends ListItem> = {
type BaseListItemHeaderProps = {
/** The group item being rendered */
item: BaseGroupListItemType;

Expand All @@ -60,7 +61,7 @@ type BaseListItemHeaderProps<TItem extends ListItem> = {
columnStyleKey: ColumnStyleKey;

/** Callback to fire when a checkbox is pressed */
onCheckboxPress?: (item: TItem) => void;
onCheckboxPress?: (options?: Partial<Modifiers>) => void;

/** Whether this section items disabled for selection */
isDisabled?: boolean | null;
Expand All @@ -84,7 +85,7 @@ type BaseListItemHeaderProps<TItem extends ListItem> = {
columns?: SearchColumnType[];
};

function BaseListItemHeader<TItem extends ListItem>({
function BaseListItemHeader({
item,
displayName,
groupColumnKey,
Expand All @@ -97,7 +98,7 @@ function BaseListItemHeader<TItem extends ListItem>({
isExpanded,
onDownArrowClick,
columns,
}: BaseListItemHeaderProps<TItem>) {
}: BaseListItemHeaderProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {isLargeScreenWidth} = useResponsiveLayout();
Expand Down Expand Up @@ -144,7 +145,7 @@ function BaseListItemHeader<TItem extends ListItem>({
<View style={[styles.flexRow, styles.alignItemsCenter, styles.mnh40, styles.flex1, styles.gap3]}>
{!!canSelectMultiple && (
<Checkbox
onPress={() => onCheckboxPress?.(item as unknown as TItem)}
onPress={(event) => onCheckboxPress?.(getModifierKeysFromEvent(event))}
isChecked={isSelectAllChecked}
isIndeterminate={isIndeterminate}
disabled={!!isDisabled || item.isDisabledCheckbox}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {View} from 'react-native';
import Checkbox from '@components/Checkbox';
import ReportActionAvatars from '@components/ReportActionAvatars';
import type {SearchColumnType} from '@components/Search/types';
import type {ListItem} from '@components/SelectionList/types';
import TextWithTooltip from '@components/TextWithTooltip';
import UserDetailsTooltip from '@components/UserDetailsTooltip';
import useLocalize from '@hooks/useLocalize';
Expand All @@ -12,19 +11,21 @@ import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils';
import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection';
import type {Modifiers} from '@libs/shiftRangeSelection';
import CONST from '@src/CONST';
import type {CompanyCardFeed} from '@src/types/onyx/CardFeeds';
import ExpandCollapseArrowButton from './ExpandCollapseArrowButton';
import TextCell from './TextCell';
import TotalCell from './TotalCell';
import type {TransactionCardGroupListItemType} from './types';

type CardListItemHeaderProps<TItem extends ListItem> = {
type CardListItemHeaderProps = {
/** The card currently being looked at */
card: TransactionCardGroupListItemType;

/** Callback to fire when a checkbox is pressed */
onCheckboxPress?: (item: TItem) => void;
onCheckboxPress?: (options?: Partial<Modifiers>) => void;

/** Whether this section items disabled for selection */
isDisabled?: boolean | null;
Expand All @@ -51,7 +52,7 @@ type CardListItemHeaderProps<TItem extends ListItem> = {
columns?: SearchColumnType[];
};

function CardListItemHeader<TItem extends ListItem>({
function CardListItemHeader({
card: cardItem,
onCheckboxPress,
isDisabled,
Expand All @@ -62,7 +63,7 @@ function CardListItemHeader<TItem extends ListItem>({
onDownArrowClick,
columns,
isExpanded,
}: CardListItemHeaderProps<TItem>) {
}: CardListItemHeaderProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {isLargeScreenWidth} = useResponsiveLayout();
Expand Down Expand Up @@ -144,7 +145,7 @@ function CardListItemHeader<TItem extends ListItem>({
<View style={[styles.flexRow, styles.alignItemsCenter, styles.mnh40, styles.flex1, styles.gap3]}>
{!!canSelectMultiple && (
<Checkbox
onPress={() => onCheckboxPress?.(cardItem as unknown as TItem)}
onPress={(event) => onCheckboxPress?.(getModifierKeysFromEvent(event))}
isChecked={isSelectAllChecked}
isIndeterminate={isIndeterminate}
disabled={!!isDisabled || cardItem.isDisabledCheckbox}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,16 @@
import React from 'react';
import type {ListItem} from '@components/SelectionList/types';
import useLocalize from '@hooks/useLocalize';
import CONST from '@src/CONST';
import type {BaseListItemHeaderProps} from './BaseListItemHeader';
import BaseListItemHeader from './BaseListItemHeader';
import type {TransactionCategoryGroupListItemType} from './types';

type CategoryListItemHeaderProps<TItem extends ListItem> = Omit<BaseListItemHeaderProps<TItem>, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & {
type CategoryListItemHeaderProps = Omit<BaseListItemHeaderProps, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & {
/** The category currently being looked at */
category: TransactionCategoryGroupListItemType;
};

function CategoryListItemHeader<TItem extends ListItem>({
category: categoryItem,
onCheckboxPress,
isDisabled,
canSelectMultiple,
isSelectAllChecked,
isIndeterminate,
isExpanded,
onDownArrowClick,
columns,
}: CategoryListItemHeaderProps<TItem>) {
function CategoryListItemHeader({category: categoryItem, ...baseProps}: CategoryListItemHeaderProps) {
const {translate} = useLocalize();

// formattedCategory is pre-decoded in SearchUIUtils, just translate empty values
Expand All @@ -30,18 +19,11 @@ function CategoryListItemHeader<TItem extends ListItem>({

return (
<BaseListItemHeader
{...baseProps}
item={categoryItem}
displayName={categoryName}
groupColumnKey={CONST.SEARCH.TABLE_COLUMNS.GROUP_CATEGORY}
columnStyleKey={CONST.SEARCH.TABLE_COLUMNS.CATEGORY}
onCheckboxPress={onCheckboxPress}
isDisabled={isDisabled}
canSelectMultiple={canSelectMultiple}
isSelectAllChecked={isSelectAllChecked}
isIndeterminate={isIndeterminate}
isExpanded={isExpanded}
onDownArrowClick={onDownArrowClick}
columns={columns}
/>
);
}
Expand Down
Loading
Loading