Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function ExpenseReportListItem<TItem extends ListItem>({
onFocus,
onLongPressRow,
shouldSyncFocus,
onHoldMenuOpen,
onSelectionButtonPress,
lastPaymentMethod,
personalPolicyID,
Expand Down Expand Up @@ -149,6 +150,7 @@ function ExpenseReportListItem<TItem extends ListItem>({
isDelegateAccessRestricted,
onDelegateAccessRestricted: showDelegateNoAccessModal,
personalPolicyID,
onHoldMenuOpen,
ownerBillingGracePeriodEnd,
amountOwed,
});
Expand All @@ -164,6 +166,7 @@ function ExpenseReportListItem<TItem extends ListItem>({
currentSearchKey,
isDelegateAccessRestricted,
showDelegateNoAccessModal,
onHoldMenuOpen,
ownerBillingGracePeriodEnd,
amountOwed,
]);
Expand Down
7 changes: 7 additions & 0 deletions src/components/Search/SearchList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -120,6 +121,9 @@ type SearchListProps = Pick<FlashListProps<SearchListItem>, 'onScroll' | 'conten
/** Violations indexed by transaction ID */
violations?: Record<string, TransactionViolations | undefined> | undefined;

/** Callback to fire when hold menu should be opened */
onHoldMenuOpen?: HoldMenuCallback;

/** Selected transactions for determining isSelected state */
selectedTransactions: SelectedTransactions;

Expand Down Expand Up @@ -214,6 +218,7 @@ function SearchList({
isMobileSelectionModeEnabled,
newTransactions = [],
violations,
onHoldMenuOpen,
nonPersonalAndWorkspaceCards,
selectedTransactions,
hasLoadedAllTransactions,
Expand Down Expand Up @@ -458,6 +463,7 @@ function SearchList({
nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards}
onFocus={onFocus}
newTransactionID={newTransactionID}
onHoldMenuOpen={onHoldMenuOpen}
onUndelete={handleUndelete}
keyForList={item.keyForList}
isFirstItem={index === firstVisibleIndex}
Expand Down Expand Up @@ -488,6 +494,7 @@ function SearchList({
violations,
lastPaymentMethod,
personalPolicyID,
onHoldMenuOpen,
userBillingGracePeriodEnds,
ownerBillingGracePeriodEnd,
nonPersonalAndWorkspaceCards,
Expand Down
59 changes: 56 additions & 3 deletions src/components/Search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -293,6 +298,19 @@ function Search({
const styles = useThemeStyles();
const navigation = useNavigation<PlatformStackNavigationProp<SearchFullscreenNavigatorParamList>>();
const isFocused = useIsFocused();
const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false);
const [holdMenuParams, setHoldMenuParams] = useState<{
chatReport: OnyxEntry<Report>;
fullAmount: string;
moneyRequestReport: OnyxEntry<Report>;
transactionCount: number;
nonHeldAmount: string;
requestType: ActionHandledType;
paymentType?: PaymentMethodType;
hasValidNonHeldAmount: boolean;
hasNoneHeldExpenses: boolean;
} | null>(null);

const {markReportIDAsExpense, markReportIDAsMultiTransactionExpense, unmarkReportIDAsMultiTransactionExpense} = useWideRHPActions();
const {
currentSearchHash,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1724,18 +1762,33 @@ function Search({
shouldAnimate={type === CONST.SEARCH.DATA_TYPES.EXPENSE}
newTransactions={newTransactions}
hasLoadedAllTransactions={hasLoadedAllTransactions}
onHoldMenuOpen={handleHoldMenuOpen}
policyForMovingExpenses={policyForMovingExpenses}
nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards}
isActionColumnWide={isTask || hasDeletedTransaction}
/>
{isHoldMenuVisible && !!holdMenuParams && (
<ProcessMoneyReportHoldMenu
isVisible={isHoldMenuVisible}
onClose={() => 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}
/>
)}
</Animated.View>
</SearchScopeProvider>
);
}

Search.displayName = 'Search';

export type {SearchProps};
export type {SearchProps, HoldMenuCallback};
const WrappedSearch = Sentry.withProfiler(Search) as typeof Search;
WrappedSearch.displayName = 'Search';

Expand Down
4 changes: 4 additions & 0 deletions src/components/SelectionList/ListItem/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -282,6 +283,9 @@ type ListItemProps<TItem extends ListItem> = CommonListItemProps<TItem> & {
/** 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;

Expand Down
7 changes: 1 addition & 6 deletions src/libs/SearchUIUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
14 changes: 13 additions & 1 deletion src/libs/actions/Search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -105,6 +106,7 @@ type HandleActionButtonPressParams = {
lastPaymentMethod: OnyxEntry<LastPaymentMethod>;
userBillingGracePeriodEnds: OnyxCollection<BillingGraceEndPeriod>;
currentSearchKey?: SearchKey;
onHoldMenuOpen?: HoldMenuCallback;
isDelegateAccessRestricted?: boolean;
onDelegateAccessRestricted?: () => void;
personalPolicyID: string | undefined;
Expand Down Expand Up @@ -135,6 +137,7 @@ function handleActionButtonPress({
lastPaymentMethod,
userBillingGracePeriodEnds,
currentSearchKey,
onHoldMenuOpen,
isDelegateAccessRestricted,
onDelegateAccessRestricted,
personalPolicyID,
Expand All @@ -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;
}
Expand All @@ -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;
Comment on lines +184 to +186
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add fallback when hold-menu callback is absent

The new approve-with-hold path returns immediately after onHoldMenuOpen?.(...), but that callback is optional and several existing callers still omit it (for example src/components/SelectionListWithSections/Search/TransactionListItem.tsx calls handleActionButtonPress without onHoldMenuOpen). Because this same commit also allows APPROVE on held reports in getActions(), those call sites can now show an Approve action that does nothing (no navigation and no approval request) when a held expense is present.

Useful? React with 👍 / 👎.

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.

@nkdengineer thoughts on this comment?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I updated to call goToItem if onHoldMenuOpen is not present.

}
approveMoneyRequestOnSearch(hash, item.reportID ? [item.reportID] : [], currentSearchKey);
return;
case CONST.SEARCH.ACTION_TYPES.SUBMIT: {
Expand Down
24 changes: 22 additions & 2 deletions tests/unit/Search/handleActionButtonPressTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
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.

Should we also test that the Hold modal is shown? And test what happens when the two different buttons on the modal are pressed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@puneetlath I updated the test to verify the onHoldMenuOpen callback is called.

const goToItem = jest.fn(() => {});
handleActionButtonPress({
hash: searchHash,
Expand All @@ -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', () => {
Expand Down
Loading