Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions src/components/ExpenseHeaderApprovalButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import type {Report, Transaction} from '@src/types/onyx';
import type AnchorAlignment from '@src/types/utils/AnchorAlignment';
import type IconAsset from '@src/types/utils/IconAsset';

import type {StyleProp, ViewStyle} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';

import React from 'react';

Expand Down Expand Up @@ -44,6 +46,30 @@ type ExpenseHeaderApprovalButtonProps = {

/** Whether to disable the approve button */
isDisabled?: boolean;

/** Whether the button should show a loading spinner */
isLoading?: boolean;

/** The size of the button */
size?: ValueOf<typeof CONST.BUTTON_SIZE>;

/** Whether the dropdown button should use its compact inline form */
shouldUseShortForm?: boolean;

/** Whether the button is rendered inside another pressable, since nesting buttons isn't valid html */
isNested?: boolean;

/** Whether the button should stay visually normal even when disabled */
stayNormalOnDisable?: boolean;

/** Additional styles to add to the button */
style?: StyleProp<ViewStyle>;

/** Additional styles to add to the dropdown button's wrapper */
wrapperStyle?: StyleProp<ViewStyle>;

/** Label used to identify this button in Sentry */
sentryLabel?: string;
};

type ApprovalOption = {
Expand Down Expand Up @@ -115,6 +141,14 @@ function ExpenseHeaderApprovalButton({
transactions,
shouldShowPayButton,
isDisabled,
isLoading,
size,
shouldUseShortForm,
isNested,
stayNormalOnDisable,
style,
wrapperStyle,
sentryLabel = CONST.SENTRY_LABEL.REPORT_PREVIEW.APPROVE_BUTTON,
}: ExpenseHeaderApprovalButtonProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
Expand Down Expand Up @@ -153,7 +187,13 @@ function ExpenseHeaderApprovalButton({
// edge — the menu would otherwise be clamped to the window edge and cover the header. Flip instead.
shouldSwitchPositionIfOverflow
isDisabled={isDisabled}
sentryLabel={CONST.SENTRY_LABEL.REPORT_PREVIEW.APPROVE_BUTTON}
isLoading={isLoading}
size={size}
shouldUseShortForm={shouldUseShortForm}
stayNormalOnDisable={stayNormalOnDisable}
style={style}
wrapperStyle={wrapperStyle}
sentryLabel={sentryLabel}
/>
);
}
Expand All @@ -162,8 +202,13 @@ function ExpenseHeaderApprovalButton({
<Button
variant={CONST.BUTTON_VARIANT.SUCCESS}
onPress={() => onApprove(true)}
sentryLabel={CONST.SENTRY_LABEL.REPORT_PREVIEW.APPROVE_BUTTON}
sentryLabel={sentryLabel}
isDisabled={isDisabled}
isLoading={isLoading}
size={size}
isNested={isNested}
stayNormalOnDisable={stayNormalOnDisable}
style={style}
>
<Button.Text>{translate('iou.approve')}</Button.Text>
</Button>
Expand Down
7 changes: 5 additions & 2 deletions src/components/Modal/Global/HoldMenuModalWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ function HoldMenuModalWrapper({
requestType,
paymentType,
methodID,
nonHeldAmount = '0',
nonHeldAmount,
fullAmount,
hasNonHeldExpenses,
transactionCount,
Expand Down Expand Up @@ -88,7 +88,10 @@ function HoldMenuModalWrapper({
onClose={() => setIsVisible(false)}
isVisible={isVisible}
prompt={approvalPrompt}
firstOptionText={hasNonHeldExpenses ? `${translate(isApprove ? 'iou.approveOnly' : 'iou.payOnly')} ${nonHeldAmount}` : undefined}
// Callers pass `undefined` when the non-held amount isn't meaningfully different from the full amount, so
// gate on the amount itself rather than on `hasNonHeldExpenses` — otherwise a report whose unheld expenses
// net out to nothing offers a partial option for a zero amount.
firstOptionText={nonHeldAmount !== undefined ? `${translate(isApprove ? 'iou.approveOnly' : 'iou.payOnly')} ${nonHeldAmount}` : undefined}
secondOptionText={`${translate(isApprove ? 'iou.approve' : 'iou.pay')} ${fullAmount}`}
onFirstOptionSubmit={() => onSubmit(false)}
onSecondOptionSubmit={() => onSubmit(true)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {isSubmitPolicy} from '@libs/PolicyUtils';
import {hasHeldExpensesFromTransactions as hasHeldExpensesReportUtils, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils';

import {approveMoneyRequest} from '@userActions/IOU/ReportWorkflow';
import type AdditionalPayOnyxData from '@userActions/IOU/types/AdditionalPayOnyxData';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand All @@ -20,7 +21,13 @@ import {delegateEmailSelector} from '@selectors/Account';
import {isTrackIntentUserSelector} from '@selectors/Onboarding';
import {personalDetailsLoginSelector} from '@selectors/PersonalDetails';

function useConfirmApproval(reportID: string | undefined, startApprovedAnimation: () => void) {
/**
* Shared approve handler for the report header, report preview and Search rows.
*
* `getAdditionalOnyxData` is resolved at approve time (not on every render) so Search rows can attach the
* optimistic data that removes the row from the current results.
*/
function useConfirmApproval(reportID: string | undefined, startApprovedAnimation: () => void, getAdditionalOnyxData?: () => AdditionalPayOnyxData) {
const {accountID, email} = useCurrentUserPersonalDetails();
const {getCurrencyDecimals} = useCurrencyListActions();
const {isBetaEnabled} = usePermissions();
Expand Down Expand Up @@ -74,6 +81,7 @@ function useConfirmApproval(reportID: string | undefined, startApprovedAnimation
delegateEmail,
delegateAccountID,
isTrackIntentUser,
additionalOnyxData: getAdditionalOnyxData?.(),
});
};

Expand Down
7 changes: 5 additions & 2 deletions src/components/ProcessMoneyReportHoldMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ type ProcessMoneyReportHoldMenuProps = {
};

function ProcessMoneyReportHoldMenu({
nonHeldAmount = '0',
nonHeldAmount,
fullAmount,
onClose,
isVisible,
Expand Down Expand Up @@ -81,7 +81,10 @@ function ProcessMoneyReportHoldMenu({
onClose={onClose}
isVisible={isVisible}
prompt={promptText}
firstOptionText={hasNonHeldExpenses ? `${translate('iou.payOnly')} ${nonHeldAmount}` : undefined}
// Callers pass `undefined` when the non-held amount isn't meaningfully different from the full amount, so
// gate on the amount itself rather than on `hasNonHeldExpenses` — otherwise a report whose unheld expenses
// net out to nothing offers a partial option for a zero amount.
firstOptionText={nonHeldAmount !== undefined ? `${translate('iou.payOnly')} ${nonHeldAmount}` : undefined}
secondOptionText={`${translate('iou.pay')} ${fullAmount}`}
onFirstOptionSubmit={() => onSubmit(false)}
onSecondOptionSubmit={() => onSubmit(true)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider';
import ExpenseHeaderApprovalButton from '@components/ExpenseHeaderApprovalButton';
import useConfirmApproval from '@components/MoneyReportHeaderPrimaryAction/useConfirmApproval';
import {useSearchQueryContext} from '@components/Search/SearchContext';
import {SearchScopeProvider} from '@components/Search/SearchScopeProvider';

import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import usePolicy from '@hooks/usePolicy';
import useReportWithTransactionsAndViolations from '@hooks/useReportWithTransactionsAndViolations';
import useThemeStyles from '@hooks/useThemeStyles';

import {getSearchApproveOnyxData} from '@libs/actions/Search';
import {hasHeldExpensesFromTransactions as hasHeldExpensesReportUtils} from '@libs/ReportUtils';

import {canIOUBePaid as canIOUBePaidAction} from '@userActions/IOU/ReportWorkflow';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Report} from '@src/types/onyx';

import type {OnyxEntry} from 'react-native-onyx';

import React from 'react';

type ApproveActionCellProps = {
isLoading: boolean;
reportID: string;
hash?: number;
shouldDisablePointerEvents?: boolean;
chatReport: OnyxEntry<Report>;
};

/**
* Approve action for a Search row. Mirrors PayActionCell in owning the action end to end, so the row can render the
* same ExpenseHeaderApprovalButton the report header uses and surface the partial/full approval choice up front when
* the report has held expenses, rather than routing through the (pay-only) hold menu.
*/
function ApproveActionCell({isLoading, reportID, hash, shouldDisablePointerEvents, chatReport}: ApproveActionCellProps) {
const styles = useThemeStyles();
const {isOffline} = useNetwork();
const currentUserDetails = useCurrentUserPersonalDetails();
const {isDelegateAccessRestricted} = useDelegateNoAccessState();
const {currentSearchKey} = useSearchQueryContext();

const [iouReport, transactions] = useReportWithTransactionsAndViolations(reportID);
const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
const activePolicy = usePolicy(activePolicyID);
const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST);

const invoiceReceiverPolicyID = iouReport?.invoiceReceiver && 'policyID' in iouReport.invoiceReceiver ? iouReport.invoiceReceiver.policyID : undefined;
const invoiceReceiverPolicy = usePolicy(invoiceReceiverPolicyID);

const isAnyTransactionOnHold = hasHeldExpensesReportUtils(transactions);

// Same derivation as ApprovePrimaryAction: the non-held amount only excludes non-reimbursables when a Pay button would show.
const canIOUBePaid = canIOUBePaidAction(
iouReport,
chatReport,
activePolicy,
bankAccountList,
currentUserDetails.login ?? '',
currentUserDetails.accountID,
// `undefined` (not the row's transactions) matches ApprovePrimaryAction, so Spend and the report header
// derive shouldShowPayButton — and therefore the displayed approval amounts — identically.
undefined,
false,
undefined,
invoiceReceiverPolicy,
);
const onlyShowPayElsewhere =
!canIOUBePaid &&
canIOUBePaidAction(
iouReport,
chatReport,
activePolicy,
bankAccountList,
currentUserDetails.login ?? '',
currentUserDetails.accountID,
undefined,
true,
undefined,
invoiceReceiverPolicy,
);

// Search rows have no approval animation, but they do need the optimistic data that drops the row from the results.
const {onApprove} = useConfirmApproval(reportID, () => {}, hash === undefined ? undefined : () => getSearchApproveOnyxData(hash, reportID, currentSearchKey));

return (
<SearchScopeProvider isOnSearch={false}>
<ExpenseHeaderApprovalButton
isAnyTransactionOnHold={isAnyTransactionOnHold}
isDelegateAccessRestricted={isDelegateAccessRestricted}
onApprove={onApprove}
anchorAlignment={{
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
}}
moneyRequestReport={iouReport}
transactions={transactions}
shouldShowPayButton={canIOUBePaid || onlyShowPayElsewhere}
isLoading={isLoading}
size={CONST.BUTTON_SIZE.SMALL}
shouldUseShortForm
isNested
isDisabled={isOffline || shouldDisablePointerEvents}
stayNormalOnDisable={shouldDisablePointerEvents}
style={[styles.w100, shouldDisablePointerEvents && styles.pointerEventsNone]}
wrapperStyle={styles.w100}
sentryLabel={CONST.SENTRY_LABEL.SEARCH.ACTION_CELL_ACTION}
/>
</SearchScopeProvider>
);
}

export default ApproveActionCell;
13 changes: 13 additions & 0 deletions src/components/Search/SearchList/ListItem/ActionCell/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {OnyxEntry} from 'react-native-onyx';
import React from 'react';

import actionTranslationsMap from './actionTranslationsMap';
import ApproveActionCell from './ApproveActionCell';
import PayActionCell from './PayActionCell';

type ActionCellProps = {
Expand Down Expand Up @@ -76,6 +77,18 @@ function ActionCell({
);
}

if (action === CONST.SEARCH.ACTION_TYPES.APPROVE) {
return (
<ApproveActionCell
isLoading={isLoading}
reportID={reportID}
hash={hash}
shouldDisablePointerEvents={shouldDisablePointerEvents}
chatReport={chatReport}
/>
);
}

if (action === CONST.SEARCH.ACTION_TYPES.PAY) {
return (
<PayActionCell
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
import useConfirmModal from '@hooks/useConfirmModal';
import {useCurrencyListActions} from '@hooks/useCurrencyList';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useHoldMenuModal from '@hooks/useHoldMenuModal';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
Expand All @@ -31,10 +30,10 @@ import {handleActionButtonPress} from '@libs/actions/Search';
import {syncMissingAttendeesViolation} from '@libs/AttendeeUtils';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {isAttendeeTrackingEnabled} from '@libs/PolicyUtils';
import {getNonHeldAndFullAmount, isInvoiceReport, isOpenExpenseReport, isProcessingReport, isReportPendingDelete, shouldShowMarkAsDone} from '@libs/ReportUtils';
import {isInvoiceReport, isOpenExpenseReport, isProcessingReport, isReportPendingDelete, shouldShowMarkAsDone} from '@libs/ReportUtils';
import {hasVisibleViolations} from '@libs/SearchUIUtils';
import shouldBreakAccessibilityGrouping from '@libs/shouldBreakAccessibilityGrouping';
import {isOnHold, isViolationDismissed, shouldShowViolation, showHeldExpensesBlockModal, showPendingCardTransactionsBlockModal} from '@libs/TransactionUtils';
import {isViolationDismissed, shouldShowViolation, showHeldExpensesBlockModal, showPendingCardTransactionsBlockModal} from '@libs/TransactionUtils';

import variables from '@styles/variables';

Expand Down Expand Up @@ -213,7 +212,6 @@ function ExpenseReportListItemInner<TItem extends ListItem>({
const {isDelegateAccessRestricted} = useDelegateNoAccessState();
const {showDelegateNoAccessModal} = useDelegateNoAccessActions();
const {showConfirmModal} = useConfirmModal();
const {showHoldMenu} = useHoldMenuModal();
const openReportSubmitToPopover = useOpenReportSubmitToPopover();
const {shouldDisableSearchSubmitPress, consumeIgnoreNextSearchSubmitPress} = useSearchSubmitPopoverGuard();
const {transactions: reportTransactions, violations: reportViolations} = useTransactionsAndViolationsForReport(reportItem.reportID);
Expand Down Expand Up @@ -260,31 +258,6 @@ function ExpenseReportListItemInner<TItem extends ListItem>({
isDelegateAccessRestricted,
onDelegateAccessRestricted: showDelegateNoAccessModal,
personalPolicyID,
onHoldMenuOpen: (holdItem, requestType, paymentType) => {
// Search rows render from a snapshot; the report may not exist in the main
// collection yet. Fall back to the snapshot so the modal can submit.
const moneyRequestReport = parentReport ?? snapshotReport;
const transactionsForHoldMenu = liveReportTransactions.length > 0 ? liveReportTransactions : holdItem.transactions;
const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(
moneyRequestReport,
holdItem.canPay ?? false,
transactionsForHoldMenu,
convertToDisplayString,
);
const hasNonHeldExpenses = transactionsForHoldMenu.some((t) => !isOnHold(t));
showHoldMenu({
reportID: holdItem.reportID,
chatReportID: holdItem.parentReportID,
moneyRequestReport,
chatReport,
requestType,
paymentType,
nonHeldAmount: hasNonHeldExpenses && hasValidNonHeldAmount ? nonHeldAmount : undefined,
fullAmount,
hasNonHeldExpenses,
transactionCount: transactionsForHoldMenu.length > 0 ? transactionsForHoldMenu.length : (holdItem.transactionCount ?? 0),
});
},
ownerBillingGracePeriodEnd,
amountOwed,
openReportSubmitToPopover,
Expand Down Expand Up @@ -323,23 +296,19 @@ function ExpenseReportListItemInner<TItem extends ListItem>({
snapshotPolicy,
submitterLogin,
parentPolicy,
parentReport,
lastPaymentMethod,
userBillingGracePeriodEnds,
personalPolicyID,
currentSearchKey,
isDelegateAccessRestricted,
showDelegateNoAccessModal,
showHoldMenu,
liveReportTransactions,
ownerBillingGracePeriodEnd,
amountOwed,
openReportSubmitToPopover,
shouldDisableSearchSubmitPress,
consumeIgnoreNextSearchSubmitPress,
showConfirmModal,
translate,
convertToDisplayString,
getCurrencyDecimals,
currentUserAccountID,
currentUserLogin,
Expand Down
Loading
Loading