Skip to content

Commit 8526bac

Browse files
authored
Merge pull request #85194 from nkdengineer/fix/78046-1
feat: Support partial approval from Reports page
2 parents 48893d5 + ec2d292 commit 8526bac

7 files changed

Lines changed: 106 additions & 12 deletions

File tree

src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ function ExpenseReportListItem<TItem extends ListItem>({
4949
onFocus,
5050
onLongPressRow,
5151
shouldSyncFocus,
52+
onHoldMenuOpen,
5253
onSelectionButtonPress,
5354
lastPaymentMethod,
5455
personalPolicyID,
@@ -149,6 +150,7 @@ function ExpenseReportListItem<TItem extends ListItem>({
149150
isDelegateAccessRestricted,
150151
onDelegateAccessRestricted: showDelegateNoAccessModal,
151152
personalPolicyID,
153+
onHoldMenuOpen,
152154
ownerBillingGracePeriodEnd,
153155
amountOwed,
154156
});
@@ -164,6 +166,7 @@ function ExpenseReportListItem<TItem extends ListItem>({
164166
currentSearchKey,
165167
isDelegateAccessRestricted,
166168
showDelegateNoAccessModal,
169+
onHoldMenuOpen,
167170
ownerBillingGracePeriodEnd,
168171
amountOwed,
169172
]);

src/components/Search/SearchList/index.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import type {TransactionPreviewData} from '@userActions/Search';
3636
import CONST from '@src/CONST';
3737
import ONYXKEYS from '@src/ONYXKEYS';
3838
import type {CardList, Policy, Transaction, TransactionViolations} from '@src/types/onyx';
39+
import type {HoldMenuCallback} from '..';
3940
import BaseSearchList from './BaseSearchList';
4041
import type ChatListItem from './ListItem/ChatListItem';
4142
import type ExpenseReportListItem from './ListItem/ExpenseReportListItem';
@@ -120,6 +121,9 @@ type SearchListProps = Pick<FlashListProps<SearchListItem>, 'onScroll' | 'conten
120121
/** Violations indexed by transaction ID */
121122
violations?: Record<string, TransactionViolations | undefined> | undefined;
122123

124+
/** Callback to fire when hold menu should be opened */
125+
onHoldMenuOpen?: HoldMenuCallback;
126+
123127
/** Selected transactions for determining isSelected state */
124128
selectedTransactions: SelectedTransactions;
125129

@@ -214,6 +218,7 @@ function SearchList({
214218
isMobileSelectionModeEnabled,
215219
newTransactions = [],
216220
violations,
221+
onHoldMenuOpen,
217222
nonPersonalAndWorkspaceCards,
218223
selectedTransactions,
219224
hasLoadedAllTransactions,
@@ -458,6 +463,7 @@ function SearchList({
458463
nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards}
459464
onFocus={onFocus}
460465
newTransactionID={newTransactionID}
466+
onHoldMenuOpen={onHoldMenuOpen}
461467
onUndelete={handleUndelete}
462468
keyForList={item.keyForList}
463469
isFirstItem={index === firstVisibleIndex}
@@ -488,6 +494,7 @@ function SearchList({
488494
violations,
489495
lastPaymentMethod,
490496
personalPolicyID,
497+
onHoldMenuOpen,
491498
userBillingGracePeriodEnds,
492499
ownerBillingGracePeriodEnd,
493500
nonPersonalAndWorkspaceCards,

src/components/Search/index.tsx

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type {OnyxEntry} from 'react-native-onyx';
77
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
88
import FullPageErrorView from '@components/BlockingViews/FullPageErrorView';
99
import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
10+
import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu';
11+
import ProcessMoneyReportHoldMenu from '@components/ProcessMoneyReportHoldMenu';
1012
import type {SelectionListHandle} from '@components/SelectionList/types';
1113
import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton';
1214
import {useWideRHPActions} from '@components/WideRHPContextProvider';
@@ -35,7 +37,7 @@ import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNa
3537
import TransitionTracker from '@libs/Navigation/TransitionTracker';
3638
import {isCreatedTaskReportAction} from '@libs/ReportActionsUtils';
3739
import {isSplitAction} from '@libs/ReportSecondaryActionUtils';
38-
import {canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canRejectReportAction, isOneTransactionReport, selectFilteredReportActions} from '@libs/ReportUtils';
40+
import {canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canRejectReportAction, getNonHeldAndFullAmount, isOneTransactionReport, selectFilteredReportActions} from '@libs/ReportUtils';
3941
import {buildCannedSearchQuery, buildSearchQueryString, isDefaultExpensesQuery} from '@libs/SearchQueryUtils';
4042
import {
4143
createAndOpenSearchTransactionThread,
@@ -71,7 +73,8 @@ import ONYXKEYS from '@src/ONYXKEYS';
7173
import ROUTES from '@src/ROUTES';
7274
import SCREENS from '@src/SCREENS';
7375
import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm';
74-
import type {OutstandingReportsByPolicyIDDerivedValue, SaveSearch, Transaction} from '@src/types/onyx';
76+
import type {OutstandingReportsByPolicyIDDerivedValue, Report, SaveSearch, Transaction} from '@src/types/onyx';
77+
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
7578
import type SearchResults from '@src/types/onyx/SearchResults';
7679
import {isEmptyObject} from '@src/types/utils/EmptyObject';
7780
import arraysEqual from '@src/utils/arraysEqual';
@@ -101,6 +104,8 @@ type SearchProps = {
101104
onDestinationVisible?: (wasListEmpty: boolean, source: 'focus' | 'layout') => void;
102105
};
103106

107+
type HoldMenuCallback = (item: TransactionReportGroupListItemType, requestType: ActionHandledType, paymentType?: PaymentMethodType) => void;
108+
104109
// Max time (ms) to keep the optimistic item cache/skeleton alive before
105110
// clearing all tracking state. Must be longer than deferredLayoutWrite's
106111
// 5s safety timeout so the API.write() has time to apply optimistic data.
@@ -293,6 +298,19 @@ function Search({
293298
const styles = useThemeStyles();
294299
const navigation = useNavigation<PlatformStackNavigationProp<SearchFullscreenNavigatorParamList>>();
295300
const isFocused = useIsFocused();
301+
const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false);
302+
const [holdMenuParams, setHoldMenuParams] = useState<{
303+
chatReport: OnyxEntry<Report>;
304+
fullAmount: string;
305+
moneyRequestReport: OnyxEntry<Report>;
306+
transactionCount: number;
307+
nonHeldAmount: string;
308+
requestType: ActionHandledType;
309+
paymentType?: PaymentMethodType;
310+
hasValidNonHeldAmount: boolean;
311+
hasNoneHeldExpenses: boolean;
312+
} | null>(null);
313+
296314
const {markReportIDAsExpense, markReportIDAsMultiTransactionExpense, unmarkReportIDAsMultiTransactionExpense} = useWideRHPActions();
297315
const {
298316
currentSearchHash,
@@ -352,6 +370,26 @@ function Search({
352370
selector: savedSearchSelector,
353371
});
354372

373+
const handleHoldMenuOpen = useCallback(
374+
(item: TransactionReportGroupListItemType, requestType: ActionHandledType, paymentType?: PaymentMethodType) => {
375+
const chatReport = searchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${item.parentReportID}`];
376+
const moneyRequestReport = searchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`];
377+
const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(moneyRequestReport, item.allActions?.includes(CONST.SEARCH.ACTION_TYPES.PAY) ?? false);
378+
setHoldMenuParams({
379+
chatReport,
380+
moneyRequestReport,
381+
transactionCount: item.transactionCount ?? 0,
382+
fullAmount,
383+
requestType,
384+
paymentType,
385+
nonHeldAmount,
386+
hasValidNonHeldAmount,
387+
hasNoneHeldExpenses: item.transactions.some((t) => !isOnHold(t)),
388+
});
389+
setIsHoldMenuVisible(true);
390+
},
391+
[searchResults?.data],
392+
);
355393
const {convertToDisplayString} = useCurrencyListActions();
356394

357395
const validGroupBy = getValidGroupBy(groupBy);
@@ -1724,18 +1762,33 @@ function Search({
17241762
shouldAnimate={type === CONST.SEARCH.DATA_TYPES.EXPENSE}
17251763
newTransactions={newTransactions}
17261764
hasLoadedAllTransactions={hasLoadedAllTransactions}
1765+
onHoldMenuOpen={handleHoldMenuOpen}
17271766
policyForMovingExpenses={policyForMovingExpenses}
17281767
nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards}
17291768
isActionColumnWide={isTask || hasDeletedTransaction}
17301769
/>
1770+
{isHoldMenuVisible && !!holdMenuParams && (
1771+
<ProcessMoneyReportHoldMenu
1772+
isVisible={isHoldMenuVisible}
1773+
onClose={() => setIsHoldMenuVisible(false)}
1774+
chatReport={holdMenuParams.chatReport}
1775+
fullAmount={holdMenuParams.fullAmount}
1776+
moneyRequestReport={holdMenuParams.moneyRequestReport}
1777+
transactionCount={holdMenuParams.transactionCount}
1778+
hasNonHeldExpenses={holdMenuParams?.hasNoneHeldExpenses}
1779+
nonHeldAmount={holdMenuParams.hasNoneHeldExpenses && holdMenuParams.hasValidNonHeldAmount ? holdMenuParams.nonHeldAmount : undefined}
1780+
requestType={holdMenuParams.requestType}
1781+
paymentType={holdMenuParams.paymentType}
1782+
/>
1783+
)}
17311784
</Animated.View>
17321785
</SearchScopeProvider>
17331786
);
17341787
}
17351788

17361789
Search.displayName = 'Search';
17371790

1738-
export type {SearchProps};
1791+
export type {SearchProps, HoldMenuCallback};
17391792
const WrappedSearch = Sentry.withProfiler(Search) as typeof Search;
17401793
WrappedSearch.displayName = 'Search';
17411794

src/components/SelectionList/ListItem/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {ReactElement, ReactNode} from 'react';
22
import type {BlurEvent, NativeSyntheticEvent, Role, StyleProp, TargetedEvent, TextStyle, ViewStyle} from 'react-native';
33
import type {AnimatedStyle} from 'react-native-reanimated';
44
import type {ValueOf} from 'type-fest';
5+
import type {HoldMenuCallback} from '@components/Search';
56
import type {SearchRouterItem} from '@components/Search/SearchAutocompleteList';
67
import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types';
78
import type {TransactionPreviewData} from '@libs/actions/Search';
@@ -282,6 +283,9 @@ type ListItemProps<TItem extends ListItem> = CommonListItemProps<TItem> & {
282283
/** Callback when the input inside the item is blurred (if input exists) */
283284
onInputBlur?: (e: BlurEvent) => void;
284285

286+
/** Callback when the hold menu should be opened */
287+
onHoldMenuOpen?: HoldMenuCallback;
288+
285289
/** Whether to disable the hover style of the item */
286290
shouldDisableHoverStyle?: boolean;
287291

src/libs/SearchUIUtils.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2365,12 +2365,7 @@ function getActions(
23652365
const isAllowedToApproveExpenseReport = isAllowedToApproveExpenseReportUtils(report, submitToAccountID, policy);
23662366

23672367
// We're not supporting approve partial amount on search page now
2368-
if (
2369-
canApproveIOU(report, policy, reportMetadata, currentUserAccountID, allReportTransactions) &&
2370-
isAllowedToApproveExpenseReport &&
2371-
!hasOnlyPendingCardOrScanningTransactions &&
2372-
!hasHeldExpenses(report.reportID, allReportTransactions)
2373-
) {
2368+
if (canApproveIOU(report, policy, reportMetadata, currentUserAccountID, allReportTransactions) && isAllowedToApproveExpenseReport && !hasOnlyPendingCardOrScanningTransactions) {
23742369
allActions.push(CONST.SEARCH.ACTION_TYPES.APPROVE);
23752370
}
23762371

src/libs/actions/Search.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {FormOnyxValues} from '@components/Form/types';
66
import type {ContinueActionParams, PaymentMethod, PaymentMethodType} from '@components/KYCWall/types';
77
import type {LocalizedTranslate} from '@components/LocaleContextProvider';
88
import type {PopoverMenuItem} from '@components/PopoverMenu';
9+
import type {HoldMenuCallback} from '@components/Search';
910
import type {TransactionListItemType, TransactionReportGroupListItemType} from '@components/Search/SearchList/ListItem/types';
1011
import type {BankAccountMenuItem, BulkPaySelectionData, PaymentData, SearchQueryJSON, SelectedReports, SelectedTransactionInfo, SelectedTransactions} from '@components/Search/types';
1112
import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList';
@@ -105,6 +106,7 @@ type HandleActionButtonPressParams = {
105106
lastPaymentMethod: OnyxEntry<LastPaymentMethod>;
106107
userBillingGracePeriodEnds: OnyxCollection<BillingGraceEndPeriod>;
107108
currentSearchKey?: SearchKey;
109+
onHoldMenuOpen?: HoldMenuCallback;
108110
isDelegateAccessRestricted?: boolean;
109111
onDelegateAccessRestricted?: () => void;
110112
personalPolicyID: string | undefined;
@@ -135,6 +137,7 @@ function handleActionButtonPress({
135137
lastPaymentMethod,
136138
userBillingGracePeriodEnds,
137139
currentSearchKey,
140+
onHoldMenuOpen,
138141
isDelegateAccessRestricted,
139142
onDelegateAccessRestricted,
140143
personalPolicyID,
@@ -147,7 +150,12 @@ function handleActionButtonPress({
147150
const allReportTransactions = (isTransactionGroupListItemType(item) ? item.transactions : [item]) as Transaction[];
148151
const hasHeldExpense = hasHeldExpenses('', allReportTransactions);
149152

150-
if (hasHeldExpense && item.action !== CONST.SEARCH.ACTION_TYPES.SUBMIT && item.action !== CONST.SEARCH.ACTION_TYPES.UNDELETE) {
153+
if (
154+
hasHeldExpense &&
155+
item.action !== CONST.SEARCH.ACTION_TYPES.SUBMIT &&
156+
item.action !== CONST.SEARCH.ACTION_TYPES.UNDELETE &&
157+
(item.action !== CONST.SEARCH.ACTION_TYPES.APPROVE || !onHoldMenuOpen)
158+
) {
151159
goToItem();
152160
return;
153161
}
@@ -173,6 +181,10 @@ function handleActionButtonPress({
173181
Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(snapshotReport.policyID));
174182
return;
175183
}
184+
if (hasHeldExpense) {
185+
onHoldMenuOpen?.(item as TransactionReportGroupListItemType, CONST.IOU.REPORT_ACTION_TYPE.APPROVE);
186+
return;
187+
}
176188
approveMoneyRequestOnSearch(hash, item.reportID ? [item.reportID] : [], currentSearchKey);
177189
return;
178190
case CONST.SEARCH.ACTION_TYPES.SUBMIT: {

tests/unit/Search/handleActionButtonPressTest.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ describe('handleActionButtonPress', () => {
314314
const snapshotReport = mockSnapshotForItem?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${mockReportItemWithHold.reportID}`] ?? {};
315315
const snapshotPolicy = mockSnapshotForItem?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${mockReportItemWithHold.policyID}`] ?? {};
316316

317-
test('Should navigate to item when report has one transaction on hold', () => {
317+
test('Should not navigate to item when report has one transaction on hold and action is approve', () => {
318318
const goToItem = jest.fn(() => {});
319319
handleActionButtonPress({
320320
hash: searchHash,
@@ -327,8 +327,28 @@ describe('handleActionButtonPress', () => {
327327
ownerBillingGracePeriodEnd: undefined,
328328
amountOwed: undefined,
329329
userBillingGracePeriodEnds: undefined,
330+
onHoldMenuOpen: jest.fn(),
330331
});
331-
expect(goToItem).toHaveBeenCalledTimes(1);
332+
expect(goToItem).not.toHaveBeenCalled();
333+
});
334+
335+
test('Should open the hold menu when the report has one transaction on hold and action is approve', () => {
336+
const onHoldMenuOpen = jest.fn();
337+
handleActionButtonPress({
338+
hash: searchHash,
339+
item: mockReportItemWithHold,
340+
goToItem: jest.fn(),
341+
snapshotReport: snapshotReport as Report,
342+
snapshotPolicy: snapshotPolicy as Policy,
343+
lastPaymentMethod: mockLastPaymentMethod,
344+
personalPolicyID: undefined,
345+
userBillingGracePeriodEnds: undefined,
346+
ownerBillingGracePeriodEnd: undefined,
347+
amountOwed: undefined,
348+
onHoldMenuOpen,
349+
});
350+
351+
expect(onHoldMenuOpen).toHaveBeenCalledWith(mockReportItemWithHold, CONST.IOU.REPORT_ACTION_TYPE.APPROVE);
332352
});
333353

334354
test('Should not navigate to item when the hold is removed', () => {

0 commit comments

Comments
 (0)