diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index e29fe39c0e0a..9a5e13b7b380 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -3,7 +3,7 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import type {Parameters} from 'storybook/internal/types'; -import {MoneyRequestReportContextProvider} from '@components/MoneyRequestReportView/MoneyRequestReportContext'; +import {SearchContextProvider} from '@components/Search/SearchContext'; import ComposeProviders from '@src/components/ComposeProviders'; import HTMLEngineProvider from '@src/components/HTMLEngineProvider'; import {LocaleContextProvider} from '@src/components/LocaleContextProvider'; @@ -23,16 +23,7 @@ Onyx.init({ const decorators = [ (Story: React.ElementType) => ( diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 4355ab52da62..54badf640693 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -111,10 +111,10 @@ import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; import MoneyReportHeaderStatusBarSkeleton from './MoneyReportHeaderStatusBarSkeleton'; import type {MoneyRequestHeaderStatusBarProps} from './MoneyRequestHeaderStatusBar'; import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar'; -import {useMoneyRequestReportContext} from './MoneyRequestReportView/MoneyRequestReportContext'; import type {PopoverMenuItem} from './PopoverMenu'; import type {ActionHandledType} from './ProcessMoneyReportHoldMenu'; import ProcessMoneyReportHoldMenu from './ProcessMoneyReportHoldMenu'; +import {useSearchContext} from './Search/SearchContext'; import AnimatedSettlementButton from './SettlementButton/AnimatedSettlementButton'; import Text from './Text'; @@ -264,7 +264,7 @@ function MoneyReportHeader({ const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); - const {selectedTransactionsID, setSelectedTransactionsID} = useMoneyRequestReportContext(); + const {selectedTransactionIDs, clearSelectedTransactions} = useSearchContext(); const { options: selectedTransactionsOptions, @@ -881,8 +881,8 @@ function MoneyReportHeader({ if (!transactionThreadReportID) { return; } - setSelectedTransactionsID([]); - // We don't need to run the effect on change of setSelectedTransactionsID since it can cause the infinite loop. + clearSelectedTransactions(true); + // We don't need to run the effect on change of clearSelectedTransactions since it can cause the infinite loop. // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps }, [transactionThreadReportID]); @@ -909,7 +909,7 @@ function MoneyReportHeader({ { - setSelectedTransactionsID([]); + clearSelectedTransactions(true); turnOffMobileSelectionMode(); }} /> @@ -982,7 +982,7 @@ function MoneyReportHeader({ null} options={selectedTransactionsOptions} - customText={translate('workspace.common.selected', {count: selectedTransactionsID.length})} + customText={translate('workspace.common.selected', {count: selectedTransactionIDs.length})} isSplitButton={false} shouldAlwaysShowDropdownMenu /> @@ -1004,7 +1004,7 @@ function MoneyReportHeader({ null} options={selectedTransactionsOptions} - customText={translate('workspace.common.selected', {count: selectedTransactionsID.length})} + customText={translate('workspace.common.selected', {count: selectedTransactionIDs.length})} isSplitButton={false} shouldAlwaysShowDropdownMenu wrapperStyle={styles.w100} @@ -1100,11 +1100,11 @@ function MoneyReportHeader({ shouldEnableNewFocusManagement /> null} options={selectedTransactionsOptions} - customText={translate('workspace.common.selected', {count: selectedTransactionsID.length})} + customText={translate('workspace.common.selected', {count: selectedTransactionIDs.length})} isSplitButton={false} shouldAlwaysShowDropdownMenu wrapperStyle={[styles.w100, styles.ph5]} @@ -545,39 +545,39 @@ function MoneyRequestReportActionsList({ 0 && selectedTransactionsID.length !== transactions.length} + isChecked={selectedTransactionIDs.length === transactions.length} + isIndeterminate={selectedTransactionIDs.length > 0 && selectedTransactionIDs.length !== transactions.length} onPress={() => { - if (selectedTransactionsID.length !== 0) { - setSelectedTransactionsID([]); + if (selectedTransactionIDs.length !== 0) { + clearSelectedTransactions(true); } else { - setSelectedTransactionsID(transactions.filter((t) => !isTransactionPendingDelete(t)).map((t) => t.transactionID)); + setSelectedTransactions(transactions.filter((t) => !isTransactionPendingDelete(t)).map((t) => t.transactionID)); } }} /> { - if (selectedTransactionsID.length === transactions.length) { - setSelectedTransactionsID([]); + if (selectedTransactionIDs.length === transactions.length) { + clearSelectedTransactions(true); } else { - setSelectedTransactionsID(transactions.filter((t) => !isTransactionPendingDelete(t)).map((t) => t.transactionID)); + setSelectedTransactions(transactions.filter((t) => !isTransactionPendingDelete(t)).map((t) => t.transactionID)); } }} accessibilityLabel={translate('workspace.people.selectAll')} role="button" - accessibilityState={{checked: selectedTransactionsID.length === transactions.length}} + accessibilityState={{checked: selectedTransactionIDs.length === transactions.length}} dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} > {translate('workspace.people.selectAll')} void; - toggleTransaction: (transactionID: string) => void; - removeTransaction: (transactionID?: string) => void; - isTransactionSelected: (transactionID: string) => boolean; -}; - -const defaultMoneyRequestReportContext = { - selectedTransactionsID: [], - setSelectedTransactionsID: () => {}, - toggleTransaction: () => {}, - removeTransaction: () => {}, - isTransactionSelected: () => false, -}; - -const Context = React.createContext(defaultMoneyRequestReportContext); - -// TODO merge it with SearchContext in follow-up - https://github.com/Expensify/App/issues/59431 -function MoneyRequestReportContextProvider({children}: ChildrenProps) { - const [selectedTransactions, setSelectedTransactions] = useState([]); - - const setSelectedTransactionsID = useCallback((transactionsID: string[]) => { - setSelectedTransactions(transactionsID); - }, []); - - const toggleTransaction = useCallback((transactionID: string) => { - setSelectedTransactions((prev) => { - if (prev.includes(transactionID)) { - return prev.filter((t) => t !== transactionID); - } - return [...prev, transactionID]; - }); - }, []); - - const removeTransaction = useCallback((transactionID?: string) => { - setSelectedTransactions((prev) => { - return prev.filter((t) => t !== transactionID); - }); - }, []); - - const isTransactionSelected = useCallback((transactionID: string) => selectedTransactions.includes(transactionID), [selectedTransactions]); - - const context = useMemo( - () => ({ - selectedTransactionsID: selectedTransactions, - setSelectedTransactionsID, - toggleTransaction, - isTransactionSelected, - removeTransaction, - }), - [isTransactionSelected, removeTransaction, selectedTransactions, setSelectedTransactionsID, toggleTransaction], - ); - - return {children}; -} - -function useMoneyRequestReportContext() { - const context = useContext(Context); - - return context; -} - -MoneyRequestReportContextProvider.displayName = 'MoneyRequestReportContextProvider'; - -export {MoneyRequestReportContextProvider, useMoneyRequestReportContext}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 5634a845716d..7b338fb1d6ca 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -10,6 +10,7 @@ import * as Expensicons from '@components/Icon/Expensicons'; import MenuItem from '@components/MenuItem'; import Modal from '@components/Modal'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import {useSearchContext} from '@components/Search/SearchContext'; import type {SortOrder} from '@components/Search/types'; import Text from '@components/Text'; import TransactionItemRow from '@components/TransactionItemRow'; @@ -40,7 +41,6 @@ import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; -import {useMoneyRequestReportContext} from './MoneyRequestReportContext'; import MoneyRequestReportTableHeader from './MoneyRequestReportTableHeader'; import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyState'; import {setActiveTransactionThreadIDs} from './TransactionThreadReportIDRepository'; @@ -137,18 +137,36 @@ function MoneyRequestReportTransactionList({ const {bind} = useHover(); const {isMouseDownOnInput, setMouseUp} = useMouseContext(); - const {selectedTransactionsID, setSelectedTransactionsID, toggleTransaction, isTransactionSelected} = useMoneyRequestReportContext(); + const {selectedTransactionIDs, setSelectedTransactions, clearSelectedTransactions} = useSearchContext(); const {selectionMode} = 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( useCallback(() => { return () => { if (navigationRef?.getRootState()?.routes.at(-1)?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { return; } - setSelectedTransactionsID([]); + clearSelectedTransactions(true); }; - }, [setSelectedTransactionsID]), + // We don't need to run the effect on change of clearSelectedTransactions on every focus. + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []), ); const handleMouseLeave = (e: React.MouseEvent) => { @@ -216,15 +234,15 @@ function MoneyRequestReportTransactionList({ { - if (selectedTransactionsID.length !== 0) { - setSelectedTransactionsID([]); + if (selectedTransactionIDs.length !== 0) { + clearSelectedTransactions(true); } else { - setSelectedTransactionsID(transactions.filter((t) => !isTransactionPendingDelete(t)).map((t) => t.transactionID)); + setSelectedTransactions(transactions.filter((t) => !isTransactionPendingDelete(t)).map((t) => t.transactionID)); } }} accessibilityLabel={CONST.ROLE.CHECKBOX} - isIndeterminate={selectedTransactionsID.length > 0 && selectedTransactionsID.length !== transactions.length} - isChecked={selectedTransactionsID.length === transactions.length} + isIndeterminate={selectedTransactionIDs.length > 0 && selectedTransactionIDs.length !== transactions.length} + isChecked={selectedTransactionIDs.length === transactions.length} /> {isMediumScreenWidth && {translate('workspace.people.selectAll')}} diff --git a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx index e4e5aadf0a40..e2474a68a911 100644 --- a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx +++ b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx @@ -16,12 +16,13 @@ import useOnyx from '@hooks/useOnyx'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {convertToDisplayString} from '@libs/CurrencyUtils'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {calculateAmount} from '@libs/IOUUtils'; import {getAvatarsForAccountIDs} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import {getCleanedTagName} from '@libs/PolicyUtils'; import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; -import {getOriginalMessage, getReportActions, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import type {TransactionDetails} from '@libs/ReportUtils'; import {canEditMoneyRequest, getTransactionDetails, getWorkspaceIcon, isIOUReport, isPolicyExpenseChat, isReportApproved, isSettled} from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; @@ -66,7 +67,7 @@ function TransactionPreviewContent({ const ownerAccountID = iouReport?.ownerAccountID ?? reportPreviewAction?.childOwnerAccountID ?? CONST.DEFAULT_NUMBER_ID; const isReportAPolicyExpenseChat = isPolicyExpenseChat(chatReport); const {amount: requestAmount, comment: requestComment, merchant, tag, category, currency: requestCurrency} = transactionDetails; - const reportActions = useMemo(() => (iouReport ? (getReportActions(iouReport) ?? {}) : {}), [iouReport]); + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(iouReport?.reportID)}`, {canBeMissing: true}); const transactionPreviewCommonArguments = useMemo( () => ({ diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx index 8a9b83bd215d..234aca31ffad 100644 --- a/src/components/Search/SearchContext.tsx +++ b/src/components/Search/SearchContext.tsx @@ -1,81 +1,42 @@ import React, {useCallback, useContext, useMemo, useState} from 'react'; -import type {ReportActionListItemType, ReportListItemType, TaskListItemType, TransactionListItemType} from '@components/SelectionList/types'; import {isMoneyRequestReport} from '@libs/ReportUtils'; import {isReportListItemType, isTransactionListItemType} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import type {SearchContext, SelectedTransactions} from './types'; +import type {SearchContext, SearchContextData} from './types'; -const defaultSearchContext: SearchContext = { +const defaultSearchContextData: SearchContextData = { currentSearchHash: -1, - shouldTurnOffSelectionMode: false, selectedTransactions: {}, + selectedTransactionIDs: [], selectedReports: [], + isOnSearch: false, + shouldTurnOffSelectionMode: false, +}; + +const defaultSearchContext: SearchContext = { + ...defaultSearchContextData, + lastSearchType: undefined, + isExportMode: false, + shouldShowExportModeOption: false, + shouldShowFiltersBarLoading: false, + setLastSearchType: () => {}, setCurrentSearchHash: () => {}, setSelectedTransactions: () => {}, clearSelectedTransactions: () => {}, - shouldShowFiltersBarLoading: false, setShouldShowFiltersBarLoading: () => {}, - lastSearchType: undefined, - setLastSearchType: () => {}, - shouldShowExportModeOption: false, setShouldShowExportModeOption: () => {}, - isExportMode: false, setExportMode: () => {}, - isOnSearch: false, }; const Context = React.createContext(defaultSearchContext); -function getReportsFromSelectedTransactions( - data: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[] | TaskListItemType[], - selectedTransactions: SelectedTransactions, -) { - if (data.length === 0) { - return []; - } - - if (isReportListItemType(data[0]) || isMoneyRequestReport(data[0])) { - return data - .filter( - (item): item is ReportListItemType => - isReportListItemType(item) && isMoneyRequestReport(item) && item.transactions?.every((transaction) => selectedTransactions[transaction.keyForList]?.isSelected), - ) - .map((item) => ({ - reportID: item.reportID, - action: item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW, - total: item.total ?? CONST.DEFAULT_NUMBER_ID, - policyID: item.policyID, - })); - } - - if (isTransactionListItemType(data[0])) { - return data - .filter((transaction) => transaction.keyForList != null && selectedTransactions[transaction.keyForList]?.isSelected) - .map((transaction) => ({ - reportID: transaction.reportID, - action: 'action' in transaction ? (transaction.action ?? CONST.SEARCH.ACTION_TYPES.VIEW) : CONST.SEARCH.ACTION_TYPES.VIEW, - total: 'amount' in transaction ? (transaction.amount ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID, - policyID: transaction.policyID, - })); - } - - return []; -} - function SearchContextProvider({children}: ChildrenProps) { const [shouldShowExportModeOption, setShouldShowExportModeOption] = useState(false); const [isExportMode, setExportMode] = useState(false); - - const [searchContextData, setSearchContextData] = useState< - Pick - >({ - currentSearchHash: defaultSearchContext.currentSearchHash, - selectedTransactions: defaultSearchContext.selectedTransactions, - shouldTurnOffSelectionMode: false, - selectedReports: defaultSearchContext.selectedReports, - isOnSearch: false, - }); + const [shouldShowFiltersBarLoading, setShouldShowFiltersBarLoading] = useState(false); + const [lastSearchType, setLastSearchType] = useState(undefined); + const [searchContextData, setSearchContextData] = useState(defaultSearchContextData); const setCurrentSearchHash = useCallback((searchHash: number) => { setSearchContextData((prevState) => ({ @@ -84,10 +45,32 @@ function SearchContextProvider({children}: ChildrenProps) { })); }, []); - const setSelectedTransactions = useCallback( - (selectedTransactions: SelectedTransactions, data: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[] | TaskListItemType[]) => { + const setSelectedTransactions: SearchContext['setSelectedTransactions'] = useCallback( + (selectedTransactions, data = []) => { + if (selectedTransactions instanceof Array) { + if (!selectedTransactions.length && !searchContextData.selectedTransactionIDs.length) { + return; + } + 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. - const selectedReports = getReportsFromSelectedTransactions(data, selectedTransactions); + let selectedReports: SearchContext['selectedReports'] = []; + + if (data.length && data.every(isReportListItemType)) { + selectedReports = data + .filter((item) => isMoneyRequestReport(item) && item.transactions.every(({keyForList}) => selectedTransactions[keyForList]?.isSelected)) + .map(({reportID, action = CONST.SEARCH.ACTION_TYPES.VIEW, total = CONST.DEFAULT_NUMBER_ID, policyID}) => ({reportID, action, total, policyID})); + } + + if (data.length && data.every(isTransactionListItemType)) { + selectedReports = data + .filter(({keyForList}) => !!keyForList && selectedTransactions[keyForList]?.isSelected) + .map(({reportID, action = CONST.SEARCH.ACTION_TYPES.VIEW, amount: total = CONST.DEFAULT_NUMBER_ID, policyID}) => ({reportID, action, total, policyID})); + } setSearchContextData((prevState) => ({ ...prevState, @@ -96,12 +79,17 @@ function SearchContextProvider({children}: ChildrenProps) { selectedReports, })); }, - [], + [searchContextData.selectedTransactionIDs.length], ); - const clearSelectedTransactions = useCallback( - (searchHash?: number, shouldTurnOffSelectionMode = false) => { - if (searchHash === searchContextData.currentSearchHash) { + const clearSelectedTransactions: SearchContext['clearSelectedTransactions'] = useCallback( + (searchHashOrClearIDsFlag, shouldTurnOffSelectionMode = false) => { + if (typeof searchHashOrClearIDsFlag === 'boolean') { + setSelectedTransactions([]); + return; + } + + if (searchHashOrClearIDsFlag === searchContextData.currentSearchHash) { return; } setSearchContextData((prevState) => ({ @@ -113,12 +101,9 @@ function SearchContextProvider({children}: ChildrenProps) { setShouldShowExportModeOption(false); setExportMode(false); }, - [searchContextData.currentSearchHash], + [searchContextData.currentSearchHash, setSelectedTransactions], ); - const [shouldShowFiltersBarLoading, setShouldShowFiltersBarLoading] = useState(false); - const [lastSearchType, setLastSearchType] = useState(undefined); - const searchContext = useMemo( () => ({ ...searchContextData, @@ -151,6 +136,11 @@ function SearchContextProvider({children}: ChildrenProps) { return {children}; } +/** + * 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(Context); } diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 28f6f7639b6b..ab24194a715e 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -66,14 +66,27 @@ type SearchStatus = SingularSearchStatus | SingularSearchStatus[]; type SearchGroupBy = ValueOf; type TableColumnSize = ValueOf; -type SearchContext = { +type SearchContextData = { currentSearchHash: number; selectedTransactions: SelectedTransactions; + selectedTransactionIDs: string[]; selectedReports: SelectedReports[]; - setCurrentSearchHash: (hash: number) => void; - setSelectedTransactions: (selectedTransactions: SelectedTransactions, data: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[] | TaskListItemType[]) => void; - clearSelectedTransactions: (hash?: number, shouldTurnOffSelectionMode?: boolean) => void; + isOnSearch: boolean; shouldTurnOffSelectionMode: boolean; +}; + +type SearchContext = SearchContextData & { + setCurrentSearchHash: (hash: number) => void; + /** If you want to set `selectedTransactionIDs`, pass an array as the first argument, object/record otherwise */ + setSelectedTransactions: { + (selectedTransactionIDs: string[], unused?: undefined): void; + (selectedTransactions: SelectedTransactions, data: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[] | TaskListItemType[]): void; + }; + /** If you want to clear `selectedTransactionIDs`, pass `true` as the first argument */ + clearSelectedTransactions: { + (hash?: number, shouldTurnOffSelectionMode?: boolean): void; + (clearIDs: true, unused?: undefined): void; + }; shouldShowFiltersBarLoading: boolean; setShouldShowFiltersBarLoading: (shouldShow: boolean) => void; setLastSearchType: (type: string | undefined) => void; @@ -82,7 +95,6 @@ type SearchContext = { setShouldShowExportModeOption: (shouldShow: boolean) => void; isExportMode: boolean; setExportMode: (on: boolean) => void; - isOnSearch: boolean; }; type ASTNode = { @@ -168,6 +180,7 @@ export type { SearchQueryString, SortOrder, SearchContext, + SearchContextData, ASTNode, QueryFilter, QueryFilters, diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index f7315dcd553e..f73dcb4a97d0 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -1,7 +1,7 @@ import {useCallback, useMemo, useState} from 'react'; import {useOnyx} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; -import {useMoneyRequestReportContext} from '@components/MoneyRequestReportView/MoneyRequestReportContext'; +import {useSearchContext} from '@components/Search/SearchContext'; import {deleteMoneyRequest, unholdRequest} from '@libs/actions/IOU'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {exportReportToCSV} from '@libs/actions/Report'; @@ -43,19 +43,19 @@ function useSelectedTransactionsActions({ session?: Session; onExportFailed?: () => void; }) { - const {selectedTransactionsID, setSelectedTransactionsID} = useMoneyRequestReportContext(); + const {selectedTransactionIDs, clearSelectedTransactions} = useSearchContext(); const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: false}); const isReportArchived = useReportIsArchived(report?.reportID); const selectedTransactions = useMemo( () => - selectedTransactionsID.reduce((acc, transactionID) => { + selectedTransactionIDs.reduce((acc, transactionID) => { const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; if (transaction) { acc.push(transaction); } return acc; }, [] as Transaction[]), - [allTransactions, selectedTransactionsID], + [allTransactions, selectedTransactionIDs], ); const {translate} = useLocalize(); @@ -74,7 +74,7 @@ function useSelectedTransactionsActions({ const handleDeleteTransactions = useCallback(() => { const iouActions = reportActions.filter((action) => isMoneyRequestAction(action)); - const transactionsWithActions = selectedTransactionsID.map((transactionID) => ({ + const transactionsWithActions = selectedTransactionIDs.map((transactionID) => ({ transactionID, action: iouActions.find((action) => { const IOUTransactionID = (getOriginalMessage(action) as OriginalMessageIOU)?.IOUTransactionID; @@ -83,12 +83,12 @@ function useSelectedTransactionsActions({ })); transactionsWithActions.forEach(({transactionID, action}) => action && deleteMoneyRequest(transactionID, action)); - setSelectedTransactionsID([]); + clearSelectedTransactions(true); if (allTransactionsLength - transactionsWithActions.length <= 1) { turnOffMobileSelectionMode(); } setIsDeleteModalVisible(false); - }, [allTransactionsLength, reportActions, selectedTransactionsID, setSelectedTransactionsID]); + }, [allTransactionsLength, reportActions, selectedTransactionIDs, clearSelectedTransactions]); const showDeleteModal = useCallback(() => { setIsDeleteModalVisible(true); @@ -99,7 +99,7 @@ function useSelectedTransactionsActions({ }, []); const computedOptions = useMemo(() => { - if (!selectedTransactionsID.length) { + if (!selectedTransactionIDs.length) { return []; } const options = []; @@ -145,14 +145,14 @@ function useSelectedTransactionsActions({ icon: Expensicons.Stopwatch, value: UNHOLD, onSelected: () => { - selectedTransactionsID.forEach((transactionID) => { + selectedTransactionIDs.forEach((transactionID) => { const action = getIOUActionForTransactionID(reportActions, transactionID); if (!action?.childReportID) { return; } unholdRequest(transactionID, action?.childReportID); }); - setSelectedTransactionsID([]); + clearSelectedTransactions(true); }, }); } @@ -165,10 +165,10 @@ function useSelectedTransactionsActions({ if (!report) { return; } - exportReportToCSV({reportID: report.reportID, transactionIDList: selectedTransactionsID}, () => { + exportReportToCSV({reportID: report.reportID, transactionIDList: selectedTransactionIDs}, () => { onExportFailed?.(); }); - setSelectedTransactionsID([]); + clearSelectedTransactions(true); }, }); @@ -185,7 +185,7 @@ function useSelectedTransactionsActions({ const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(report); if (canSelectedExpensesBeMoved && canUserPerformWriteAction) { options.push({ - text: translate('iou.moveExpenses', {count: selectedTransactionsID.length}), + text: translate('iou.moveExpenses', {count: selectedTransactionIDs.length}), icon: Expensicons.DocumentMerge, value: MOVE, onSelected: () => { @@ -195,7 +195,7 @@ function useSelectedTransactionsActions({ }); } - const canAllSelectedTransactionsBeRemoved = selectedTransactionsID.every((transactionID) => { + const canAllSelectedTransactionsBeRemoved = selectedTransactionIDs.every((transactionID) => { const canRemoveTransaction = canDeleteCardTransactionByLiabilityType(transactionID); const action = getIOUActionForTransactionID(reportActions, transactionID); const isActionDeleted = isDeletedAction(action); @@ -216,12 +216,12 @@ function useSelectedTransactionsActions({ } return options; }, [ - selectedTransactionsID, + selectedTransactionIDs, report, selectedTransactions, translate, reportActions, - setSelectedTransactionsID, + clearSelectedTransactions, onExportFailed, iouType, session?.accountID, diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 302b5c57f6a1..3e72462a4bf9 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -5,7 +5,6 @@ import type {OnyxEntry} from 'react-native-onyx'; import Onyx, {useOnyx, withOnyx} from 'react-native-onyx'; import ComposeProviders from '@components/ComposeProviders'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; -import {MoneyRequestReportContextProvider} from '@components/MoneyRequestReportView/MoneyRequestReportContext'; import OptionsListContextProvider from '@components/OptionListContextProvider'; import {SearchContextProvider} from '@components/Search/SearchContext'; import {useSearchRouterContext} from '@components/Search/SearchRouter/SearchRouterContext'; @@ -547,7 +546,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie } return ( - + {/* This has to be the first navigator in auth screens. */} ( { [SCREENS.SEARCH.REPORT_RHP]: () => require('../../../../pages/home/ReportScreen').default, - [SCREENS.SEARCH.MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS]: () => require('../../../../pages/Search/SearchMoneyRequestReportHoldReasonPage').default, + [SCREENS.SEARCH.MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS]: () => require('../../../../pages/Search/SearchHoldReasonPage').default, [SCREENS.SEARCH.TRANSACTION_HOLD_REASON_RHP]: () => require('../../../../pages/Search/SearchHoldReasonPage').default, [SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP]: () => require('../../../../pages/Search/SearchTransactionsChangeReport').default, }, diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index fb3742af7333..eea1ecd4e01e 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -10820,6 +10820,16 @@ function putOnHold(transactionID: string, comment: string, initialReportID: stri Navigation.setNavigationActionToMicrotaskQueue(() => notifyNewAction(currentReportID, userAccountID)); } +function putTransactionsOnHold(transactionsID: string[], comment: string, reportID: string) { + transactionsID.forEach((transactionID) => { + const {childReportID} = getIOUActionForReportID(reportID, transactionID) ?? {}; + if (!childReportID) { + return; + } + putOnHold(transactionID, comment, childReportID); + }); +} + /** * Remove expense from HOLD */ @@ -11877,6 +11887,7 @@ export { payInvoice, payMoneyRequest, putOnHold, + putTransactionsOnHold, replaceReceipt, requestMoney, resetSplitShares, diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index 23c8d384f63a..60596b424c97 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -14,7 +14,6 @@ import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/M import * as Expensicons from '@components/Icon/Expensicons'; import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import {useMoneyRequestReportContext} from '@components/MoneyRequestReportView/MoneyRequestReportContext'; import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ParentNavigationSubtitle from '@components/ParentNavigationSubtitle'; @@ -24,6 +23,7 @@ import PromotedActionsBar, {PromotedActions} from '@components/PromotedActionsBa import RoomHeaderAvatars from '@components/RoomHeaderAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import {useSearchContext} from '@components/Search/SearchContext'; import TextWithCopy from '@components/TextWithCopy'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -157,6 +157,15 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta const {reportActions} = usePaginatedReportActions(report.reportID); + const {setSelectedTransactions, selectedTransactionIDs} = useSearchContext(); + + const removeTransaction = useCallback( + (transactionID?: string) => { + setSelectedTransactions(selectedTransactionIDs.filter((t) => t !== transactionID)); + }, + [setSelectedTransactions, selectedTransactionIDs], + ); + const transactionThreadReportID = useMemo(() => getOneTransactionThreadReportID(report.reportID, reportActions ?? [], isOffline), [report.reportID, reportActions, isOffline]); /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ @@ -165,8 +174,6 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false}); - const {removeTransaction} = useMoneyRequestReportContext(); - const [isLastMemberLeavingGroupModalVisible, setIsLastMemberLeavingGroupModalVisible] = useState(false); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const policy = useMemo(() => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`], [policies, report?.policyID]); diff --git a/src/pages/Search/SearchHoldReasonPage.tsx b/src/pages/Search/SearchHoldReasonPage.tsx index 3bcd3a84de91..8182f089e3f9 100644 --- a/src/pages/Search/SearchHoldReasonPage.tsx +++ b/src/pages/Search/SearchHoldReasonPage.tsx @@ -5,35 +5,34 @@ import useLocalize from '@hooks/useLocalize'; import {clearErrorFields, clearErrors} from '@libs/actions/FormActions'; import {holdMoneyRequestOnSearch} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; -import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; +import type {SearchReportParamList} from '@navigation/types'; import HoldReasonFormView from '@pages/iou/HoldReasonFormView'; +import {putTransactionsOnHold} from '@userActions/IOU'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Route} from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/MoneyRequestHoldReasonForm'; -type SearchHoldReasonPageRouteParams = { - /** Link to previous page */ - backTo: Route; -}; - -type SearchHoldReasonPageProps = { - /** Navigation route context info provided by react navigation */ - route: PlatformStackRouteProp<{params?: SearchHoldReasonPageRouteParams}>; -}; - -function SearchHoldReasonPage({route}: SearchHoldReasonPageProps) { +function SearchHoldReasonPage({route}: PlatformStackScreenProps>) { const {translate} = useLocalize(); + const {backTo = '', reportID} = route.params ?? {}; + const context = useSearchContext(); - const {currentSearchHash, selectedTransactions, clearSelectedTransactions} = useSearchContext(); - const {backTo = ''} = route.params ?? {}; + const onSubmit = useCallback( + ({comment}: FormOnyxValues) => { + if (route.name === SCREENS.SEARCH.MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS) { + putTransactionsOnHold(context.selectedTransactionIDs, comment, reportID); + context.clearSelectedTransactions(true); + } else { + holdMoneyRequestOnSearch(context.currentSearchHash, Object.keys(context.selectedTransactions), comment); + context.clearSelectedTransactions(); + } - const selectedTransactionIDs = Object.keys(selectedTransactions); - const onSubmit = (values: FormOnyxValues) => { - holdMoneyRequestOnSearch(currentSearchHash, selectedTransactionIDs, values.comment); - clearSelectedTransactions(); - Navigation.goBack(); - }; + Navigation.goBack(); + }, + [route.name, context, reportID], + ); const validate = useCallback( (values: FormOnyxValues) => { diff --git a/src/pages/Search/SearchMoneyRequestReportHoldReasonPage.tsx b/src/pages/Search/SearchMoneyRequestReportHoldReasonPage.tsx deleted file mode 100644 index f315a7f86c32..000000000000 --- a/src/pages/Search/SearchMoneyRequestReportHoldReasonPage.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React, {useCallback, useEffect} from 'react'; -import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import {useMoneyRequestReportContext} from '@components/MoneyRequestReportView/MoneyRequestReportContext'; -import useLocalize from '@hooks/useLocalize'; -import {putOnHold} from '@libs/actions/IOU'; -import Navigation from '@libs/Navigation/Navigation'; -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {SearchReportParamList} from '@libs/Navigation/types'; -import {getIOUActionForReportID} from '@libs/ReportActionsUtils'; -import {getFieldRequiredErrors} from '@libs/ValidationUtils'; -import HoldReasonFormView from '@pages/iou/HoldReasonFormView'; -import {clearErrorFields, clearErrors} from '@userActions/FormActions'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type SCREENS from '@src/SCREENS'; -import INPUT_IDS from '@src/types/form/MoneyRequestHoldReasonForm'; - -function SearchMoneyRequestReportHoldReasonPage({route}: PlatformStackScreenProps) { - const {translate} = useLocalize(); - - const {backTo, reportID} = route.params; - const {selectedTransactionsID, setSelectedTransactionsID} = useMoneyRequestReportContext(); - - const onSubmit = (values: FormOnyxValues) => { - selectedTransactionsID.forEach((transactionID) => { - const iouAction = getIOUActionForReportID(reportID, transactionID); - const transactionThreadReportID = iouAction?.childReportID; - if (!transactionThreadReportID) { - return; - } - - putOnHold(transactionID, values.comment, transactionThreadReportID); - }); - setSelectedTransactionsID([]); - Navigation.goBack(); - }; - - const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = getFieldRequiredErrors(values, [INPUT_IDS.COMMENT]); - - if (!values.comment) { - errors.comment = translate('common.error.fieldRequired'); - } - - return errors; - }, - [translate], - ); - - useEffect(() => { - clearErrors(ONYXKEYS.FORMS.MONEY_REQUEST_HOLD_FORM); - clearErrorFields(ONYXKEYS.FORMS.MONEY_REQUEST_HOLD_FORM); - }, []); - - return ( - - ); -} - -SearchMoneyRequestReportHoldReasonPage.displayName = 'SearchMoneyRequestReportHoldReasonPage'; - -export default SearchMoneyRequestReportHoldReasonPage; diff --git a/src/pages/iou/request/step/IOURequestEditReport.tsx b/src/pages/iou/request/step/IOURequestEditReport.tsx index 43a796ab1d8c..2d4b0c240212 100644 --- a/src/pages/iou/request/step/IOURequestEditReport.tsx +++ b/src/pages/iou/request/step/IOURequestEditReport.tsx @@ -1,6 +1,6 @@ import React from 'react'; import {useOnyx} from 'react-native-onyx'; -import {useMoneyRequestReportContext} from '@components/MoneyRequestReportView/MoneyRequestReportContext'; +import {useSearchContext} from '@components/Search/SearchContext'; import type {ListItem} from '@components/SelectionList/types'; import {changeTransactionsReport} from '@libs/actions/Transaction'; import Navigation from '@libs/Navigation/Navigation'; @@ -20,17 +20,17 @@ type IOURequestEditReportProps = WithWritableReportOrNotFoundProps { - if (selectedTransactionsID.length === 0) { + if (selectedTransactionIDs.length === 0) { return; } if (item.value !== transactionReport?.reportID) { - changeTransactionsReport(selectedTransactionsID, item.value); - setSelectedTransactionsID([]); + changeTransactionsReport(selectedTransactionIDs, item.value); + clearSelectedTransactions(true); } Navigation.dismissModalWithReport({reportID: item.value}); }; diff --git a/tests/ui/ReportListItemHeaderTest.tsx b/tests/ui/ReportListItemHeaderTest.tsx index a2998d60d6ff..a90350b8793e 100644 --- a/tests/ui/ReportListItemHeaderTest.tsx +++ b/tests/ui/ReportListItemHeaderTest.tsx @@ -23,10 +23,18 @@ jest.mock('@components/AvatarWithDisplayName.tsx'); const mockSearchContext = { currentSearchHash: 12345, selectedReports: {}, - setSelectedReports: jest.fn(), selectedTransactionIDs: [], - setSelectedTransactionIDs: jest.fn(), - clearSelectedItems: jest.fn(), + selectedTransactions: {}, + isOnSearch: false, + shouldTurnOffSelectionMode: false, + setSelectedReports: jest.fn(), + clearSelectedTransactions: jest.fn(), + setLastSearchType: jest.fn(), + setCurrentSearchHash: jest.fn(), + setSelectedTransactions: jest.fn(), + setShouldShowFiltersBarLoading: jest.fn(), + setShouldShowExportModeOption: jest.fn(), + setExportMode: jest.fn(), }; const mockPersonalDetails: Record = {