diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index 28be8ea50930..20a45118d46d 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -49,6 +49,7 @@ function ExpenseReportListItem({ onFocus, onLongPressRow, shouldSyncFocus, + onHoldMenuOpen, onSelectionButtonPress, lastPaymentMethod, personalPolicyID, @@ -149,6 +150,7 @@ function ExpenseReportListItem({ isDelegateAccessRestricted, onDelegateAccessRestricted: showDelegateNoAccessModal, personalPolicyID, + onHoldMenuOpen, ownerBillingGracePeriodEnd, amountOwed, }); @@ -164,6 +166,7 @@ function ExpenseReportListItem({ currentSearchKey, isDelegateAccessRestricted, showDelegateNoAccessModal, + onHoldMenuOpen, ownerBillingGracePeriodEnd, amountOwed, ]); diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 319a393e257b..df4cff50cd2f 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -36,6 +36,7 @@ import type {TransactionPreviewData} from '@userActions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {CardList, Policy, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {HoldMenuCallback} from '..'; import BaseSearchList from './BaseSearchList'; import type ChatListItem from './ListItem/ChatListItem'; import type ExpenseReportListItem from './ListItem/ExpenseReportListItem'; @@ -120,6 +121,9 @@ type SearchListProps = Pick, 'onScroll' | 'conten /** Violations indexed by transaction ID */ violations?: Record | undefined; + /** Callback to fire when hold menu should be opened */ + onHoldMenuOpen?: HoldMenuCallback; + /** Selected transactions for determining isSelected state */ selectedTransactions: SelectedTransactions; @@ -214,6 +218,7 @@ function SearchList({ isMobileSelectionModeEnabled, newTransactions = [], violations, + onHoldMenuOpen, nonPersonalAndWorkspaceCards, selectedTransactions, hasLoadedAllTransactions, @@ -458,6 +463,7 @@ function SearchList({ nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} onFocus={onFocus} newTransactionID={newTransactionID} + onHoldMenuOpen={onHoldMenuOpen} onUndelete={handleUndelete} keyForList={item.keyForList} isFirstItem={index === firstVisibleIndex} @@ -488,6 +494,7 @@ function SearchList({ violations, lastPaymentMethod, personalPolicyID, + onHoldMenuOpen, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, nonPersonalAndWorkspaceCards, diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 7b7b1e6f101f..e46269baf357 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -7,6 +7,8 @@ import type {OnyxEntry} from 'react-native-onyx'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; +import ProcessMoneyReportHoldMenu from '@components/ProcessMoneyReportHoldMenu'; import type {SelectionListHandle} from '@components/SelectionList/types'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; @@ -35,7 +37,7 @@ import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNa import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {isCreatedTaskReportAction} from '@libs/ReportActionsUtils'; import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; -import {canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canRejectReportAction, isOneTransactionReport, selectFilteredReportActions} from '@libs/ReportUtils'; +import {canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canRejectReportAction, getNonHeldAndFullAmount, isOneTransactionReport, selectFilteredReportActions} from '@libs/ReportUtils'; import {buildCannedSearchQuery, buildSearchQueryString, isDefaultExpensesQuery} from '@libs/SearchQueryUtils'; import { createAndOpenSearchTransactionThread, @@ -71,7 +73,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; -import type {OutstandingReportsByPolicyIDDerivedValue, SaveSearch, Transaction} from '@src/types/onyx'; +import type {OutstandingReportsByPolicyIDDerivedValue, Report, SaveSearch, Transaction} from '@src/types/onyx'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type SearchResults from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import arraysEqual from '@src/utils/arraysEqual'; @@ -101,6 +104,8 @@ type SearchProps = { onDestinationVisible?: (wasListEmpty: boolean, source: 'focus' | 'layout') => void; }; +type HoldMenuCallback = (item: TransactionReportGroupListItemType, requestType: ActionHandledType, paymentType?: PaymentMethodType) => void; + // Max time (ms) to keep the optimistic item cache/skeleton alive before // clearing all tracking state. Must be longer than deferredLayoutWrite's // 5s safety timeout so the API.write() has time to apply optimistic data. @@ -293,6 +298,19 @@ function Search({ const styles = useThemeStyles(); const navigation = useNavigation>(); const isFocused = useIsFocused(); + const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); + const [holdMenuParams, setHoldMenuParams] = useState<{ + chatReport: OnyxEntry; + fullAmount: string; + moneyRequestReport: OnyxEntry; + transactionCount: number; + nonHeldAmount: string; + requestType: ActionHandledType; + paymentType?: PaymentMethodType; + hasValidNonHeldAmount: boolean; + hasNoneHeldExpenses: boolean; + } | null>(null); + const {markReportIDAsExpense, markReportIDAsMultiTransactionExpense, unmarkReportIDAsMultiTransactionExpense} = useWideRHPActions(); const { currentSearchHash, @@ -352,6 +370,26 @@ function Search({ selector: savedSearchSelector, }); + const handleHoldMenuOpen = useCallback( + (item: TransactionReportGroupListItemType, requestType: ActionHandledType, paymentType?: PaymentMethodType) => { + const chatReport = searchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${item.parentReportID}`]; + const moneyRequestReport = searchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`]; + const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(moneyRequestReport, item.allActions?.includes(CONST.SEARCH.ACTION_TYPES.PAY) ?? false); + setHoldMenuParams({ + chatReport, + moneyRequestReport, + transactionCount: item.transactionCount ?? 0, + fullAmount, + requestType, + paymentType, + nonHeldAmount, + hasValidNonHeldAmount, + hasNoneHeldExpenses: item.transactions.some((t) => !isOnHold(t)), + }); + setIsHoldMenuVisible(true); + }, + [searchResults?.data], + ); const {convertToDisplayString} = useCurrencyListActions(); const validGroupBy = getValidGroupBy(groupBy); @@ -1724,10 +1762,25 @@ function Search({ shouldAnimate={type === CONST.SEARCH.DATA_TYPES.EXPENSE} newTransactions={newTransactions} hasLoadedAllTransactions={hasLoadedAllTransactions} + onHoldMenuOpen={handleHoldMenuOpen} policyForMovingExpenses={policyForMovingExpenses} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} isActionColumnWide={isTask || hasDeletedTransaction} /> + {isHoldMenuVisible && !!holdMenuParams && ( + setIsHoldMenuVisible(false)} + chatReport={holdMenuParams.chatReport} + fullAmount={holdMenuParams.fullAmount} + moneyRequestReport={holdMenuParams.moneyRequestReport} + transactionCount={holdMenuParams.transactionCount} + hasNonHeldExpenses={holdMenuParams?.hasNoneHeldExpenses} + nonHeldAmount={holdMenuParams.hasNoneHeldExpenses && holdMenuParams.hasValidNonHeldAmount ? holdMenuParams.nonHeldAmount : undefined} + requestType={holdMenuParams.requestType} + paymentType={holdMenuParams.paymentType} + /> + )} ); @@ -1735,7 +1788,7 @@ function Search({ Search.displayName = 'Search'; -export type {SearchProps}; +export type {SearchProps, HoldMenuCallback}; const WrappedSearch = Sentry.withProfiler(Search) as typeof Search; WrappedSearch.displayName = 'Search'; diff --git a/src/components/SelectionList/ListItem/types.ts b/src/components/SelectionList/ListItem/types.ts index d5be25e0742c..115e7acfab3b 100644 --- a/src/components/SelectionList/ListItem/types.ts +++ b/src/components/SelectionList/ListItem/types.ts @@ -2,6 +2,7 @@ import type {ReactElement, ReactNode} from 'react'; import type {BlurEvent, NativeSyntheticEvent, Role, StyleProp, TargetedEvent, TextStyle, ViewStyle} from 'react-native'; import type {AnimatedStyle} from 'react-native-reanimated'; import type {ValueOf} from 'type-fest'; +import type {HoldMenuCallback} from '@components/Search'; import type {SearchRouterItem} from '@components/Search/SearchAutocompleteList'; import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; import type {TransactionPreviewData} from '@libs/actions/Search'; @@ -282,6 +283,9 @@ type ListItemProps = CommonListItemProps & { /** Callback when the input inside the item is blurred (if input exists) */ onInputBlur?: (e: BlurEvent) => void; + /** Callback when the hold menu should be opened */ + onHoldMenuOpen?: HoldMenuCallback; + /** Whether to disable the hover style of the item */ shouldDisableHoverStyle?: boolean; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index b84392280d5d..e40ecf3fa81a 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -2365,12 +2365,7 @@ function getActions( const isAllowedToApproveExpenseReport = isAllowedToApproveExpenseReportUtils(report, submitToAccountID, policy); // We're not supporting approve partial amount on search page now - if ( - canApproveIOU(report, policy, reportMetadata, currentUserAccountID, allReportTransactions) && - isAllowedToApproveExpenseReport && - !hasOnlyPendingCardOrScanningTransactions && - !hasHeldExpenses(report.reportID, allReportTransactions) - ) { + if (canApproveIOU(report, policy, reportMetadata, currentUserAccountID, allReportTransactions) && isAllowedToApproveExpenseReport && !hasOnlyPendingCardOrScanningTransactions) { allActions.push(CONST.SEARCH.ACTION_TYPES.APPROVE); } diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 64c83471cf38..dfb748fe4570 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -6,6 +6,7 @@ import type {FormOnyxValues} from '@components/Form/types'; import type {ContinueActionParams, PaymentMethod, PaymentMethodType} from '@components/KYCWall/types'; import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import type {PopoverMenuItem} from '@components/PopoverMenu'; +import type {HoldMenuCallback} from '@components/Search'; import type {TransactionListItemType, TransactionReportGroupListItemType} from '@components/Search/SearchList/ListItem/types'; import type {BankAccountMenuItem, BulkPaySelectionData, PaymentData, SearchQueryJSON, SelectedReports, SelectedTransactionInfo, SelectedTransactions} from '@components/Search/types'; import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; @@ -105,6 +106,7 @@ type HandleActionButtonPressParams = { lastPaymentMethod: OnyxEntry; userBillingGracePeriodEnds: OnyxCollection; currentSearchKey?: SearchKey; + onHoldMenuOpen?: HoldMenuCallback; isDelegateAccessRestricted?: boolean; onDelegateAccessRestricted?: () => void; personalPolicyID: string | undefined; @@ -135,6 +137,7 @@ function handleActionButtonPress({ lastPaymentMethod, userBillingGracePeriodEnds, currentSearchKey, + onHoldMenuOpen, isDelegateAccessRestricted, onDelegateAccessRestricted, personalPolicyID, @@ -147,7 +150,12 @@ function handleActionButtonPress({ const allReportTransactions = (isTransactionGroupListItemType(item) ? item.transactions : [item]) as Transaction[]; const hasHeldExpense = hasHeldExpenses('', allReportTransactions); - if (hasHeldExpense && item.action !== CONST.SEARCH.ACTION_TYPES.SUBMIT && item.action !== CONST.SEARCH.ACTION_TYPES.UNDELETE) { + if ( + hasHeldExpense && + item.action !== CONST.SEARCH.ACTION_TYPES.SUBMIT && + item.action !== CONST.SEARCH.ACTION_TYPES.UNDELETE && + (item.action !== CONST.SEARCH.ACTION_TYPES.APPROVE || !onHoldMenuOpen) + ) { goToItem(); return; } @@ -173,6 +181,10 @@ function handleActionButtonPress({ Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(snapshotReport.policyID)); return; } + if (hasHeldExpense) { + onHoldMenuOpen?.(item as TransactionReportGroupListItemType, CONST.IOU.REPORT_ACTION_TYPE.APPROVE); + return; + } approveMoneyRequestOnSearch(hash, item.reportID ? [item.reportID] : [], currentSearchKey); return; case CONST.SEARCH.ACTION_TYPES.SUBMIT: { diff --git a/tests/unit/Search/handleActionButtonPressTest.ts b/tests/unit/Search/handleActionButtonPressTest.ts index 41c73957e964..3f3f432ed2d9 100644 --- a/tests/unit/Search/handleActionButtonPressTest.ts +++ b/tests/unit/Search/handleActionButtonPressTest.ts @@ -314,7 +314,7 @@ describe('handleActionButtonPress', () => { const snapshotReport = mockSnapshotForItem?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${mockReportItemWithHold.reportID}`] ?? {}; const snapshotPolicy = mockSnapshotForItem?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${mockReportItemWithHold.policyID}`] ?? {}; - test('Should navigate to item when report has one transaction on hold', () => { + test('Should not navigate to item when report has one transaction on hold and action is approve', () => { const goToItem = jest.fn(() => {}); handleActionButtonPress({ hash: searchHash, @@ -327,8 +327,28 @@ describe('handleActionButtonPress', () => { ownerBillingGracePeriodEnd: undefined, amountOwed: undefined, userBillingGracePeriodEnds: undefined, + onHoldMenuOpen: jest.fn(), }); - expect(goToItem).toHaveBeenCalledTimes(1); + expect(goToItem).not.toHaveBeenCalled(); + }); + + test('Should open the hold menu when the report has one transaction on hold and action is approve', () => { + const onHoldMenuOpen = jest.fn(); + handleActionButtonPress({ + hash: searchHash, + item: mockReportItemWithHold, + goToItem: jest.fn(), + snapshotReport: snapshotReport as Report, + snapshotPolicy: snapshotPolicy as Policy, + lastPaymentMethod: mockLastPaymentMethod, + personalPolicyID: undefined, + userBillingGracePeriodEnds: undefined, + ownerBillingGracePeriodEnd: undefined, + amountOwed: undefined, + onHoldMenuOpen, + }); + + expect(onHoldMenuOpen).toHaveBeenCalledWith(mockReportItemWithHold, CONST.IOU.REPORT_ACTION_TYPE.APPROVE); }); test('Should not navigate to item when the hold is removed', () => {