-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Decompose MoneyRequestConfirmationList into view-model hooks #88720
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
marcochavezf
merged 13 commits into
Expensify:main
from
callstack-internal:decompose-MoneyRequestConfirmationList
Apr 30, 2026
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
27392fc
Decompose MoneyRequestConfirmationList into view-model hooks
OlimpiaZurek 1fec011
fix compiler
OlimpiaZurek 1087fc6
address comments
OlimpiaZurek 8070032
address review
OlimpiaZurek a4653a0
Merge branch 'main' into decompose-MoneyRequestConfirmationList
OlimpiaZurek 0a38636
add unit tests
OlimpiaZurek 92118d0
fix lint
OlimpiaZurek d314004
add docs
OlimpiaZurek e01a902
small fix
OlimpiaZurek 1e34e94
fix routeError type
OlimpiaZurek 8b98fed
fix conflicts
OlimpiaZurek 6ab8411
fix lint
OlimpiaZurek 0060183
fix lint
OlimpiaZurek File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
160 changes: 160 additions & 0 deletions
160
src/components/MoneyRequestConfirmationList/ConfirmationFooterContent.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| import React from 'react'; | ||
| import {View} from 'react-native'; | ||
| import Button from '@components/Button'; | ||
| import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; | ||
| import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; | ||
| import FormHelpMessage from '@components/FormHelpMessage'; | ||
| import SettlementButton from '@components/SettlementButton'; | ||
| import type {PaymentActionParams} from '@components/SettlementButton/types'; | ||
| import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; | ||
| import useLocalize from '@hooks/useLocalize'; | ||
| import useThemeStyles from '@hooks/useThemeStyles'; | ||
| import type {IOUType} from '@src/CONST'; | ||
| import CONST from '@src/CONST'; | ||
| import ROUTES from '@src/ROUTES'; | ||
| import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; | ||
|
|
||
| type ConfirmationFooterContentProps = { | ||
| /** IOU type currently being confirmed (submit / split / track / pay / invoice) */ | ||
| iouType: IOUType; | ||
|
|
||
| /** Click handler invoked when the user taps the primary confirmation button */ | ||
| confirm: (params: PaymentActionParams) => void; | ||
|
|
||
| /** Currency the IOU is being created in, used by the Pay settlement button */ | ||
| iouCurrencyCode: string; | ||
|
|
||
| /** Policy the IOU belongs to, when applicable */ | ||
| policyID: string | undefined; | ||
|
|
||
| /** Report the IOU is being created on */ | ||
| reportID: string; | ||
|
|
||
| /** Whether the confirmation has already been submitted (locks the button) */ | ||
| isConfirmed: boolean | undefined; | ||
|
|
||
| /** Whether a confirmation request is currently in flight */ | ||
| isConfirming: boolean | undefined; | ||
|
|
||
| /** Whether a SmartScan receipt is still being processed */ | ||
| isLoadingReceipt: boolean; | ||
|
|
||
| /** Dropdown options for the primary CTA (e.g. Submit / Submit & Close) */ | ||
| splitOrRequestOptions: Array<DropdownOption<string>>; | ||
|
|
||
| /** Inline error message displayed above the button, if any */ | ||
| errorMessage: string | undefined; | ||
|
|
||
| /** Number of expenses that will be created on confirm (drives bulk copy) */ | ||
| expensesNumber: number; | ||
|
|
||
| /** Optional callback to show a confirm-modal before removing an expense */ | ||
| showRemoveExpenseConfirmModal: (() => void) | undefined; | ||
|
|
||
| /** Whether the product-training tooltip should anchor to the button */ | ||
| shouldShowProductTrainingTooltip: boolean; | ||
|
|
||
| /** Renders the product-training tooltip content */ | ||
| renderProductTrainingTooltip: () => React.ReactElement; | ||
| }; | ||
|
|
||
| function ConfirmationFooterContent({ | ||
| iouType, | ||
| confirm, | ||
| iouCurrencyCode, | ||
| policyID, | ||
| reportID, | ||
| isConfirmed, | ||
| isConfirming, | ||
| isLoadingReceipt, | ||
| splitOrRequestOptions, | ||
| errorMessage, | ||
| expensesNumber, | ||
| showRemoveExpenseConfirmModal, | ||
| shouldShowProductTrainingTooltip, | ||
| renderProductTrainingTooltip, | ||
| }: ConfirmationFooterContentProps) { | ||
| const styles = useThemeStyles(); | ||
| const {translate} = useLocalize(); | ||
|
|
||
| const shouldShowSettlementButton = iouType === CONST.IOU.TYPE.PAY; | ||
|
|
||
| const button = shouldShowSettlementButton ? ( | ||
| <SettlementButton | ||
| pressOnEnter | ||
| onPress={confirm} | ||
| enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS} | ||
| chatReportID={reportID} | ||
| shouldShowPersonalBankAccountOption | ||
| currency={iouCurrencyCode} | ||
| policyID={policyID} | ||
| buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE} | ||
| kycWallAnchorAlignment={{ | ||
| horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, | ||
| vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, | ||
| }} | ||
| paymentMethodDropdownAnchorAlignment={{ | ||
| horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, | ||
| vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, | ||
| }} | ||
| enterKeyEventListenerPriority={1} | ||
| useKeyboardShortcuts | ||
| // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Using || because we want undefined and false to both be treated as falsy for isLoading | ||
| isLoading={isConfirmed || isConfirming} | ||
|
OlimpiaZurek marked this conversation as resolved.
|
||
| sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST.CONFIRMATION_PAY_BUTTON} | ||
| /> | ||
| ) : ( | ||
| <> | ||
| {expensesNumber > 1 && ( | ||
| <Button | ||
| large | ||
| text={translate('iou.removeThisExpense')} | ||
| onPress={showRemoveExpenseConfirmModal} | ||
| style={styles.mb3} | ||
| sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST.CONFIRMATION_REMOVE_EXPENSE_BUTTON} | ||
| /> | ||
| )} | ||
| <EducationalTooltip | ||
| shouldRender={shouldShowProductTrainingTooltip} | ||
| renderTooltipContent={renderProductTrainingTooltip} | ||
| anchorAlignment={{ | ||
| horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.CENTER, | ||
| vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, | ||
| }} | ||
| wrapperStyle={styles.productTrainingTooltipWrapper} | ||
| shouldHideOnNavigate | ||
| shiftVertical={-10} | ||
| > | ||
| <View> | ||
| <ButtonWithDropdownMenu | ||
| pressOnEnter | ||
| onPress={(event, value) => confirm({paymentType: value as PaymentMethodType})} | ||
| options={splitOrRequestOptions} | ||
| buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE} | ||
| enterKeyEventListenerPriority={1} | ||
| useKeyboardShortcuts | ||
| // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Using || because we want undefined and false to both be treated as falsy for isLoading | ||
| isLoading={isConfirmed || isConfirming || isLoadingReceipt} | ||
|
OlimpiaZurek marked this conversation as resolved.
|
||
| sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST.CONFIRMATION_SUBMIT_BUTTON} | ||
| /> | ||
| </View> | ||
| </EducationalTooltip> | ||
| </> | ||
| ); | ||
|
|
||
| return ( | ||
| <> | ||
| {!!errorMessage && ( | ||
| <FormHelpMessage | ||
| style={[styles.ph1, styles.mb2]} | ||
| isError | ||
| message={errorMessage} | ||
| /> | ||
| )} | ||
| <View>{button}</View> | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| export default ConfirmationFooterContent; | ||
| export type {ConfirmationFooterContentProps}; | ||
126 changes: 126 additions & 0 deletions
126
src/components/MoneyRequestConfirmationList/confirmAction.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| import type {OnyxEntry} from 'react-native-onyx'; | ||
| import type {PaymentActionParams} from '@components/SettlementButton/types'; | ||
| import Log from '@libs/Log'; | ||
| import Navigation from '@libs/Navigation/Navigation'; | ||
| import {hasInvoicingDetails} from '@userActions/Policy/Policy'; | ||
| import CONST from '@src/CONST'; | ||
| import type {IOUType} from '@src/CONST'; | ||
| import type {TranslationPaths} from '@src/languages/types'; | ||
| import ROUTES from '@src/ROUTES'; | ||
| import type * as OnyxTypes from '@src/types/onyx'; | ||
| import type {Participant} from '@src/types/onyx/IOU'; | ||
| import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; | ||
|
|
||
| type BuildConfirmActionParams = { | ||
| /** IOU type being confirmed (submit / split / track / pay / invoice) */ | ||
| iouType: IOUType; | ||
|
|
||
| /** Policy the IOU belongs to, used to detect missing invoice company info */ | ||
| policy: OnyxEntry<OnyxTypes.Policy>; | ||
|
|
||
| /** Transaction being confirmed, when one exists */ | ||
| transactionID: string | undefined; | ||
|
|
||
| /** Report the IOU is being created on */ | ||
| reportID: string; | ||
|
|
||
| /** Truthy when the route to the confirmation page has a known error */ | ||
| routeError: string | null | undefined; | ||
|
|
||
| /** Current form-level error key, or '' when no error is set */ | ||
| formError: TranslationPaths | ''; | ||
|
|
||
| /** Participants selected for this IOU */ | ||
| selectedParticipants: Participant[]; | ||
|
|
||
| /** Whether the current user is a delegate without permission to pay */ | ||
| isDelegateAccessRestricted: boolean; | ||
|
|
||
| /** Pure validator that returns the first failing translation key, or null on success */ | ||
| validate: (paymentType?: PaymentMethodType) => {errorKey: TranslationPaths; shouldSetDidConfirmSplit?: boolean} | {errorKey: null} | null; | ||
|
|
||
| /** Setter for the form-level error key */ | ||
| setFormError: (error: TranslationPaths | '') => void; | ||
|
|
||
| /** Setter for the "user already confirmed split" flag */ | ||
| setDidConfirmSplit: (value: boolean) => void; | ||
|
|
||
| /** Shows the modal that explains delegate-no-access restrictions */ | ||
| showDelegateNoAccessModal: () => void; | ||
|
|
||
| /** Caller-provided confirm handler for non-pay flows */ | ||
| onConfirm?: (selectedParticipants: Participant[]) => void; | ||
|
|
||
| /** Caller-provided send-money handler for pay flows */ | ||
| onSendMoney?: (paymentMethod: PaymentMethodType | undefined) => void; | ||
|
Comment on lines
+16
to
+55
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. docs |
||
| }; | ||
|
|
||
| /** | ||
| * Owns the click-confirm action for the Money Request confirmation flow. | ||
| * | ||
| * Handles three branches: (1) invoice-without-company-info routes to the company info | ||
| * step before validation; (2) non-PAY types invoke `onConfirm`; (3) PAY types run | ||
| * delegate-access gating and invoke `onSendMoney` with the chosen payment method. | ||
| * Validation results drive form-error state. | ||
| */ | ||
| function buildConfirmAction({ | ||
| iouType, | ||
| policy, | ||
| transactionID, | ||
| reportID, | ||
| routeError, | ||
| formError, | ||
| selectedParticipants, | ||
| isDelegateAccessRestricted, | ||
| validate, | ||
| setFormError, | ||
| setDidConfirmSplit, | ||
| showDelegateNoAccessModal, | ||
| onConfirm, | ||
| onSendMoney, | ||
| }: BuildConfirmActionParams) { | ||
| return ({paymentType: paymentMethod}: PaymentActionParams) => { | ||
| // Routing short-circuit: invoices without company info go to the company info step before we validate anything. | ||
| if (iouType === CONST.IOU.TYPE.INVOICE && !hasInvoicingDetails(policy) && transactionID && !routeError) { | ||
| Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_COMPANY_INFO.getRoute(iouType, transactionID, reportID, Navigation.getActiveRoute())); | ||
| return; | ||
| } | ||
|
|
||
| const result = validate(paymentMethod); | ||
| if (!result) { | ||
| return; | ||
| } | ||
|
|
||
| if (result.errorKey) { | ||
| if (result.shouldSetDidConfirmSplit) { | ||
| setDidConfirmSplit(true); | ||
| } | ||
| setFormError(result.errorKey); | ||
| return; | ||
| } | ||
|
|
||
| if (iouType !== CONST.IOU.TYPE.PAY) { | ||
| if (formError) { | ||
| return; | ||
| } | ||
| onConfirm?.(selectedParticipants); | ||
| return; | ||
| } | ||
|
|
||
| // PAY branch side effects. | ||
| if (!paymentMethod) { | ||
| return; | ||
| } | ||
| if (isDelegateAccessRestricted) { | ||
| showDelegateNoAccessModal(); | ||
| return; | ||
| } | ||
| if (formError) { | ||
| return; | ||
| } | ||
| Log.info(`[IOU] Sending money via: ${paymentMethod}`); | ||
| onSendMoney?.(paymentMethod); | ||
| }; | ||
| } | ||
|
|
||
| export default buildConfirmAction; | ||
104 changes: 104 additions & 0 deletions
104
src/components/MoneyRequestConfirmationList/hooks/useConfirmationAmount.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import type {OnyxEntry} from 'react-native-onyx'; | ||
| import {useCurrencyListActions} from '@hooks/useCurrencyList'; | ||
| import useLocalize from '@hooks/useLocalize'; | ||
| import {computePerDiemExpenseAmount} from '@libs/actions/IOU/PerDiem'; | ||
| import type {getAttendees} from '@libs/TransactionUtils'; | ||
| import {isScanning, isScanRequest as isScanRequestUtil} from '@libs/TransactionUtils'; | ||
| import type * as OnyxTypes from '@src/types/onyx'; | ||
|
|
||
| type SubRates = NonNullable<NonNullable<NonNullable<OnyxTypes.Transaction['comment']>['customUnit']>['subRates']>; | ||
|
|
||
| type UseConfirmationAmountParams = { | ||
| /** Transaction whose amount we're computing */ | ||
| transaction: OnyxEntry<OnyxTypes.Transaction>; | ||
|
|
||
| /** Total IOU amount entered by the user */ | ||
| iouAmount: number; | ||
|
|
||
| /** Currency the IOU is being created in */ | ||
| iouCurrencyCode: string | undefined; | ||
|
|
||
| /** Currently selected attendees, used to compute the per-attendee amount */ | ||
| iouAttendees: ReturnType<typeof getAttendees>; | ||
|
|
||
| /** Whether the transaction is a distance request */ | ||
| isDistanceRequest: boolean; | ||
|
|
||
| /** Whether the distance request route is still pending */ | ||
| isDistanceRequestWithPendingRoute: boolean; | ||
|
|
||
| /** Whether the distance amount needs to be (re)calculated this render */ | ||
| shouldCalculateDistanceAmount: boolean; | ||
|
|
||
| /** Pre-computed distance amount in the smallest currency unit */ | ||
| distanceRequestAmount: number; | ||
|
|
||
| /** Currency reported by the active mileage rate */ | ||
| distanceCurrency: string | undefined; | ||
|
|
||
| /** Whether the transaction is a per-diem request */ | ||
| isPerDiemRequest: boolean; | ||
|
|
||
| /** Currency the transaction had on the previous render, used to detect changes */ | ||
| prevCurrency: string | undefined; | ||
|
|
||
| /** Currency the transaction has on the current render */ | ||
| currency: string | undefined; | ||
|
|
||
| /** Per-diem sub-rates from the previous render, used to detect rate changes */ | ||
| prevSubRates: SubRates; | ||
|
Comment on lines
+13
to
+49
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. docs |
||
| }; | ||
|
|
||
| /** | ||
| * Computes the display amount and per-attendee amount for the confirmation flow. | ||
| * | ||
| * Handles the three amount sources — distance (recalculated from the route), per-diem | ||
| * (summed from sub-rates), and plain IOU amount — and formats them for display, | ||
| * including the pending-route and scanning special cases. | ||
| */ | ||
| function useConfirmationAmount({ | ||
| transaction, | ||
| iouAmount, | ||
| iouCurrencyCode, | ||
| iouAttendees, | ||
| isDistanceRequest, | ||
| isDistanceRequestWithPendingRoute, | ||
| shouldCalculateDistanceAmount, | ||
| distanceRequestAmount, | ||
| distanceCurrency, | ||
| isPerDiemRequest, | ||
| prevCurrency, | ||
| currency, | ||
| prevSubRates, | ||
| }: UseConfirmationAmountParams) { | ||
| const {translate} = useLocalize(); | ||
| const {convertToDisplayString} = useCurrencyListActions(); | ||
|
|
||
| const isScanRequest = isScanRequestUtil(transaction); | ||
|
|
||
| const subRates = transaction?.comment?.customUnit?.subRates ?? []; | ||
| const shouldCalculatePerDiemAmount = isPerDiemRequest && (iouAmount === 0 || JSON.stringify(prevSubRates) !== JSON.stringify(subRates) || prevCurrency !== currency); | ||
|
|
||
| let amountToBeUsed = iouAmount; | ||
| if (shouldCalculateDistanceAmount) { | ||
| amountToBeUsed = distanceRequestAmount; | ||
| } else if (shouldCalculatePerDiemAmount) { | ||
| amountToBeUsed = computePerDiemExpenseAmount({subRates}); | ||
| } | ||
|
|
||
| const displayCurrency = isDistanceRequest ? distanceCurrency : iouCurrencyCode; | ||
|
|
||
| let formattedAmount = convertToDisplayString(amountToBeUsed, displayCurrency); | ||
| if (isDistanceRequestWithPendingRoute) { | ||
| formattedAmount = ''; | ||
| } else if (isScanning(transaction)) { | ||
| formattedAmount = translate('iou.receiptStatusTitle'); | ||
| } | ||
|
|
||
| const attendeeCount = iouAttendees?.length && iouAttendees.length > 0 ? iouAttendees.length : 1; | ||
| const formattedAmountPerAttendee = isDistanceRequestWithPendingRoute || isScanRequest ? '' : convertToDisplayString(amountToBeUsed / attendeeCount, displayCurrency); | ||
|
|
||
| return {amountToBeUsed, formattedAmount, formattedAmountPerAttendee, isScanRequest}; | ||
| } | ||
|
|
||
| export default useConfirmationAmount; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
docs