Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8103,6 +8103,7 @@ const CONST = {
THREAD_DIVIDER: 'Report-ThreadDivider',
PURE_REPORT_ACTION_ITEM: 'Report-PureReportActionItem',
MODERATION_BUTTON: 'Report-ModerationButton',
MONEY_REQUEST_REPORT_ACTIONS_LIST_SELECT_ALL: 'MoneyRequestReportActionsList-SelectAll',
},
SIDEBAR: {
SIGN_IN_BUTTON: 'Sidebar-SignInButton',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import ScrollView from '@components/ScrollView';
import {useSearchContext} from '@components/Search/SearchContext';
import Text from '@components/Text';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useFilterSelectedTransactions from '@hooks/useFilterSelectedTransactions';
import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@hooks/useFlatListScrollKey';
import useLoadReportActions from '@hooks/useLoadReportActions';
import useLocalize from '@hooks/useLocalize';
Expand Down Expand Up @@ -185,6 +186,8 @@ function MoneyRequestReportActionsList({

const {selectedTransactionIDs, setSelectedTransactions, clearSelectedTransactions} = useSearchContext();

useFilterSelectedTransactions(transactions);

const isMobileSelectionModeEnabled = useMobileSelectionMode();
const [isExportWithTemplateModalVisible, setIsExportWithTemplateModalVisible] = useState(false);
const beginExportWithTemplate = useCallback(
Expand Down Expand Up @@ -729,6 +732,7 @@ function MoneyRequestReportActionsList({
role="button"
accessibilityState={{checked: isSelectAllChecked}}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
sentryLabel={CONST.SENTRY_LABEL.REPORT.MONEY_REQUEST_REPORT_ACTIONS_LIST_SELECT_ALL}
>
<Text style={[styles.textStrong, styles.ph3]}>{translate('workspace.people.selectAll')}</Text>
</PressableWithFeedback>
Expand Down
25 changes: 25 additions & 0 deletions src/hooks/useFilterSelectedTransactions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {useEffect, useMemo} from 'react';
import {useSearchContext} from '@components/Search/SearchContext';
import type {Transaction} from '@src/types/onyx';

/**
* Hook that filters selected transaction IDs to only include transactions that exist in the provided list.
* This is useful when transactions are deleted and we need to clean up the selection state.
*
* @param transactions - The current list of transactions
*/
function useFilterSelectedTransactions(transactions: Transaction[]) {
const {selectedTransactionIDs, setSelectedTransactions} = useSearchContext();

const transactionIDs = useMemo(() => transactions.map((transaction) => transaction.transactionID), [transactions]);
const filteredSelectedTransactionIDs = useMemo(() => selectedTransactionIDs.filter((id) => transactionIDs.includes(id)), [selectedTransactionIDs, transactionIDs]);
Comment on lines +12 to +15

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

selectedTransactionIDs is global, but transactions is local, so they are only comparable when from the same report screen. Implemented in #85510.

useEffect(() => {
if (filteredSelectedTransactionIDs.length === selectedTransactionIDs.length) {
return;
}
setSelectedTransactions(filteredSelectedTransactionIDs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filteredSelectedTransactionIDs]);
}

export default useFilterSelectedTransactions;
160 changes: 160 additions & 0 deletions tests/unit/hooks/useFilterSelectedTransactionsTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import {renderHook} from '@testing-library/react-native';
import useFilterSelectedTransactions from '@hooks/useFilterSelectedTransactions';
import type {Transaction} from '@src/types/onyx';
import createRandomTransaction from '../../utils/collections/transaction';

// Mock variables that can be modified in tests
let mockSelectedTransactionIDs: string[] = [];
const mockSetSelectedTransactions = jest.fn();

jest.mock('@components/Search/SearchContext', () => ({
useSearchContext: () => ({
selectedTransactionIDs: mockSelectedTransactionIDs,
setSelectedTransactions: mockSetSelectedTransactions,
clearSelectedTransactions: jest.fn(),
selectedTransactions: {},
currentSearchHash: 12345,
}),
}));

describe('useFilterSelectedTransactions', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSelectedTransactionIDs = [];
});

it('should not call setSelectedTransactions when no transactions are selected', () => {
const transactions = [createRandomTransaction(1), createRandomTransaction(2)];
const trans0 = transactions.at(0);
const trans1 = transactions.at(1);
if (trans0) {
trans0.transactionID = 'trans1';
}
if (trans1) {
trans1.transactionID = 'trans2';
}

mockSelectedTransactionIDs = [];

renderHook(() => useFilterSelectedTransactions(transactions));

expect(mockSetSelectedTransactions).not.toHaveBeenCalled();
});

it('should not call setSelectedTransactions when all selected transactions exist in the list', () => {
const transactions = [createRandomTransaction(1), createRandomTransaction(2)];
const trans0 = transactions.at(0);
const trans1 = transactions.at(1);
if (trans0) {
trans0.transactionID = 'trans1';
}
if (trans1) {
trans1.transactionID = 'trans2';
}

mockSelectedTransactionIDs = ['trans1', 'trans2'];

renderHook(() => useFilterSelectedTransactions(transactions));

expect(mockSetSelectedTransactions).not.toHaveBeenCalled();
});

it('should filter out selected transactions that no longer exist in the transactions list', () => {
const transactions = [createRandomTransaction(1)];
const trans0 = transactions.at(0);
if (trans0) {
trans0.transactionID = 'trans1';
}

// trans2 and trans3 are selected but don't exist in transactions
mockSelectedTransactionIDs = ['trans1', 'trans2', 'trans3'];

renderHook(() => useFilterSelectedTransactions(transactions));

expect(mockSetSelectedTransactions).toHaveBeenCalledWith(['trans1']);
});

it('should clear all selections when all selected transactions are removed', () => {
const transactions = [createRandomTransaction(1)];
const trans0 = transactions.at(0);
if (trans0) {
trans0.transactionID = 'trans1';
}

// All selected transactions don't exist in the current list
mockSelectedTransactionIDs = ['trans2', 'trans3'];

renderHook(() => useFilterSelectedTransactions(transactions));

expect(mockSetSelectedTransactions).toHaveBeenCalledWith([]);
});

it('should update filtered transactions when transactions list changes', () => {
// Initial state: 3 transactions, 3 selected
const initialTransactions = [createRandomTransaction(1), createRandomTransaction(2), createRandomTransaction(3)];
const initTrans0 = initialTransactions.at(0);
const initTrans1 = initialTransactions.at(1);
const initTrans2 = initialTransactions.at(2);
if (initTrans0) {
initTrans0.transactionID = 'trans1';
}
if (initTrans1) {
initTrans1.transactionID = 'trans2';
}
if (initTrans2) {
initTrans2.transactionID = 'trans3';
}

mockSelectedTransactionIDs = ['trans1', 'trans2', 'trans3'];

const {rerender} = renderHook(({transactions}) => useFilterSelectedTransactions(transactions), {
initialProps: {transactions: initialTransactions},
});

// No filtering needed initially
expect(mockSetSelectedTransactions).not.toHaveBeenCalled();

// Now remove trans2 from the list (simulating deletion)
const updatedTransactions = [initialTransactions.at(0), initialTransactions.at(2)].filter((t): t is Transaction => !!t);

rerender({transactions: updatedTransactions});

// Should filter out trans2 from selection
expect(mockSetSelectedTransactions).toHaveBeenCalledWith(['trans1', 'trans3']);
});

it('should handle empty transactions list', () => {
const transactions: Transaction[] = [];

mockSelectedTransactionIDs = ['trans1', 'trans2'];

renderHook(() => useFilterSelectedTransactions(transactions));

// All selections should be cleared since no transactions exist
expect(mockSetSelectedTransactions).toHaveBeenCalledWith([]);
});

it('should preserve order of selected transactions after filtering', () => {
const transactions = [createRandomTransaction(1), createRandomTransaction(2), createRandomTransaction(3)];
const trans0 = transactions.at(0);
const trans1 = transactions.at(1);
const trans2 = transactions.at(2);
if (trans0) {
trans0.transactionID = 'trans1';
}
if (trans1) {
trans1.transactionID = 'trans2';
}
if (trans2) {
trans2.transactionID = 'trans3';
}

// Selected in specific order, with some non-existent IDs interspersed
mockSelectedTransactionIDs = ['trans3', 'nonexistent1', 'trans1', 'nonexistent2', 'trans2'];

renderHook(() => useFilterSelectedTransactions(transactions));

// Should maintain the original order of valid selections
expect(mockSetSelectedTransactions).toHaveBeenCalledWith(['trans3', 'trans1', 'trans2']);
});
});
Loading