Skip to content
4 changes: 3 additions & 1 deletion src/components/MoneyReportHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ import type {PopoverMenuItem} from './PopoverMenu';
import {PressableWithFeedback} from './Pressable';
import ProcessMoneyReportHoldMenu from './ProcessMoneyReportHoldMenu';
import {useSearchContext} from './Search/SearchContext';
import {useSearchSelectionContext} from './Search/SearchSelectionContext';
import AnimatedSettlementButton from './SettlementButton/AnimatedSettlementButton';
import Text from './Text';

Expand Down Expand Up @@ -427,7 +428,8 @@ function MoneyReportHeader({
typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK
> | null>(null);

const {selectedTransactionIDs, removeTransaction, clearSelectedTransactions, currentSearchQueryJSON, currentSearchKey, currentSearchHash, currentSearchResults} = useSearchContext();
const {currentSearchQueryJSON, currentSearchKey, currentSearchHash, currentSearchResults} = useSearchContext();
const {selectedTransactionIDs, removeTransaction, clearSelectedTransactions} = useSearchSelectionContext();
const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true);

const [network] = useOnyx(ONYXKEYS.NETWORK);
Expand Down
4 changes: 3 additions & 1 deletion src/components/MoneyRequestHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar';
import MoneyRequestReportTransactionsNavigation from './MoneyRequestReportView/MoneyRequestReportTransactionsNavigation';
import {usePersonalDetails} from './OnyxListItemProvider';
import {useSearchContext} from './Search/SearchContext';
import {useSearchSelectionContext} from './Search/SearchSelectionContext';
import {useWideRHPState} from './WideRHPContextProvider';

type MoneyRequestHeaderProps = {
Expand Down Expand Up @@ -149,7 +150,8 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre
const isOnHold = isOnHoldTransactionUtils(transaction);
const isDuplicate = isDuplicateTransactionUtils(transaction, email ?? '', accountID, report, policy, transactionViolations);
const reportID = report?.reportID;
const {removeTransaction, currentSearchHash} = useSearchContext();
const {currentSearchHash} = useSearchContext();
const {removeTransaction} = useSearchSelectionContext();
const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction);
const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION);
const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import {usePersonalDetails} from '@components/OnyxListItemProvider';
import {PressableWithFeedback} from '@components/Pressable';
import ScrollView from '@components/ScrollView';
import {useSearchContext} from '@components/Search/SearchContext';
import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext';
import Text from '@components/Text';
import useConfirmModal from '@hooks/useConfirmModal';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
Expand Down Expand Up @@ -196,7 +196,7 @@
const [enableScrollToEnd, setEnableScrollToEnd] = useState<boolean>(false);
const [lastActionEventId, setLastActionEventId] = useState<string>('');

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

useFilterSelectedTransactions(transactions);

Expand Down Expand Up @@ -674,7 +674,7 @@
reportScrollManager.scrollToEnd();
readActionSkipped.current = false;
readNewestAction(report.reportID);
}, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, report.reportID]);

Check warning on line 677 in src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

React Hook useCallback has a missing dependency: 'introSelected'. Either include it or remove the dependency array

Check warning on line 677 in src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

React Hook useCallback has a missing dependency: 'introSelected'. Either include it or remove the dependency array

Check warning on line 677 in src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

React Hook useCallback has a missing dependency: 'introSelected'. Either include it or remove the dependency array

const scrollToNewTransaction = useCallback(
(pageY: number) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import MenuItem from '@components/MenuItem';
import Modal from '@components/Modal';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import {useSearchContext} from '@components/Search/SearchContext';
import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext';
import type {SortOrder} from '@components/Search/types';
import Text from '@components/Text';
import {useWideRHPActions} from '@components/WideRHPContextProvider';
Expand Down Expand Up @@ -207,7 +207,7 @@
return hasPendingDeletionTransaction || transactions.some(getTransactionPendingAction);
}, [hasPendingDeletionTransaction, transactions]);

const {selectedTransactionIDs, setSelectedTransactions, clearSelectedTransactions} = useSearchContext();
const {selectedTransactionIDs, setSelectedTransactions, clearSelectedTransactions} = useSearchSelectionContext();
useHandleSelectionMode(selectedTransactionIDs);
const isMobileSelectionModeEnabled = useMobileSelectionMode();

