Skip to content

Commit 4515ef6

Browse files
authored
Merge pull request Expensify#66790 from getusha/feat-standardize-pay-button-expense-n-ious-2
feat: standardize pay button for expenses and IOUs
2 parents fa2e2b1 + 0da978e commit 4515ef6

55 files changed

Lines changed: 1518 additions & 320 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/CONST/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6899,9 +6899,9 @@ const CONST = {
68996899
},
69006900
LAST_PAYMENT_METHOD: {
69016901
LAST_USED: 'lastUsed',
6902-
IOU: 'Iou',
6903-
EXPENSE: 'Expense',
6904-
INVOICE: 'Invoice',
6902+
IOU: 'iou',
6903+
EXPENSE: 'expense',
6904+
INVOICE: 'invoice',
69056905
},
69066906
SKIPPABLE_COLLECTION_MEMBER_IDS: [String(DEFAULT_NUMBER_ID), '-1', 'undefined', 'null', 'NaN'] as string[],
69076907
SETUP_SPECIALIST_LOGIN: 'Setup Specialist',

src/components/Button/index.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,19 @@ function Button(
310310
const textComponent = secondLineText ? (
311311
<View style={[styles.alignItemsCenter, styles.flexColumn, styles.flexShrink1]}>
312312
{primaryText}
313-
<Text style={[isLoading && styles.opacity0, styles.pointerEventsNone, styles.fontWeightNormal, styles.textDoubleDecker]}>{secondLineText}</Text>
313+
<Text
314+
style={[
315+
isLoading && styles.opacity0,
316+
styles.pointerEventsNone,
317+
styles.fontWeightNormal,
318+
styles.textDoubleDecker,
319+
!!secondLineText && styles.textExtraSmallSupporting,
320+
styles.textWhite,
321+
styles.textBold,
322+
]}
323+
>
324+
{secondLineText}
325+
</Text>
314326
</View>
315327
) : (
316328
primaryText

src/components/ButtonWithDropdownMenu/index.tsx

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import useTheme from '@hooks/useTheme';
1414
import useThemeStyles from '@hooks/useThemeStyles';
1515
import useWindowDimensions from '@hooks/useWindowDimensions';
1616
import mergeRefs from '@libs/mergeRefs';
17+
import variables from '@styles/variables';
1718
import CONST from '@src/CONST';
1819
import type {AnchorPosition} from '@src/styles';
1920
import type {ButtonWithDropdownMenuProps} from './types';
@@ -56,7 +57,10 @@ function ButtonWithDropdownMenuInner<IValueType>(props: ButtonWithDropdownMenuPr
5657
testID,
5758
secondLineText = '',
5859
icon,
60+
shouldPopoverUseScrollView = false,
61+
containerStyles,
5962
shouldUseModalPaddingStyle = true,
63+
shouldUseShortForm = false,
6064
shouldUseOptionIcon = false,
6165
} = props;
6266

@@ -79,9 +83,14 @@ function ButtonWithDropdownMenuInner<IValueType>(props: ButtonWithDropdownMenuPr
7983
const areAllOptionsDisabled = options.every((option) => option.disabled);
8084
const innerStyleDropButton = StyleUtils.getDropDownButtonHeight(buttonSize);
8185
const isButtonSizeLarge = buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE;
86+
const isButtonSizeSmall = buttonSize === CONST.DROPDOWN_BUTTON_SIZE.SMALL;
8287
const nullCheckRef = (refParam: RefObject<View | null>) => refParam ?? null;
8388
const shouldShowButtonRightIcon = !!options.at(0)?.shouldShowButtonRightIcon;
8489

90+
useEffect(() => {
91+
setSelectedItemIndex(defaultSelectedIndex);
92+
}, [defaultSelectedIndex]);
93+
8594
const {paddingBottom} = useSafeAreaPaddings(true);
8695

8796
useEffect(() => {
@@ -153,6 +162,7 @@ function ButtonWithDropdownMenuInner<IValueType>(props: ButtonWithDropdownMenuPr
153162
},
154163
);
155164
const splitButtonWrapperStyle = isSplitButton ? [styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter] : {};
165+
const isTextTooLong = customText && customText?.length > 6;
156166

157167
const handlePress = useCallback(
158168
(event?: GestureResponderEvent | KeyboardEvent) => {
@@ -186,12 +196,13 @@ function ButtonWithDropdownMenuInner<IValueType>(props: ButtonWithDropdownMenuPr
186196
large={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE}
187197
medium={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.MEDIUM}
188198
small={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.SMALL}
189-
innerStyles={[innerStyleDropButton, !isSplitButton && styles.dropDownButtonCartIconView]}
199+
innerStyles={[innerStyleDropButton, !isSplitButton && styles.dropDownButtonCartIconView, isTextTooLong && shouldUseShortForm && {...styles.pl2, ...styles.pr1}]}
190200
enterKeyEventListenerPriority={enterKeyEventListenerPriority}
191201
iconRight={Expensicons.DownArrow}
192202
shouldShowRightIcon={!isSplitButton}
193203
isSplitButton={isSplitButton}
194204
testID={testID}
205+
textStyles={[isTextTooLong && shouldUseShortForm ? {...styles.textExtraSmall, ...styles.textBold} : {}]}
195206
secondLineText={secondLineText}
196207
icon={icon}
197208
/>
@@ -207,16 +218,25 @@ function ButtonWithDropdownMenuInner<IValueType>(props: ButtonWithDropdownMenuPr
207218
large={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE}
208219
medium={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.MEDIUM}
209220
small={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.SMALL}
210-
innerStyles={[styles.dropDownButtonCartIconContainerPadding, innerStyleDropButton]}
221+
innerStyles={[styles.dropDownButtonCartIconContainerPadding, innerStyleDropButton, isButtonSizeSmall && styles.dropDownButtonCartIcon]}
211222
enterKeyEventListenerPriority={enterKeyEventListenerPriority}
212223
>
213224
<View style={[styles.dropDownButtonCartIconView, innerStyleDropButton]}>
214225
<View style={[success ? styles.buttonSuccessDivider : styles.buttonDivider]} />
215-
<View style={[isButtonSizeLarge ? styles.dropDownLargeButtonArrowContain : styles.dropDownMediumButtonArrowContain]}>
226+
<View
227+
style={[
228+
isButtonSizeLarge && styles.dropDownLargeButtonArrowContain,
229+
isButtonSizeSmall && shouldUseShortForm ? styles.dropDownSmallButtonArrowContain : styles.dropDownMediumButtonArrowContain,
230+
]}
231+
>
216232
<Icon
217233
medium={isButtonSizeLarge}
218-
small={!isButtonSizeLarge}
234+
small={!isButtonSizeLarge && !shouldUseShortForm}
235+
inline={shouldUseShortForm}
236+
width={shouldUseShortForm ? variables.iconSizeExtraSmall : undefined}
237+
height={shouldUseShortForm ? variables.iconSizeExtraSmall : undefined}
219238
src={Expensicons.DownArrow}
239+
additionalStyles={shouldUseShortForm ? [styles.pRelative, styles.t0] : undefined}
220240
fill={success ? theme.buttonSuccessText : theme.icon}
221241
/>
222242
</View>
@@ -266,18 +286,27 @@ function ButtonWithDropdownMenuInner<IValueType>(props: ButtonWithDropdownMenuPr
266286
shouldShowSelectedItemCheck={shouldShowSelectedItemCheck}
267287
// eslint-disable-next-line react-compiler/react-compiler
268288
anchorRef={nullCheckRef(dropdownAnchor)}
269-
withoutOverlay
270-
shouldUseScrollView
271289
scrollContainerStyle={!shouldUseModalPaddingStyle && isSmallScreenWidth && {...styles.pt4, paddingBottom}}
272-
shouldUseModalPaddingStyle={shouldUseModalPaddingStyle}
273290
anchorAlignment={anchorAlignment}
291+
shouldUseModalPaddingStyle={shouldUseModalPaddingStyle}
274292
headerText={menuHeaderText}
293+
shouldUseScrollView={shouldPopoverUseScrollView}
294+
containerStyles={containerStyles}
275295
menuItems={options.map((item, index) => ({
276296
...item,
277297
onSelected: item.onSelected
278-
? () => item.onSelected?.()
298+
? () => {
299+
item.onSelected?.();
300+
if (item.shouldUpdateSelectedIndex) {
301+
setSelectedItemIndex(index);
302+
}
303+
}
279304
: () => {
280305
onOptionSelected?.(item);
306+
if (item.shouldUpdateSelectedIndex === false) {
307+
return;
308+
}
309+
281310
setSelectedItemIndex(index);
282311
},
283312
shouldCallAfterModalHide: true,

src/components/ButtonWithDropdownMenu/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ type DropdownOption<TValueType> = {
4141
descriptionTextStyle?: StyleProp<TextStyle>;
4242
wrapperStyle?: StyleProp<ViewStyle>;
4343
displayInDefaultIconColor?: boolean;
44+
/** Whether the selected index should be updated when the option is selected even if we have onSelected callback */
45+
shouldUpdateSelectedIndex?: boolean;
4446
subMenuItems?: PopoverMenuItem[];
4547
backButtonText?: string;
4648
avatarSize?: ValueOf<typeof CONST.AVATAR_SIZE>;
@@ -143,9 +145,18 @@ type ButtonWithDropdownMenuProps<TValueType> = {
143145
/** Icon for main button */
144146
icon?: IconAsset;
145147

148+
/** Whether the popover content should be scrollable */
149+
shouldPopoverUseScrollView?: boolean;
150+
151+
/** Container style to be applied to the popover of the dropdown menu */
152+
containerStyles?: StyleProp<ViewStyle>;
153+
146154
/** Whether to use modal padding style for the popover menu */
147155
shouldUseModalPaddingStyle?: boolean;
148156

157+
/** Whether to use short form for the button */
158+
shouldUseShortForm?: boolean;
159+
149160
/** Whether to display the option icon when only one option is available */
150161
shouldUseOptionIcon?: boolean;
151162
};

src/components/KYCWall/BaseKYCWall.tsx

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,23 @@ import React, {useCallback, useEffect, useRef, useState} from 'react';
33
import {Dimensions} from 'react-native';
44
import type {EmitterSubscription, GestureResponderEvent, View} from 'react-native';
55
import AddPaymentMethodMenu from '@components/AddPaymentMethodMenu';
6+
import useLocalize from '@hooks/useLocalize';
67
import useOnyx from '@hooks/useOnyx';
78
import {openPersonalBankAccountSetupView} from '@libs/actions/BankAccounts';
8-
import {completePaymentOnboarding} from '@libs/actions/IOU';
9+
import {completePaymentOnboarding, savePreferredPaymentMethod} from '@libs/actions/IOU';
10+
import {moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter} from '@libs/actions/Report';
911
import getClickedTargetLocation from '@libs/getClickedTargetLocation';
1012
import Log from '@libs/Log';
1113
import Navigation from '@libs/Navigation/Navigation';
1214
import {hasExpensifyPaymentMethod} from '@libs/PaymentUtils';
13-
import {getBankAccountRoute, isExpenseReport as isExpenseReportReportUtils, isIOUReport} from '@libs/ReportUtils';
15+
import {getBankAccountRoute, getPolicyExpenseChat, isExpenseReport as isExpenseReportReportUtils, isIOUReport} from '@libs/ReportUtils';
1416
import {kycWallRef} from '@userActions/PaymentMethods';
1517
import {createWorkspaceFromIOUPayment} from '@userActions/Policy/Policy';
1618
import {setKYCWallSource} from '@userActions/Wallet';
1719
import CONST from '@src/CONST';
1820
import ONYXKEYS from '@src/ONYXKEYS';
1921
import ROUTES from '@src/ROUTES';
20-
import type {BankAccountList} from '@src/types/onyx';
22+
import type {BankAccountList, Policy} from '@src/types/onyx';
2123
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
2224
import {getEmptyObject} from '@src/types/utils/EmptyObject';
2325
import viewRef from '@src/types/utils/viewRef';
@@ -54,6 +56,8 @@ function KYCWall({
5456
const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {canBeMissing: true});
5557
const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, {canBeMissing: true});
5658

59+
const {formatPhoneNumber} = useLocalize();
60+
5761
const anchorRef = useRef<HTMLDivElement | View>(null);
5862
const transferBalanceButtonRef = useRef<HTMLDivElement | View | null>(null);
5963

@@ -64,6 +68,8 @@ function KYCWall({
6468
anchorPositionHorizontal: 0,
6569
});
6670

71+
const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD, {canBeMissing: true});
72+
6773
const getAnchorPosition = useCallback(
6874
(domRect: DomRect): AnchorPosition => {
6975
if (anchorAlignment.vertical === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP) {
@@ -103,31 +109,55 @@ function KYCWall({
103109
}, [getAnchorPosition]);
104110

105111
const selectPaymentMethod = useCallback(
106-
(paymentMethod: PaymentMethod) => {
107-
onSelectPaymentMethod(paymentMethod);
112+
(paymentMethod?: PaymentMethod, policy?: Policy) => {
113+
if (paymentMethod) {
114+
onSelectPaymentMethod(paymentMethod);
115+
}
108116

109117
if (paymentMethod === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT) {
110118
openPersonalBankAccountSetupView({shouldSetUpUSBankAccount: isIOUReport(iouReport)});
111119
} else if (paymentMethod === CONST.PAYMENT_METHODS.DEBIT_CARD) {
112120
Navigation.navigate(addDebitCardRoute ?? ROUTES.HOME);
113-
} else if (paymentMethod === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT) {
121+
} else if (paymentMethod === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT || policy) {
114122
if (iouReport && isIOUReport(iouReport)) {
123+
if (policy) {
124+
const policyExpenseChatReportID = getPolicyExpenseChat(iouReport.ownerAccountID, policy.id)?.reportID;
125+
if (!policyExpenseChatReportID) {
126+
const {policyExpenseChatReportID: newPolicyExpenseChatReportID} = moveIOUReportToPolicyAndInviteSubmitter(iouReport.reportID, policy.id, formatPhoneNumber) ?? {};
127+
savePreferredPaymentMethod(iouReport.policyID, policy.id, CONST.LAST_PAYMENT_METHOD.IOU, lastPaymentMethod?.[policy.id]);
128+
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(newPolicyExpenseChatReportID));
129+
} else {
130+
moveIOUReportToPolicy(iouReport.reportID, policy.id, true);
131+
savePreferredPaymentMethod(iouReport.policyID, policy.id, CONST.LAST_PAYMENT_METHOD.IOU, lastPaymentMethod?.[policy.id]);
132+
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(policyExpenseChatReportID));
133+
}
134+
135+
if (policy?.achAccount) {
136+
return;
137+
}
138+
// Navigate to the bank account set up flow for this specific policy
139+
Navigation.navigate(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute(policy.id));
140+
return;
141+
}
142+
115143
const {policyID, workspaceChatReportID, reportPreviewReportActionID, adminsChatReportID} = createWorkspaceFromIOUPayment(iouReport) ?? {};
144+
if (policyID && iouReport?.policyID) {
145+
savePreferredPaymentMethod(iouReport.policyID, policyID, CONST.LAST_PAYMENT_METHOD.IOU, lastPaymentMethod?.[iouReport?.policyID]);
146+
}
116147
completePaymentOnboarding(CONST.PAYMENT_SELECTED.BBA, adminsChatReportID, policyID);
117148
if (workspaceChatReportID) {
118149
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(workspaceChatReportID, reportPreviewReportActionID));
119150
}
120151

121152
// Navigate to the bank account set up flow for this specific policy
122153
Navigation.navigate(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute(policyID));
123-
124154
return;
125155
}
126156
const bankAccountRoute = addBankAccountRoute ?? getBankAccountRoute(chatReport);
127157
Navigation.navigate(bankAccountRoute);
128158
}
129159
},
130-
[addBankAccountRoute, addDebitCardRoute, chatReport, iouReport, onSelectPaymentMethod],
160+
[addBankAccountRoute, addDebitCardRoute, chatReport, iouReport, onSelectPaymentMethod, formatPhoneNumber, lastPaymentMethod],
131161
);
132162

133163
/**
@@ -137,7 +167,7 @@ function KYCWall({
137167
*
138168
*/
139169
const continueAction = useCallback(
140-
(event?: GestureResponderEvent | KeyboardEvent, iouPaymentType?: PaymentMethodType) => {
170+
(event?: GestureResponderEvent | KeyboardEvent, iouPaymentType?: PaymentMethodType, paymentMethod?: PaymentMethod, policy?: Policy) => {
141171
const currentSource = walletTerms?.source ?? source;
142172

143173
/**
@@ -171,6 +201,19 @@ function KYCWall({
171201
return;
172202
}
173203

204+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
205+
if (paymentMethod || policy) {
206+
setShouldShowAddPaymentMenu(false);
207+
selectPaymentMethod(paymentMethod, policy);
208+
return;
209+
}
210+
211+
if (iouPaymentType && isExpenseReport) {
212+
setShouldShowAddPaymentMenu(false);
213+
selectPaymentMethod(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT);
214+
return;
215+
}
216+
174217
const clickedElementLocation = getClickedTargetLocation(targetElement as HTMLDivElement);
175218
const position = getAnchorPosition(clickedElementLocation);
176219

@@ -183,13 +226,20 @@ function KYCWall({
183226
// Ask the user to upgrade to a gold wallet as this means they have not yet gone through our Know Your Customer (KYC) checks
184227
const hasActivatedWallet = userWallet?.tierName && [CONST.WALLET.TIER_NAME.GOLD, CONST.WALLET.TIER_NAME.PLATINUM].some((name) => name === userWallet.tierName);
185228

186-
if (!hasActivatedWallet) {
229+
if (!hasActivatedWallet && !policy) {
187230
Log.info('[KYC Wallet] User does not have active wallet');
188231

189232
Navigation.navigate(enablePaymentsRoute);
190233

191234
return;
192235
}
236+
237+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
238+
if (policy || (paymentMethod && (!hasActivatedWallet || paymentMethod !== CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT))) {
239+
setShouldShowAddPaymentMenu(false);
240+
selectPaymentMethod(paymentMethod, policy);
241+
return;
242+
}
193243
}
194244

195245
Log.info('[KYC Wallet] User has valid payment method and passed KYC checks or did not need them');

src/components/KYCWall/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {OnyxEntry} from 'react-native-onyx';
44
import type {ValueOf} from 'type-fest';
55
import type CONST from '@src/CONST';
66
import type {Route} from '@src/ROUTES';
7-
import type {Report} from '@src/types/onyx';
7+
import type {Policy, Report} from '@src/types/onyx';
88
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
99
import type AnchorAlignment from '@src/types/utils/AnchorAlignment';
1010

@@ -63,6 +63,9 @@ type KYCWallProps = {
6363

6464
/** Children to build the KYC */
6565
children: (continueAction: (event: GestureResponderEvent | KeyboardEvent | undefined, method?: PaymentMethodType) => void, anchorRef: RefObject<View | null>) => void;
66+
67+
/** The policy used for payment */
68+
policy?: Policy;
6669
};
6770

6871
export type {AnchorPosition, KYCWallProps, PaymentMethod, DomRect, PaymentMethodType, Source};

0 commit comments

Comments
 (0)