Expand Down Expand Up @@ -310,7 +310,7 @@
return groupTransactionsByTag(sortedTransactions, report, localeCompare);
}
return groupTransactionsByCategory(sortedTransactions, report, localeCompare);
}, [sortedTransactions, currentGroupBy, report?.reportID, report?.currency, localeCompare, shouldShowGroupedTransactions]);

Check warning on line 313 in src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

React Hook useMemo has a missing dependency: 'report'. Either include it or remove the dependency array

Check warning on line 313 in src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

React Hook useMemo has a missing dependency: 'report'. Either include it or remove the dependency array

Check warning on line 313 in src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

React Hook useMemo has a missing dependency: 'report'. Either include it or remove the dependency array

const visualOrderTransactionIDs = useMemo(() => {
if (!shouldShowGroupedTransactions || groupedTransactions.length === 0) {
Expand Down
29 changes: 29 additions & 0 deletions src/components/Search/SearchBulkActionsModalContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React, {createContext, useContext} from 'react';

Check failure on line 1 in src/components/Search/SearchBulkActionsModalContext.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

'React' is defined but never used

Check failure on line 1 in src/components/Search/SearchBulkActionsModalContext.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

'React' is defined but never used

Check failure on line 1 in src/components/Search/SearchBulkActionsModalContext.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

'React' is defined but never used

Check failure on line 1 in src/components/Search/SearchBulkActionsModalContext.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

'React' is defined but never used
import type {ValueOf} from 'type-fest';
import type CONST from '@src/CONST';

type RejectModalActionType = ValueOf<typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD> | ValueOf<typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT>;

export type SearchBulkActionsModalContextValue = {

Check failure on line 7 in src/components/Search/SearchBulkActionsModalContext.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Do not inline named exports

Check failure on line 7 in src/components/Search/SearchBulkActionsModalContext.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

Do not inline named exports

Check failure on line 7 in src/components/Search/SearchBulkActionsModalContext.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

Do not inline named exports
setIsOfflineModalVisible: (visible: boolean) => void;
setRejectModalAction: (action: RejectModalActionType | null) => void;
setIsHoldEducationalModalVisible: (visible: boolean) => void;
setIsDownloadErrorModalVisible: (visible: boolean) => void;
setEmptyReportsCount: (count: number) => void;
};

const defaultValue: SearchBulkActionsModalContextValue = {
setIsOfflineModalVisible: () => {},
setRejectModalAction: () => {},
setIsHoldEducationalModalVisible: () => {},
setIsDownloadErrorModalVisible: () => {},
setEmptyReportsCount: () => {},
};

const SearchBulkActionsModalContext = createContext<SearchBulkActionsModalContextValue>(defaultValue);

function useSearchBulkActionsModal() {
return useContext(SearchBulkActionsModalContext);
}

export {SearchBulkActionsModalContext, useSearchBulkActionsModal};
170 changes: 3 additions & 167 deletions src/components/Search/SearchContext.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import React, {useCallback, useContext, useMemo, useRef, useState} from 'react';
import React, {useCallback, useContext, useMemo, useState} from 'react';
// We need direct access to useOnyx from react-native-onyx to avoid circular dependencies in SearchContext
// eslint-disable-next-line no-restricted-imports
import {useOnyx} from 'react-native-onyx';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useTodos from '@hooks/useTodos';
import {isMoneyRequestReport} from '@libs/ReportUtils';
import {getSuggestedSearches, isTodoSearch, isTransactionListItemType, isTransactionReportGroupListItemType} from '@libs/SearchUIUtils';
import {getSuggestedSearches, isTodoSearch} from '@libs/SearchUIUtils';
import type {SearchKey} from '@libs/SearchUIUtils';
import {hasValidModifiedAmount} from '@libs/TransactionUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {SearchResults} from '@src/types/onyx';
import type {SearchResultsInfo} from '@src/types/onyx/SearchResults';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type {SearchContextData, SearchContextProps, SearchQueryJSON, SelectedTransactions} from './types';
import type {SearchContextData, SearchContextProps, SearchQueryJSON} from './types';

// Default search info when building from live data
// Used for to-do searches where we build SearchResults from live Onyx data instead of API snapshots
Expand All @@ -35,47 +32,29 @@ const defaultSearchContextData: SearchContextData = {
currentSearchKey: undefined,
currentSearchQueryJSON: undefined,
currentSearchResults: undefined,
selectedTransactions: {},
selectedTransactionIDs: [],
selectedReports: [],
isOnSearch: false,
shouldTurnOffSelectionMode: false,
shouldResetSearchQuery: false,
};

const defaultSearchContext: SearchContextProps = {
...defaultSearchContextData,
lastSearchType: undefined,
areAllMatchingItemsSelected: false,
showSelectAllMatchingItems: false,
shouldShowFiltersBarLoading: false,
currentSearchResults: undefined,
shouldUseLiveData: false,
setLastSearchType: () => {},
setCurrentSearchHashAndKey: () => {},
setCurrentSearchQueryJSON: () => {},
setSelectedTransactions: () => {},
removeTransaction: () => {},
clearSelectedTransactions: () => {},
setShouldShowFiltersBarLoading: () => {},
shouldShowSelectAllMatchingItems: () => {},
selectAllMatchingItems: () => {},
setShouldResetSearchQuery: () => {},
};

const SearchContext = React.createContext<SearchContextProps>(defaultSearchContext);

function SearchContextProvider({children}: ChildrenProps) {
const [showSelectAllMatchingItems, shouldShowSelectAllMatchingItems] = useState(false);
const [areAllMatchingItemsSelected, selectAllMatchingItems] = useState(false);
const [shouldShowFiltersBarLoading, setShouldShowFiltersBarLoading] = useState(false);
const [lastSearchType, setLastSearchType] = useState<string | undefined>(undefined);
const [searchContextData, setSearchContextData] = useState(defaultSearchContextData);
const areTransactionsEmpty = useRef(true);

// Use a ref to access searchContextData in callbacks without causing callback reference changes
const searchContextDataRef = useRef(searchContextData);
searchContextDataRef.current = searchContextData;

const [snapshotSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${searchContextData.currentSearchHash}`);
const todoSearchResultsData = useTodos();
Expand Down Expand Up @@ -136,131 +115,6 @@ function SearchContextProvider({children}: ChildrenProps) {
});
}, []);

const setSelectedTransactions: SearchContextProps['setSelectedTransactions'] = useCallback((selectedTransactions, data = []) => {
if (selectedTransactions instanceof Array) {
if (!selectedTransactions.length && areTransactionsEmpty.current) {
areTransactionsEmpty.current = true;
return;
}
areTransactionsEmpty.current = false;
return setSearchContextData((prevState) => ({
...prevState,
selectedTransactionIDs: selectedTransactions,
}));
}

// When selecting transactions, we also need to manage the reports to which these transactions belong. This is done to ensure proper exporting to CSV.
let selectedReports: SearchContextProps['selectedReports'] = [];

if (data.length && data.every(isTransactionReportGroupListItemType)) {
selectedReports = data
.filter((item) => {
if (!isMoneyRequestReport(item)) {
return false;
}
if (item.transactions.length === 0) {
return !!item.keyForList && selectedTransactions[item.keyForList]?.isSelected;
}
return item.transactions.every(({keyForList}) => selectedTransactions[keyForList]?.isSelected);
})
.map(({reportID, action = CONST.SEARCH.ACTION_TYPES.VIEW, total = CONST.DEFAULT_NUMBER_ID, policyID, allActions = [action], currency, chatReportID}) => ({
reportID,
action,
total,
policyID,
allActions,
currency,
chatReportID,
}));
} else if (data.length && data.every(isTransactionListItemType)) {
selectedReports = data
.filter(({keyForList}) => !!keyForList && selectedTransactions[keyForList]?.isSelected)
.map((item) => {
const total = hasValidModifiedAmount(item) ? Number(item.modifiedAmount) : (item.amount ?? CONST.DEFAULT_NUMBER_ID);
const action = item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW;

return {
reportID: item.reportID,
action,
total,
policyID: item.policyID,
allActions: item.allActions ?? [action],
currency: item.currency,
chatReportID: item.report?.chatReportID,
};
});
}

setSearchContextData((prevState) => ({
...prevState,
selectedTransactions,
shouldTurnOffSelectionMode: false,
selectedReports,
}));
}, []);

const clearSelectedTransactions: SearchContextProps['clearSelectedTransactions'] = useCallback(
(searchHashOrClearIDsFlag, shouldTurnOffSelectionMode = false) => {
if (typeof searchHashOrClearIDsFlag === 'boolean') {
setSelectedTransactions([]);
return;
}

const data = searchContextDataRef.current;

if (searchHashOrClearIDsFlag === data.currentSearchHash) {
return;
}

if (data.selectedReports.length === 0 && isEmptyObject(data.selectedTransactions) && !data.shouldTurnOffSelectionMode) {
return;
}
setSearchContextData((prevState) => ({
...prevState,
shouldTurnOffSelectionMode,
selectedTransactions: {},
selectedReports: [],
}));

// Unselect all transactions and hide the "select all matching items" option
shouldShowSelectAllMatchingItems(false);
selectAllMatchingItems(false);
},
[setSelectedTransactions],
);

const removeTransaction: SearchContextProps['removeTransaction'] = useCallback(
(transactionID) => {
if (!transactionID) {
return;
}
const selectedTransactionIDs = searchContextData.selectedTransactionIDs;

if (!isEmptyObject(searchContextData.selectedTransactions)) {
const newSelectedTransactions = Object.entries(searchContextData.selectedTransactions).reduce((acc, [key, value]) => {
if (key === transactionID) {
return acc;
}
acc[key] = value;
return acc;
}, {} as SelectedTransactions);

setSearchContextData((prevState) => ({
...prevState,
selectedTransactions: newSelectedTransactions,
}));
}

if (selectedTransactionIDs.length > 0) {
setSearchContextData((prevState) => ({
...prevState,
selectedTransactionIDs: selectedTransactionIDs.filter((ID) => transactionID !== ID),
}));
}
},
[searchContextData.selectedTransactionIDs, searchContextData.selectedTransactions],
);

const setShouldResetSearchQuery = useCallback((shouldReset: boolean) => {
setSearchContextData((prevState) => ({
...prevState,
Expand All @@ -273,47 +127,29 @@ function SearchContextProvider({children}: ChildrenProps) {
...searchContextData,
currentSearchResults,
shouldUseLiveData,
removeTransaction,
setCurrentSearchHashAndKey,
setCurrentSearchQueryJSON,
setSelectedTransactions,
clearSelectedTransactions,
shouldShowFiltersBarLoading,
setShouldShowFiltersBarLoading,
lastSearchType,
setLastSearchType,
showSelectAllMatchingItems,
shouldShowSelectAllMatchingItems,
areAllMatchingItemsSelected,
selectAllMatchingItems,
setShouldResetSearchQuery,
}),
[
searchContextData,
currentSearchResults,
shouldUseLiveData,
removeTransaction,
setCurrentSearchHashAndKey,
setCurrentSearchQueryJSON,
setSelectedTransactions,
clearSelectedTransactions,
shouldShowFiltersBarLoading,
lastSearchType,
shouldShowSelectAllMatchingItems,
showSelectAllMatchingItems,
areAllMatchingItemsSelected,
setShouldResetSearchQuery,
],
);

return <SearchContext.Provider value={searchContext}>{children}</SearchContext.Provider>;
}

/**
* Note: `selectedTransactionIDs` and `selectedTransactions` are two separate properties.
* Setting or clearing one of them does not influence the other.
* IDs should be used if transaction details are not required.
*/
function useSearchContext() {
return useContext(SearchContext);
}
Expand Down
14 changes: 7 additions & 7 deletions src/components/Search/SearchPageFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,11 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useSearchFooter from '@hooks/useSearchFooter';
import {convertToDisplayString} from '@libs/CurrencyUtils';

type SearchPageFooterProps = {
count: number | undefined;
total: number | undefined;
currency: string | undefined;
};

function SearchPageFooter({count, total, currency}: SearchPageFooterProps) {
function SearchPageFooter() {
const {count, total, currency, shouldShow} = useSearchFooter();
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
Expand All @@ -26,6 +22,10 @@ function SearchPageFooter({count, total, currency}: SearchPageFooterProps) {

const valueTextStyle = useMemo(() => (isOffline ? [styles.textLabelSupporting, styles.labelStrong] : [styles.labelStrong]), [isOffline, styles]);

if (!shouldShow) {
return null;
}

return (
<View
style={[
Expand Down
Loading
Loading