diff --git a/android/app/build.gradle b/android/app/build.gradle index 2528e23ebfe4..9a930427ecdf 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001044408 - versionName "1.4.44-8" + versionCode 1001044501 + versionName "1.4.45-1" } flavorDimensions "default" diff --git a/assets/images/workspace-profile-light.png b/assets/images/workspace-profile-light.png new file mode 100644 index 000000000000..7e82c98656d2 Binary files /dev/null and b/assets/images/workspace-profile-light.png differ diff --git a/assets/images/workspace-profile.png b/assets/images/workspace-profile.png index 72112566e35f..df1f6f9fd645 100644 Binary files a/assets/images/workspace-profile.png and b/assets/images/workspace-profile.png differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index b8cbf2e78f99..cf7cc7cc3caa 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.44 + 1.4.45 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.44.8 + 1.4.45.1 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 48b003f4185e..22bc17b167f6 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.44 + 1.4.45 CFBundleSignature ???? CFBundleVersion - 1.4.44.8 + 1.4.45.1 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 45b77311fb36..8055f08b8d7c 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.44 + 1.4.45 CFBundleVersion - 1.4.44.8 + 1.4.45.1 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index dc8eb94eeb3f..12c0c99c0d9a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1432,7 +1432,7 @@ PODS: - React-Core - RNReactNativeHapticFeedback (2.2.0): - React-Core - - RNReanimated (3.6.1): + - RNReanimated (3.7.1): - glog - RCT-Folly (= 2022.05.16.00) - React-Core @@ -1986,7 +1986,7 @@ SPEC CHECKSUMS: rnmapbox-maps: fcf7f1cbdc8bd7569c267d07284e8a5c7bee06ed RNPermissions: 9b086c8f05b2e2faa587fdc31f4c5ab4509728aa RNReactNativeHapticFeedback: ec56a5f81c3941206fd85625fa669ffc7b4545f9 - RNReanimated: 57f436e7aa3d277fbfed05e003230b43428157c0 + RNReanimated: beb07f7f900543928467da8107c175d1e57a1049 RNScreens: b582cb834dc4133307562e930e8fa914b8c04ef2 RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852 RNSVG: ba3e7232f45e34b7b47e74472386cf4e1a676d0a diff --git a/package-lock.json b/package-lock.json index 42795645cbe4..066c140d915f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.44-8", + "version": "1.4.45-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.44-8", + "version": "1.4.45-1", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -106,7 +106,7 @@ "react-native-plaid-link-sdk": "10.8.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", - "react-native-reanimated": "^3.6.1", + "react-native-reanimated": "^3.7.1", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.8.2", "react-native-screens": "3.29.0", @@ -44630,9 +44630,9 @@ } }, "node_modules/react-native-reanimated": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.6.1.tgz", - "integrity": "sha512-F4vG9Yf9PKmE3GaWtVGUpzj3SM6YY2cx1yRHCwiMd1uY7W0gU017LfcVUorboJnj0y5QZqEriEK1Usq2Y8YZqg==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.7.1.tgz", + "integrity": "sha512-bapCxhnS58+GZynQmA/f5U8vRlmhXlI/WhYg0dqnNAGXHNIc+38ahRWcG8iK8e0R2v9M8Ky2ZWObEC6bmweofg==", "dependencies": { "@babel/plugin-transform-object-assign": "^7.16.7", "@babel/preset-typescript": "^7.16.7", diff --git a/package.json b/package.json index 7df7dcfcb93f..10b4f107aa9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.44-8", + "version": "1.4.45-1", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -154,7 +154,7 @@ "react-native-plaid-link-sdk": "10.8.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", - "react-native-reanimated": "^3.6.1", + "react-native-reanimated": "^3.7.1", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.8.2", "react-native-screens": "3.29.0", diff --git a/patches/react-native-reanimated+3.6.1+001+fix-boost-dependency.patch b/patches/react-native-reanimated+3.7.1+001+fix-boost-dependency.patch similarity index 100% rename from patches/react-native-reanimated+3.6.1+001+fix-boost-dependency.patch rename to patches/react-native-reanimated+3.7.1+001+fix-boost-dependency.patch diff --git a/patches/react-native-reanimated+3.6.1.patch b/patches/react-native-reanimated+3.7.1.patch similarity index 100% rename from patches/react-native-reanimated+3.6.1.patch rename to patches/react-native-reanimated+3.7.1.patch diff --git a/src/CONST.ts b/src/CONST.ts index 8abd4c087b16..3bce14516b80 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -178,6 +178,7 @@ const CONST = { DATE: { SQL_DATE_TIME: 'YYYY-MM-DD HH:mm:ss', FNS_FORMAT_STRING: 'yyyy-MM-dd', + FNS_DATE_TIME_FORMAT_STRING: 'yyyy-MM-dd HH:mm:ss', LOCAL_TIME_FORMAT: 'h:mm a', YEAR_MONTH_FORMAT: 'yyyyMM', MONTH_FORMAT: 'MMMM', @@ -3319,6 +3320,10 @@ const CONST = { PREFER_CLASSIC: 'preferClassic', }, }, + + SESSION_STORAGE_KEYS: { + INITIAL_URL: 'INITIAL_URL', + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 78f0e61e72a9..d4a0b8a21d66 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -272,6 +272,9 @@ const ONYXKEYS = { /** Indicates whether we should store logs or not */ SHOULD_STORE_LOGS: 'shouldStoreLogs', + // Paths of PDF file that has been cached during one session + CACHED_PDF_PATHS: 'cachedPDFPaths', + /** Collection Keys */ COLLECTION: { DOWNLOAD: 'download_', @@ -564,6 +567,7 @@ type OnyxValuesMapping = { [ONYXKEYS.PLAID_CURRENT_EVENT]: string; [ONYXKEYS.LOGS]: Record; [ONYXKEYS.SHOULD_STORE_LOGS]: boolean; + [ONYXKEYS.CACHED_PDF_PATHS]: Record; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping; diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index ab39e5379230..7f0178863fc9 100755 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -89,7 +89,7 @@ type AttachmentModalProps = AttachmentModalOnyxProps & { source?: AvatarSource; /** Optional callback to fire when we want to preview an image and approve it for use. */ - onConfirm?: ((file: Partial) => void) | null; + onConfirm?: ((file: FileObject) => void) | null; /** Whether the modal should be open by default */ defaultOpen?: boolean; @@ -264,7 +264,7 @@ function AttachmentModal({ } if (onConfirm) { - onConfirm(Object.assign(file ?? {}, {source: sourceState})); + onConfirm(Object.assign(file ?? {}, {source: sourceState} as FileObject)); } setIsModalOpen(false); @@ -318,7 +318,7 @@ function AttachmentModal({ const validateAndDisplayFileToUpload = useCallback( (data: FileObject) => { - if (!isDirectoryCheck(data)) { + if (!data || !isDirectoryCheck(data)) { return; } let fileObject = data; @@ -617,4 +617,4 @@ export default withOnyx({ }, })(memo(AttachmentModal)); -export type {Attachment}; +export type {Attachment, FileObject}; diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.js b/src/components/Attachments/AttachmentCarousel/CarouselItem.js index edc8ab11fd27..b2c9fed64467 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselItem.js +++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.js @@ -105,6 +105,7 @@ function CarouselItem({item, onPress, isFocused, isModalHovered}) { isAuthTokenRequired={item.isAuthTokenRequired} onPress={onPress} transactionID={item.transactionID} + reportActionID={item.reportActionID} isHovered={isModalHovered} isFocused={isFocused} optionalVideoDuration={item.duration} diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js index c871628f65e7..56425f64a51c 100755 --- a/src/components/Attachments/AttachmentView/index.js +++ b/src/components/Attachments/AttachmentView/index.js @@ -17,6 +17,7 @@ import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as CachedPDFPaths from '@libs/actions/CachedPDFPaths'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; import compose from '@libs/compose'; import * as TransactionUtils from '@libs/TransactionUtils'; @@ -57,6 +58,9 @@ const propTypes = { // eslint-disable-next-line react/no-unused-prop-types transactionID: PropTypes.string, + /** The id of the report action related to the attachment */ + reportActionID: PropTypes.string, + isHovered: PropTypes.bool, optionalVideoDuration: PropTypes.number, @@ -71,6 +75,7 @@ const defaultProps = { isWorkspaceAvatar: false, maybeIcon: false, transactionID: '', + reportActionID: '', isHovered: false, optionalVideoDuration: 0, }; @@ -92,6 +97,7 @@ function AttachmentView({ maybeIcon, fallbackSource, transaction, + reportActionID, isHovered, optionalVideoDuration, }) { @@ -153,6 +159,15 @@ function AttachmentView({ if ((_.isString(source) && Str.isPDF(source)) || (file && Str.isPDF(file.name || translate('attachmentView.unknownFilename')))) { const encryptedSourceUrl = isAuthTokenRequired ? addEncryptedAuthTokenToURL(source) : source; + const onPDFLoadComplete = (path) => { + if (path && (transaction.transactionID || reportActionID)) { + CachedPDFPaths.add(transaction.transactionID || reportActionID, path); + } + if (!loadComplete) { + setLoadComplete(true); + } + }; + // We need the following View component on android native // So that the event will propagate properly and // the Password protected preview will be shown for pdf attachement we are about to send. @@ -166,7 +181,7 @@ function AttachmentView({ encryptedSourceUrl={encryptedSourceUrl} onPress={onPress} onToggleKeyboard={onToggleKeyboard} - onLoadComplete={() => !loadComplete && setLoadComplete(true)} + onLoadComplete={onPDFLoadComplete} errorLabelStyles={isUsedInAttachmentModal ? [styles.textLabel, styles.textLarge] : [styles.cursorAuto]} style={isUsedInAttachmentModal ? styles.imageModalPDF : styles.flex1} isUsedInCarousel={isUsedInCarousel} diff --git a/src/components/AvatarWithImagePicker.tsx b/src/components/AvatarWithImagePicker.tsx index fa8a6d71516f..4388ebb8f815 100644 --- a/src/components/AvatarWithImagePicker.tsx +++ b/src/components/AvatarWithImagePicker.tsx @@ -220,7 +220,7 @@ function AvatarWithImagePicker({ setError(null, {}); setIsMenuVisible(false); setImageData({ - uri: image.uri, + uri: image.uri ?? '', name: image.name, type: image.type, }); diff --git a/src/components/ButtonWithDropdownMenu.tsx b/src/components/ButtonWithDropdownMenu.tsx index 9466da601825..8aa3a5f0b9f0 100644 --- a/src/components/ButtonWithDropdownMenu.tsx +++ b/src/components/ButtonWithDropdownMenu.tsx @@ -10,33 +10,30 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import type {AnchorPosition} from '@styles/index'; import CONST from '@src/CONST'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; -import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type IconAsset from '@src/types/utils/IconAsset'; import Button from './Button'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import PopoverMenu from './PopoverMenu'; -type PaymentType = DeepValueOf; - -type DropdownOption = { - value: PaymentType; +type DropdownOption = { + value: T; text: string; - icon: IconAsset; + icon?: IconAsset; iconWidth?: number; iconHeight?: number; iconDescription?: string; }; -type ButtonWithDropdownMenuProps = { +type ButtonWithDropdownMenuProps = { /** Text to display for the menu header */ menuHeaderText?: string; /** Callback to execute when the main button is pressed */ - onPress: (event: GestureResponderEvent | KeyboardEvent | undefined, value: PaymentType) => void; + onPress: (event: GestureResponderEvent | KeyboardEvent | undefined, value: T) => void; /** Callback to execute when a dropdown option is selected */ - onOptionSelected?: (option: DropdownOption) => void; + onOptionSelected?: (option: DropdownOption) => void; /** Call the onPress function on main button when Enter key is pressed */ pressOnEnter?: boolean; @@ -55,19 +52,19 @@ type ButtonWithDropdownMenuProps = { /** Menu options to display */ /** e.g. [{text: 'Pay with Expensify', icon: Wallet}] */ - options: DropdownOption[]; + options: Array>; /** The anchor alignment of the popover menu */ anchorAlignment?: AnchorAlignment; /* ref for the button */ - buttonRef: RefObject; + buttonRef?: RefObject; /** The priority to assign the enter key event listener to buttons. 0 is the highest priority. */ enterKeyEventListenerPriority?: number; }; -function ButtonWithDropdownMenu({ +function ButtonWithDropdownMenu({ isLoading = false, isDisabled = false, pressOnEnter = false, @@ -83,7 +80,7 @@ function ButtonWithDropdownMenu({ options, onOptionSelected, enterKeyEventListenerPriority = 0, -}: ButtonWithDropdownMenuProps) { +}: ButtonWithDropdownMenuProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); diff --git a/src/components/CategoryPicker/index.js b/src/components/CategoryPicker/index.js index 2374fc9e5d0c..89312a7ca614 100644 --- a/src/components/CategoryPicker/index.js +++ b/src/components/CategoryPicker/index.js @@ -70,6 +70,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC onSelectRow={onSubmit} ListItem={RadioListItem} initiallyFocusedOptionKey={selectedOptionKey} + isRowMultilineSupported /> ); } diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 516de55c73ba..b6443f3ca385 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -75,7 +75,7 @@ function Composer( shouldContainScroll = false, ...props }: ComposerProps, - ref: ForwardedRef, + ref: ForwardedRef, ) { const theme = useTheme(); const styles = useThemeStyles(); @@ -278,6 +278,7 @@ function Composer( if (!onKeyPress || isEnterWhileComposition(e as unknown as KeyboardEvent)) { return; } + onKeyPress(e); }, [onKeyPress], diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index d8d88970ea78..6bc44aba69cd 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -1,11 +1,11 @@ -import type {NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInputKeyPressEventData, TextInputSelectionChangeEventData, TextStyle} from 'react-native'; +import type {NativeSyntheticEvent, StyleProp, TextInputProps, TextInputSelectionChangeEventData, TextStyle} from 'react-native'; type TextSelection = { start: number; end?: number; }; -type ComposerProps = { +type ComposerProps = TextInputProps & { /** identify id in the text input */ id?: string; @@ -31,7 +31,7 @@ type ComposerProps = { onNumberOfLinesChange?: (numberOfLines: number) => void; /** Callback method to handle pasting a file */ - onPasteFile?: (file?: File) => void; + onPasteFile?: (file: File) => void; /** General styles to apply to the text input */ // eslint-disable-next-line react/forbid-prop-types @@ -74,12 +74,6 @@ type ComposerProps = { /** Whether the sull composer is open */ isComposerFullSize?: boolean; - onKeyPress?: (event: NativeSyntheticEvent) => void; - - onFocus?: (event: NativeSyntheticEvent) => void; - - onBlur?: (event: NativeSyntheticEvent) => void; - /** Should make the input only scroll inside the element avoid scroll out to parent */ shouldContainScroll?: boolean; }; diff --git a/src/components/ConfirmedRoute.tsx b/src/components/ConfirmedRoute.tsx index 7f05b45bca30..da8c0ed86a84 100644 --- a/src/components/ConfirmedRoute.tsx +++ b/src/components/ConfirmedRoute.tsx @@ -25,13 +25,13 @@ type ConfirmedRoutePropsOnyxProps = { type ConfirmedRouteProps = ConfirmedRoutePropsOnyxProps & { /** Transaction that stores the distance request data */ - transaction: Transaction; + transaction: Transaction | undefined; }; function ConfirmedRoute({mapboxAccessToken, transaction}: ConfirmedRouteProps) { const {isOffline} = useNetwork(); - const {route0: route} = transaction.routes ?? {}; - const waypoints = transaction.comment?.waypoints ?? {}; + const {route0: route} = transaction?.routes ?? {}; + const waypoints = transaction?.comment?.waypoints ?? {}; const coordinates = route?.geometry?.coordinates ?? []; const theme = useTheme(); const styles = useThemeStyles(); diff --git a/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.tsx b/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.tsx index ed91b51a2a44..154689df4ce8 100644 --- a/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.tsx +++ b/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.tsx @@ -12,7 +12,6 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; type DeeplinkRedirectLoadingIndicatorOnyxProps = { @@ -45,7 +44,7 @@ function DeeplinkRedirectLoadingIndicator({openLinkInBrowser, session}: Deeplink {translate('deeplinkWrapper.loggedInAs', {email: session?.email ?? ''})} {translate('deeplinkWrapper.doNotSeePrompt')} openLinkInBrowser(true)}>{translate('deeplinkWrapper.tryAgain')} - {translate('deeplinkWrapper.or')} Navigation.navigate(ROUTES.HOME)}>{translate('deeplinkWrapper.continueInWeb')}. + {translate('deeplinkWrapper.or')} Navigation.goBack()}>{translate('deeplinkWrapper.continueInWeb')}. diff --git a/src/components/DistanceEReceipt.tsx b/src/components/DistanceEReceipt.tsx index 9846cfa04257..941d63c1bf94 100644 --- a/src/components/DistanceEReceipt.tsx +++ b/src/components/DistanceEReceipt.tsx @@ -75,8 +75,6 @@ function DistanceEReceipt({transaction}: DistanceEReceiptProps) { let descriptionKey: TranslationPaths = 'distance.waypointDescription.stop'; if (index === 0) { descriptionKey = 'distance.waypointDescription.start'; - } else if (index === Object.keys(waypoints).length - 1) { - descriptionKey = 'distance.waypointDescription.finish'; } return ( diff --git a/src/components/DistanceRequest/DistanceRequestRenderItem.tsx b/src/components/DistanceRequest/DistanceRequestRenderItem.tsx index a1f3efbf0291..57e4fb0b530e 100644 --- a/src/components/DistanceRequest/DistanceRequestRenderItem.tsx +++ b/src/components/DistanceRequest/DistanceRequestRenderItem.tsx @@ -42,7 +42,7 @@ function DistanceRequestRenderItem({waypoints, item = '', onSecondaryInteraction descriptionKey += 'start'; waypointIcon = Expensicons.DotIndicatorUnfilled; } else if (index === lastWaypointIndex) { - descriptionKey += 'finish'; + descriptionKey += 'stop'; waypointIcon = Expensicons.Location; } else { descriptionKey += 'stop'; diff --git a/src/components/FormElement.tsx b/src/components/FormElement/index.native.tsx similarity index 80% rename from src/components/FormElement.tsx rename to src/components/FormElement/index.native.tsx index da98d4dc565a..d0413c5244c1 100644 --- a/src/components/FormElement.tsx +++ b/src/components/FormElement/index.native.tsx @@ -2,12 +2,10 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef} from 'react'; import type {ViewProps} from 'react-native'; import {View} from 'react-native'; -import * as ComponentUtils from '@libs/ComponentUtils'; function FormElement(props: ViewProps, ref: ForwardedRef) { return ( { + // When Enter is pressed, the form is submitted to the action URL (POST /). + // As we are using a controlled component, we need to disable this behavior here. + event.preventDefault(); +}; + +function FormElement(props: ViewProps, outerRef: ForwardedRef) { + const formRef = useRef(null); + const mergedRef = mergeRefs(formRef, outerRef); + + useEffect(() => { + const formCurrent = formRef.current; + + if (!formCurrent) { + return; + } + + // Prevent the browser from applying its own validation, which affects the email input + formCurrent.setAttribute('novalidate', ''); + + // Password Managers need these attributes to be able to identify the form elements properly. + formCurrent.setAttribute('method', 'post'); + formCurrent.setAttribute('action', '/'); + formCurrent.addEventListener('submit', preventFormDefault); + + return () => { + formCurrent.removeEventListener('submit', preventFormDefault); + }; + }, []); + + return ( + + ); +} + +FormElement.displayName = 'FormElement'; + +export default forwardRef(FormElement); diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx index 7313bb4aa7bb..25b468181b87 100644 --- a/src/components/LocaleContextProvider.tsx +++ b/src/components/LocaleContextProvider.tsx @@ -132,4 +132,4 @@ Provider.displayName = 'withOnyx(LocaleContextProvider)'; export {Provider as LocaleContextProvider, LocaleContext}; -export type {LocaleContextProps}; +export type {LocaleContextProps, Locale}; diff --git a/src/components/MentionSuggestions.tsx b/src/components/MentionSuggestions.tsx index 459131ecc434..23040a242807 100644 --- a/src/components/MentionSuggestions.tsx +++ b/src/components/MentionSuggestions.tsx @@ -1,4 +1,5 @@ import React, {useCallback} from 'react'; +import type {MeasureInWindowOnSuccessCallback} from 'react-native'; import {View} from 'react-native'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -18,7 +19,7 @@ type Mention = { alternateText: string; /** Email/phone number of the user */ - login: string; + login?: string; /** Array of icons of the user. We use the first element of this array */ icons: Icon[]; @@ -32,7 +33,7 @@ type MentionSuggestionsProps = { mentions: Mention[]; /** Fired when the user selects a mention */ - onSelect: () => void; + onSelect: (highlightedMentionIndex: number) => void; /** Mention prefix that follows the @ sign */ prefix: string; @@ -43,7 +44,7 @@ type MentionSuggestionsProps = { isMentionPickerLarge: boolean; /** Measures the parent container's position and dimensions. */ - measureParentContainer: () => void; + measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; }; /** @@ -142,3 +143,5 @@ function MentionSuggestions({prefix, mentions, highlightedMentionIndex = 0, onSe MentionSuggestions.displayName = 'MentionSuggestions'; export default MentionSuggestions; + +export type {Mention}; diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js deleted file mode 100755 index df2781d3ea89..000000000000 --- a/src/components/MoneyRequestConfirmationList.js +++ /dev/null @@ -1,898 +0,0 @@ -import {useIsFocused} from '@react-navigation/native'; -import {format} from 'date-fns'; -import {isEmpty} from 'lodash'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react'; -import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import useLocalize from '@hooks/useLocalize'; -import usePermissions from '@hooks/usePermissions'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import * as CurrencyUtils from '@libs/CurrencyUtils'; -import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import * as IOUUtils from '@libs/IOUUtils'; -import Log from '@libs/Log'; -import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import * as OptionsListUtils from '@libs/OptionsListUtils'; -import * as PolicyUtils from '@libs/PolicyUtils'; -import * as ReceiptUtils from '@libs/ReceiptUtils'; -import * as ReportUtils from '@libs/ReportUtils'; -import * as TransactionUtils from '@libs/TransactionUtils'; -import {iouDefaultProps, iouPropTypes} from '@pages/iou/propTypes'; -import {policyPropTypes} from '@pages/workspace/withPolicy'; -import * as IOU from '@userActions/IOU'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; -import categoryPropTypes from './categoryPropTypes'; -import ConfirmedRoute from './ConfirmedRoute'; -import FormHelpMessage from './FormHelpMessage'; -import Image from './Image'; -import MenuItemWithTopDescription from './MenuItemWithTopDescription'; -import optionPropTypes from './optionPropTypes'; -import OptionsSelector from './OptionsSelector'; -import ReceiptEmptyState from './ReceiptEmptyState'; -import SettlementButton from './SettlementButton'; -import ShowMoreButton from './ShowMoreButton'; -import Switch from './Switch'; -import tagPropTypes from './tagPropTypes'; -import Text from './Text'; -import transactionPropTypes from './transactionPropTypes'; -import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from './withCurrentUserPersonalDetails'; - -const propTypes = { - /** Callback to inform parent modal of success */ - onConfirm: PropTypes.func, - - /** Callback to parent modal to send money */ - onSendMoney: PropTypes.func, - - /** Callback to inform a participant is selected */ - onSelectParticipant: PropTypes.func, - - /** Should we request a single or multiple participant selection from user */ - hasMultipleParticipants: PropTypes.bool.isRequired, - - /** IOU amount */ - iouAmount: PropTypes.number.isRequired, - - /** IOU comment */ - iouComment: PropTypes.string, - - /** IOU currency */ - iouCurrencyCode: PropTypes.string, - - /** IOU type */ - iouType: PropTypes.string, - - /** IOU date */ - iouCreated: PropTypes.string, - - /** IOU merchant */ - iouMerchant: PropTypes.string, - - /** IOU Category */ - iouCategory: PropTypes.string, - - /** IOU Tag */ - iouTag: PropTypes.string, - - /** IOU isBillable */ - iouIsBillable: PropTypes.bool, - - /** Callback to toggle the billable state */ - onToggleBillable: PropTypes.func, - - /** Selected participants from MoneyRequestModal with login / accountID */ - selectedParticipants: PropTypes.arrayOf(optionPropTypes).isRequired, - - /** Payee of the money request with login */ - payeePersonalDetails: optionPropTypes, - - /** Can the participants be modified or not */ - canModifyParticipants: PropTypes.bool, - - /** Should the list be read only, and not editable? */ - isReadOnly: PropTypes.bool, - - /** Depending on expense report or personal IOU report, respective bank account route */ - bankAccountRoute: PropTypes.string, - - ...withCurrentUserPersonalDetailsPropTypes, - - /** Current user session */ - session: PropTypes.shape({ - email: PropTypes.string.isRequired, - }), - - /** The policyID of the request */ - policyID: PropTypes.string, - - /** The reportID of the request */ - reportID: PropTypes.string, - - /** File path of the receipt */ - receiptPath: PropTypes.string, - - /** File name of the receipt */ - receiptFilename: PropTypes.string, - - /** List styles for OptionsSelector */ - listStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), - - /** ID of the transaction that represents the money request */ - transactionID: PropTypes.string, - - /** Transaction that represents the money request */ - transaction: transactionPropTypes, - - /** Unit and rate used for if the money request is a distance request */ - mileageRate: PropTypes.shape({ - /** Unit used to represent distance */ - unit: PropTypes.oneOf([CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS]), - - /** Rate used to calculate the distance request amount */ - rate: PropTypes.number, - - /** The currency of the rate */ - currency: PropTypes.string, - }), - - /** Whether the money request is a distance request */ - isDistanceRequest: PropTypes.bool, - - /** Whether the money request is a scan request */ - isScanRequest: PropTypes.bool, - - /** Whether we're editing a split bill */ - isEditingSplitBill: PropTypes.bool, - - /** Whether we should show the amount, date, and merchant fields. */ - shouldShowSmartScanFields: PropTypes.bool, - - /** A flag for verifying that the current report is a sub-report of a workspace chat */ - isPolicyExpenseChat: PropTypes.bool, - - /* Onyx Props */ - /** Collection of categories attached to a policy */ - policyCategories: PropTypes.objectOf(categoryPropTypes), - - /** Collection of tags attached to a policy */ - policyTags: tagPropTypes, - - /* Onyx Props */ - /** The policy of the report */ - policy: policyPropTypes.policy, - - /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ - iou: iouPropTypes, -}; - -const defaultProps = { - onConfirm: () => {}, - onSendMoney: () => {}, - onSelectParticipant: () => {}, - iouType: CONST.IOU.TYPE.REQUEST, - iouCategory: '', - iouTag: '', - iouIsBillable: false, - onToggleBillable: () => {}, - payeePersonalDetails: null, - canModifyParticipants: false, - isReadOnly: false, - bankAccountRoute: '', - session: { - email: null, - }, - policyID: '', - reportID: '', - ...withCurrentUserPersonalDetailsDefaultProps, - receiptPath: '', - receiptFilename: '', - listStyles: [], - policy: {}, - policyCategories: {}, - policyTags: {}, - transactionID: '', - transaction: {}, - mileageRate: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate: 0, currency: 'USD'}, - isDistanceRequest: false, - isScanRequest: false, - shouldShowSmartScanFields: true, - isPolicyExpenseChat: false, - iou: iouDefaultProps, -}; - -function MoneyRequestConfirmationList(props) { - const theme = useTheme(); - const styles = useThemeStyles(); - // Destructure functions from props to pass it as a dependecy to useCallback/useMemo hooks. - // Prop functions pass props itself as a "this" value to the function which means they change every time props change. - const {onSendMoney, onConfirm, onSelectParticipant} = props; - const {translate, toLocaleDigit} = useLocalize(); - const transaction = props.transaction; - const {canUseViolations} = usePermissions(); - - const isTypeRequest = props.iouType === CONST.IOU.TYPE.REQUEST; - const isSplitBill = props.iouType === CONST.IOU.TYPE.SPLIT; - const isTypeSend = props.iouType === CONST.IOU.TYPE.SEND; - - const isSplitWithScan = isSplitBill && props.isScanRequest; - - const {unit, rate, currency} = props.mileageRate; - const distance = lodashGet(transaction, 'routes.route0.distance', 0); - const shouldCalculateDistanceAmount = props.isDistanceRequest && props.iouAmount === 0; - const taxRates = lodashGet(props.policy, 'taxRates', {}); - - // A flag for showing the categories field - const shouldShowCategories = props.isPolicyExpenseChat && (props.iouCategory || OptionsListUtils.hasEnabledOptions(_.values(props.policyCategories))); - // A flag and a toggler for showing the rest of the form fields - const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false); - - // Do not hide fields in case of send money request - const shouldShowAllFields = props.isDistanceRequest || shouldExpandFields || !props.shouldShowSmartScanFields || isTypeSend || props.isEditingSplitBill; - - // In Send Money and Split Bill with Scan flow, we don't allow the Merchant or Date to be edited. For distance requests, don't show the merchant as there's already another "Distance" menu item - const shouldShowDate = shouldShowAllFields && !isTypeSend && !isSplitWithScan; - const shouldShowMerchant = shouldShowAllFields && !isTypeSend && !props.isDistanceRequest && !isSplitWithScan; - - const policyTagLists = useMemo(() => PolicyUtils.getTagLists(props.policyTags), [props.policyTags]); - - // A flag for showing the tags field - const shouldShowTags = props.isPolicyExpenseChat && (props.iouTag || OptionsListUtils.hasEnabledTags(policyTagLists)); - - // A flag for showing tax fields - tax rate and tax amount - const shouldShowTax = props.isPolicyExpenseChat && lodashGet(props.policy, 'tax.trackingEnabled', props.policy.isTaxTrackingEnabled); - - // A flag for showing the billable field - const shouldShowBillable = !lodashGet(props.policy, 'disabledFields.defaultBillable', true); - - const hasRoute = TransactionUtils.hasRoute(transaction); - const isDistanceRequestWithPendingRoute = props.isDistanceRequest && (!hasRoute || !rate); - const formattedAmount = isDistanceRequestWithPendingRoute - ? '' - : CurrencyUtils.convertToDisplayString( - shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate) : props.iouAmount, - props.isDistanceRequest ? currency : props.iouCurrencyCode, - ); - const formattedTaxAmount = CurrencyUtils.convertToDisplayString(props.transaction.taxAmount, props.iouCurrencyCode); - - const defaultTaxKey = taxRates.defaultExternalID; - const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey].name} (${taxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || ''; - const taxRateTitle = (props.transaction.taxRate && props.transaction.taxRate.text) || defaultTaxName; - - const isFocused = useIsFocused(); - const [formError, setFormError] = useState(''); - - const [didConfirm, setDidConfirm] = useState(false); - const [didConfirmSplit, setDidConfirmSplit] = useState(false); - - const shouldDisplayFieldError = useMemo(() => { - if (!props.isEditingSplitBill) { - return false; - } - - return (props.hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction)); - }, [props.isEditingSplitBill, props.hasSmartScanFailed, transaction, didConfirmSplit]); - - const isMerchantEmpty = !props.iouMerchant || props.iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; - const shouldDisplayMerchantError = props.isPolicyExpenseChat && !props.isScanRequest && isMerchantEmpty; - - useEffect(() => { - if (shouldDisplayFieldError && didConfirmSplit) { - setFormError('iou.error.genericSmartscanFailureMessage'); - return; - } - if (shouldDisplayFieldError && props.hasSmartScanFailed) { - setFormError('iou.receiptScanningFailed'); - return; - } - // reset the form error whenever the screen gains or loses focus - setFormError(''); - }, [isFocused, transaction, shouldDisplayFieldError, props.hasSmartScanFailed, didConfirmSplit]); - - useEffect(() => { - if (!shouldCalculateDistanceAmount) { - return; - } - - const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate); - IOU.setMoneyRequestAmount(amount); - }, [shouldCalculateDistanceAmount, distance, rate, unit]); - - /** - * Returns the participants with amount - * @param {Array} participants - * @returns {Array} - */ - const getParticipantsWithAmount = useCallback( - (participantsList) => { - const iouAmount = IOUUtils.calculateAmount(participantsList.length, props.iouAmount, props.iouCurrencyCode); - return OptionsListUtils.getIOUConfirmationOptionsFromParticipants( - participantsList, - props.iouAmount > 0 ? CurrencyUtils.convertToDisplayString(iouAmount, props.iouCurrencyCode) : '', - ); - }, - [props.iouAmount, props.iouCurrencyCode], - ); - - // If completing a split bill fails, set didConfirm to false to allow the user to edit the fields again - if (props.isEditingSplitBill && didConfirm) { - setDidConfirm(false); - } - - const splitOrRequestOptions = useMemo(() => { - let text; - if (isSplitBill && props.iouAmount === 0) { - text = translate('iou.split'); - } else if ((props.receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) { - text = translate('iou.request'); - if (props.iouAmount !== 0) { - text = translate('iou.requestAmount', {amount: formattedAmount}); - } - } else { - const translationKey = isSplitBill ? 'iou.splitAmount' : 'iou.requestAmount'; - text = translate(translationKey, {amount: formattedAmount}); - } - return [ - { - text: text[0].toUpperCase() + text.slice(1), - value: props.iouType, - }, - ]; - }, [isSplitBill, isTypeRequest, props.iouType, props.iouAmount, props.receiptPath, formattedAmount, isDistanceRequestWithPendingRoute, translate]); - - const selectedParticipants = useMemo(() => _.filter(props.selectedParticipants, (participant) => participant.selected), [props.selectedParticipants]); - const payeePersonalDetails = useMemo(() => props.payeePersonalDetails || props.currentUserPersonalDetails, [props.payeePersonalDetails, props.currentUserPersonalDetails]); - const canModifyParticipants = !props.isReadOnly && props.canModifyParticipants && props.hasMultipleParticipants; - const shouldDisablePaidBySection = canModifyParticipants; - - const optionSelectorSections = useMemo(() => { - const sections = []; - const unselectedParticipants = _.filter(props.selectedParticipants, (participant) => !participant.selected); - if (props.hasMultipleParticipants) { - const formattedSelectedParticipants = getParticipantsWithAmount(selectedParticipants); - let formattedParticipantsList = _.union(formattedSelectedParticipants, unselectedParticipants); - - if (!canModifyParticipants) { - formattedParticipantsList = _.map(formattedParticipantsList, (participant) => ({ - ...participant, - isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID), - })); - } - - const myIOUAmount = IOUUtils.calculateAmount(selectedParticipants.length, props.iouAmount, props.iouCurrencyCode, true); - const formattedPayeeOption = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail( - payeePersonalDetails, - props.iouAmount > 0 ? CurrencyUtils.convertToDisplayString(myIOUAmount, props.iouCurrencyCode) : '', - ); - - sections.push( - { - title: translate('moneyRequestConfirmationList.paidBy'), - data: [formattedPayeeOption], - shouldShow: true, - indexOffset: 0, - isDisabled: shouldDisablePaidBySection, - }, - { - title: translate('moneyRequestConfirmationList.splitWith'), - data: formattedParticipantsList, - shouldShow: true, - indexOffset: 1, - }, - ); - } else { - const formattedSelectedParticipants = _.map(props.selectedParticipants, (participant) => ({ - ...participant, - isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID), - })); - sections.push({ - title: translate('common.to'), - data: formattedSelectedParticipants, - shouldShow: true, - indexOffset: 0, - }); - } - return sections; - }, [ - props.selectedParticipants, - props.hasMultipleParticipants, - props.iouAmount, - props.iouCurrencyCode, - getParticipantsWithAmount, - selectedParticipants, - payeePersonalDetails, - translate, - shouldDisablePaidBySection, - canModifyParticipants, - ]); - - const selectedOptions = useMemo(() => { - if (!props.hasMultipleParticipants) { - return []; - } - return [...selectedParticipants, OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(payeePersonalDetails)]; - }, [selectedParticipants, props.hasMultipleParticipants, payeePersonalDetails]); - - useEffect(() => { - if (!props.isDistanceRequest) { - return; - } - - /* - Set pending waypoints based on the route status. We should handle this dynamically to cover cases such as: - When the user completes the initial steps of the IOU flow offline and then goes online on the confirmation page. - In this scenario, the route will be fetched from the server, and the waypoints will no longer be pending. - */ - IOU.setMoneyRequestPendingFields(props.transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null}); - - const distanceMerchant = DistanceRequestUtils.getDistanceMerchant(hasRoute, distance, unit, rate, currency, translate, toLocaleDigit); - IOU.setMoneyRequestMerchant(props.transactionID, distanceMerchant, false); - }, [isDistanceRequestWithPendingRoute, hasRoute, distance, unit, rate, currency, translate, toLocaleDigit, props.isDistanceRequest, props.transactionID]); - - /** - * @param {Object} option - */ - const selectParticipant = useCallback( - (option) => { - // Return early if selected option is currently logged in user. - if (option.accountID === props.session.accountID) { - return; - } - onSelectParticipant(option); - }, - [props.session.accountID, onSelectParticipant], - ); - - /** - * Navigate to report details or profile of selected user - * @param {Object} option - */ - const navigateToReportOrUserDetail = (option) => { - if (option.accountID) { - const activeRoute = Navigation.getActiveRouteWithoutParams(); - - Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID, activeRoute)); - } else if (option.reportID) { - Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(option.reportID)); - } - }; - - /** - * @param {String} paymentMethod - */ - const confirm = useCallback( - (paymentMethod) => { - if (_.isEmpty(selectedParticipants)) { - return; - } - if (props.iouCategory && props.iouCategory.length > CONST.API_TRANSACTION_CATEGORY_MAX_LENGTH) { - setFormError('iou.error.invalidCategoryLength'); - return; - } - if (props.iouType === CONST.IOU.TYPE.SEND) { - if (!paymentMethod) { - return; - } - - setDidConfirm(true); - - Log.info(`[IOU] Sending money via: ${paymentMethod}`); - onSendMoney(paymentMethod); - } else { - // validate the amount for distance requests - const decimals = CurrencyUtils.getCurrencyDecimals(props.iouCurrencyCode); - if (props.isDistanceRequest && !isDistanceRequestWithPendingRoute && !MoneyRequestUtils.validateAmount(String(props.iouAmount), decimals)) { - setFormError('common.error.invalidAmount'); - return; - } - - if (props.isEditingSplitBill && TransactionUtils.areRequiredFieldsEmpty(transaction)) { - setDidConfirmSplit(true); - return; - } - - setDidConfirm(true); - onConfirm(selectedParticipants); - } - }, - [ - selectedParticipants, - onSendMoney, - onConfirm, - props.isEditingSplitBill, - props.iouType, - props.isDistanceRequest, - props.iouCategory, - isDistanceRequestWithPendingRoute, - props.iouCurrencyCode, - props.iouAmount, - transaction, - ], - ); - - const footerContent = useMemo(() => { - if (props.isReadOnly) { - return; - } - - const shouldShowSettlementButton = props.iouType === CONST.IOU.TYPE.SEND; - const shouldDisableButton = selectedParticipants.length === 0 || shouldDisplayMerchantError; - - const button = shouldShowSettlementButton ? ( - - ) : ( - confirm(value)} - options={splitOrRequestOptions} - buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE} - enterKeyEventListenerPriority={1} - /> - ); - - return ( - <> - {!_.isEmpty(formError) && ( - - )} - {button} - - ); - }, [ - props.isReadOnly, - props.iouType, - props.bankAccountRoute, - props.iouCurrencyCode, - props.policyID, - selectedParticipants.length, - shouldDisplayMerchantError, - confirm, - splitOrRequestOptions, - formError, - styles.ph1, - styles.mb2, - ]); - - const {image: receiptImage, thumbnail: receiptThumbnail} = - props.receiptPath && props.receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, props.receiptPath, props.receiptFilename) : {}; - return ( - - {props.isDistanceRequest && ( - - - - )} - {receiptImage || receiptThumbnail ? ( - - ) : ( - // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") - PolicyUtils.isPaidGroupPolicy(props.policy) && - !props.isDistanceRequest && - props.iouType === CONST.IOU.TYPE.REQUEST && ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( - CONST.IOU.ACTION.CREATE, - props.iouType, - transaction.transactionID, - props.reportID, - Navigation.getActiveRouteWithoutParams(), - ), - ) - } - /> - ) - )} - {props.shouldShowSmartScanFields && ( - { - if (props.isDistanceRequest) { - return; - } - if (props.isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.AMOUNT)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_AMOUNT.getRoute(props.iouType, props.reportID)); - }} - style={[styles.moneyRequestMenuItem, styles.mt2]} - titleStyle={styles.moneyRequestConfirmationAmount} - disabled={didConfirm} - brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - error={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? translate('common.error.enterAmount') : ''} - /> - )} - { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute( - CONST.IOU.ACTION.EDIT, - props.iouType, - transaction.transactionID, - props.reportID, - Navigation.getActiveRouteWithoutParams(), - ), - ); - }} - style={[styles.moneyRequestMenuItem]} - titleStyle={styles.flex1} - disabled={didConfirm} - interactive={!props.isReadOnly} - numberOfLinesTitle={2} - /> - {!shouldShowAllFields && ( - - )} - {shouldShowAllFields && ( - <> - {shouldShowDate && ( - { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DATE.getRoute( - CONST.IOU.ACTION.EDIT, - props.iouType, - transaction.transactionID, - props.reportID, - Navigation.getActiveRouteWithoutParams(), - ), - ); - }} - disabled={didConfirm} - interactive={!props.isReadOnly} - brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''} - /> - )} - {props.isDistanceRequest && ( - Navigation.navigate(ROUTES.MONEY_REQUEST_DISTANCE.getRoute(props.iouType, props.reportID))} - disabled={didConfirm || !isTypeRequest} - interactive={!props.isReadOnly} - /> - )} - {shouldShowMerchant && ( - { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute( - CONST.IOU.ACTION.EDIT, - props.iouType, - transaction.transactionID, - props.reportID, - Navigation.getActiveRouteWithoutParams(), - ), - ); - }} - disabled={didConfirm} - interactive={!props.isReadOnly} - brickRoadIndicator={ - props.isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '' - } - error={ - shouldDisplayMerchantError || (props.isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction)) - ? translate('common.error.enterMerchant') - : '' - } - /> - )} - {shouldShowCategories && ( - { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute( - CONST.IOU.ACTION.EDIT, - props.iouType, - props.transaction.transactionID, - props.reportID, - Navigation.getActiveRouteWithoutParams(), - ), - ); - }} - style={[styles.moneyRequestMenuItem]} - titleStyle={styles.flex1} - disabled={didConfirm} - interactive={!props.isReadOnly} - rightLabel={canUseViolations && Boolean(props.policy.requiresCategory) ? translate('common.required') : ''} - /> - )} - {shouldShowTags && - _.map(policyTagLists, ({name}, index) => ( - { - if (props.isEditingSplitBill) { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAG.getRoute( - CONST.IOU.ACTION.EDIT, - CONST.IOU.TYPE.SPLIT, - index, - props.transaction.transactionID, - props.reportID, - Navigation.getActiveRouteWithoutParams(), - ), - ); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_TAG.getRoute(props.iouType, props.reportID)); - }} - style={[styles.moneyRequestMenuItem]} - disabled={didConfirm} - interactive={!props.isReadOnly} - rightLabel={canUseViolations && Boolean(props.policy.requiresTag) ? translate('common.required') : ''} - /> - ))} - - {shouldShowTax && ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(props.iouType, props.transaction.transactionID, props.reportID, Navigation.getActiveRouteWithoutParams()), - ) - } - disabled={didConfirm} - interactive={!props.isReadOnly} - /> - )} - - {shouldShowTax && ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(props.iouType, props.transaction.transactionID, props.reportID, Navigation.getActiveRouteWithoutParams()), - ) - } - disabled={didConfirm} - interactive={!props.isReadOnly} - /> - )} - - {shouldShowBillable && ( - - {translate('common.billable')} - - - )} - - )} - - ); -} - -MoneyRequestConfirmationList.propTypes = propTypes; -MoneyRequestConfirmationList.defaultProps = defaultProps; -MoneyRequestConfirmationList.displayName = 'MoneyRequestConfirmationList'; - -export default compose( - withCurrentUserPersonalDetails, - withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, - policyCategories: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, - }, - policyTags: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, - }, - mileageRate: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - selector: DistanceRequestUtils.getDefaultMileageRate, - }, - splitTransactionDraft: { - key: ({transactionID}) => `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, - }, - policy: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - }, - iou: { - key: ONYXKEYS.IOU, - }, - }), -)(MoneyRequestConfirmationList); diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx new file mode 100755 index 000000000000..773e98b6462e --- /dev/null +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -0,0 +1,904 @@ +import {useIsFocused} from '@react-navigation/native'; +import {format} from 'date-fns'; +import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import * as IOUUtils from '@libs/IOUUtils'; +import Log from '@libs/Log'; +import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as ReceiptUtils from '@libs/ReceiptUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import * as IOU from '@userActions/IOU'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; +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'; +import type {MileageRate} from '@src/types/onyx/Policy'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; +import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; +import ConfirmedRoute from './ConfirmedRoute'; +import FormHelpMessage from './FormHelpMessage'; +import Image from './Image'; +import MenuItemWithTopDescription from './MenuItemWithTopDescription'; +import OptionsSelector from './OptionsSelector'; +import ReceiptEmptyState from './ReceiptEmptyState'; +import SettlementButton from './SettlementButton'; +import ShowMoreButton from './ShowMoreButton'; +import Switch from './Switch'; +import Text from './Text'; +import type {WithCurrentUserPersonalDetailsProps} from './withCurrentUserPersonalDetails'; +import withCurrentUserPersonalDetails from './withCurrentUserPersonalDetails'; + +type DropdownOption = { + text: string; + value: DeepValueOf; +}; + +type Option = Partial; + +type CategorySection = { + title: string | undefined; + shouldShow: boolean; + indexOffset: number; + data: Option[]; +}; + +type MoneyRequestConfirmationListOnyxProps = { + /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ + iou: OnyxEntry; + + /** Unit and rate used for if the money request is a distance request */ + mileageRate: OnyxEntry; + + /** Collection of categories attached to a policy */ + policyCategories: OnyxEntry; + + /** Collection of tags attached to a policy */ + policyTags: OnyxEntry; + + /** The policy of root parent report */ + policy: OnyxEntry; + + /** The session of the logged in user */ + session: OnyxEntry; +}; + +type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & + WithCurrentUserPersonalDetailsProps & { + /** Callback to inform parent modal of success */ + onConfirm?: (selectedParticipants: Participant[]) => void; + + /** Callback to parent modal to send money */ + onSendMoney?: (paymentMethod: PaymentMethodType) => void; + + /** Callback to inform a participant is selected */ + onSelectParticipant?: (option: Participant) => void; + + /** Should we request a single or multiple participant selection from user */ + hasMultipleParticipants: boolean; + + /** IOU amount */ + iouAmount: number; + + /** IOU comment */ + iouComment?: string; + + /** IOU currency */ + iouCurrencyCode?: string; + + /** IOU type */ + iouType?: ValueOf; + + /** IOU date */ + iouCreated?: string; + + /** IOU merchant */ + iouMerchant?: string; + + /** IOU Category */ + iouCategory?: string; + + /** IOU Tag */ + iouTag?: string; + + /** IOU isBillable */ + iouIsBillable?: boolean; + + /** Callback to toggle the billable state */ + onToggleBillable?: () => void; + + /** Selected participants from MoneyRequestModal with login / accountID */ + selectedParticipants: Participant[]; + + /** Payee of the money request with login */ + payeePersonalDetails?: OnyxEntry; + + /** Can the participants be modified or not */ + canModifyParticipants?: boolean; + + /** Should the list be read only, and not editable? */ + isReadOnly?: boolean; + + /** Depending on expense report or personal IOU report, respective bank account route */ + bankAccountRoute?: Route; + + /** The policyID of the request */ + policyID?: string; + + /** The reportID of the request */ + reportID?: string; + + /** File path of the receipt */ + receiptPath?: string; + + /** File name of the receipt */ + receiptFilename?: string; + + /** List styles for OptionsSelector */ + listStyles?: StyleProp; + + /** ID of the transaction that represents the money request */ + transactionID?: string; + + /** Whether the money request is a distance request */ + isDistanceRequest?: boolean; + + /** Whether the money request is a scan request */ + isScanRequest?: boolean; + + /** Whether we're editing a split bill */ + isEditingSplitBill?: boolean; + + /** Whether we should show the amount, date, and merchant fields. */ + shouldShowSmartScanFields?: boolean; + + /** A flag for verifying that the current report is a sub-report of a workspace chat */ + isPolicyExpenseChat?: boolean; + + /** Whether there is smartscan failed */ + hasSmartScanFailed?: boolean; + + /** ID of the report action */ + reportActionID?: string; + + /** Transaction object */ + transaction?: OnyxTypes.Transaction; + }; + +function MoneyRequestConfirmationList({ + onConfirm = () => {}, + onSendMoney = () => {}, + onSelectParticipant = () => {}, + iouType = CONST.IOU.TYPE.REQUEST, + iouCategory = '', + iouTag = '', + iouIsBillable = false, + onToggleBillable = () => {}, + payeePersonalDetails, + canModifyParticipants = false, + isReadOnly = false, + bankAccountRoute, + policyID, + reportID, + receiptPath, + receiptFilename, + transactionID, + mileageRate, + isDistanceRequest = false, + isScanRequest = false, + shouldShowSmartScanFields = true, + isPolicyExpenseChat = false, + transaction, + iouAmount, + policyTags, + policyCategories, + policy, + iouCurrencyCode, + isEditingSplitBill, + hasSmartScanFailed, + iouMerchant, + currentUserPersonalDetails, + hasMultipleParticipants, + selectedParticipants, + session, + iou, + reportActionID, + iouCreated, + listStyles, + iouComment, +}: MoneyRequestConfirmationListProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {translate, toLocaleDigit} = useLocalize(); + const {canUseViolations} = usePermissions(); + + const isTypeRequest = iouType === CONST.IOU.TYPE.REQUEST; + const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT; + const isTypeSend = iouType === CONST.IOU.TYPE.SEND; + + const isSplitWithScan = isSplitBill && isScanRequest; + + const distance = transaction?.routes?.route0.distance ?? 0; + const shouldCalculateDistanceAmount = isDistanceRequest && iouAmount === 0; + const taxRates = policy?.taxRates; + + // A flag for showing the categories field + const shouldShowCategories = isPolicyExpenseChat && (iouCategory || OptionsListUtils.hasEnabledOptions(Object.values(policyCategories ?? {}))); + + // A flag and a toggler for showing the rest of the form fields + const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false); + + // Do not hide fields in case of send money request + const shouldShowAllFields = isDistanceRequest || shouldExpandFields || !shouldShowSmartScanFields || isTypeSend || isEditingSplitBill; + + // In Send Money and Split Bill with Scan flow, we don't allow the Merchant or Date to be edited. For distance requests, don't show the merchant as there's already another "Distance" menu item + const shouldShowDate = shouldShowAllFields && !isTypeSend && !isSplitWithScan; + const shouldShowMerchant = shouldShowAllFields && !isTypeSend && !isDistanceRequest && !isSplitWithScan; + const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]); + // A flag for showing the tags field + const shouldShowTags = isPolicyExpenseChat && (iouTag || OptionsListUtils.hasEnabledTags(policyTagLists)); + + // A flag for showing tax fields - tax rate and tax amount + const shouldShowTax = isPolicyExpenseChat && (policy?.tax?.trackingEnabled ?? policy?.isTaxTrackingEnabled); + + // A flag for showing the billable field + const shouldShowBillable = !policy?.disabledFields?.defaultBillable ?? true; + + const hasRoute = TransactionUtils.hasRoute(transaction ?? null); + const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !mileageRate?.rate); + const formattedAmount = isDistanceRequestWithPendingRoute + ? '' + : CurrencyUtils.convertToDisplayString( + shouldCalculateDistanceAmount + ? DistanceRequestUtils.getDistanceRequestAmount(distance, mileageRate?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, mileageRate?.rate ?? 0) + : iouAmount, + isDistanceRequest ? mileageRate?.currency : iouCurrencyCode, + ); + const formattedTaxAmount = CurrencyUtils.convertToDisplayString(transaction?.taxAmount, iouCurrencyCode); + + const defaultTaxKey = taxRates?.defaultExternalID; + const defaultTaxName = (defaultTaxKey && `${taxRates?.taxes[defaultTaxKey].name} (${taxRates?.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) ?? ''; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const taxRateTitle = transaction?.taxRate?.text || defaultTaxName; + + const isFocused = useIsFocused(); + const [formError, setFormError] = useState(null); + + const [didConfirm, setDidConfirm] = useState(false); + const [didConfirmSplit, setDidConfirmSplit] = useState(false); + + const shouldDisplayFieldError = useMemo(() => { + if (!isEditingSplitBill) { + return false; + } + + return (!!hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction ?? null)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction ?? null)); + }, [isEditingSplitBill, hasSmartScanFailed, transaction, didConfirmSplit]); + + const isMerchantEmpty = !iouMerchant || iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; + const shouldDisplayMerchantError = isPolicyExpenseChat && !isScanRequest && isMerchantEmpty; + + useEffect(() => { + if (shouldDisplayFieldError && didConfirmSplit) { + setFormError('iou.error.genericSmartscanFailureMessage'); + return; + } + if (shouldDisplayFieldError && hasSmartScanFailed) { + setFormError('iou.receiptScanningFailed'); + return; + } + // reset the form error whenever the screen gains or loses focus + setFormError(null); + }, [isFocused, transaction, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit]); + + useEffect(() => { + if (!shouldCalculateDistanceAmount) { + return; + } + + const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, mileageRate?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, mileageRate?.rate ?? 0); + IOU.setMoneyRequestAmount(amount); + }, [shouldCalculateDistanceAmount, distance, mileageRate?.rate, mileageRate?.unit]); + + /** + * Returns the participants with amount + */ + const getParticipantsWithAmount = useCallback( + (participantsList: Participant[]) => { + const calculatedIouAmount = IOUUtils.calculateAmount(participantsList.length, iouAmount, iouCurrencyCode ?? ''); + return OptionsListUtils.getIOUConfirmationOptionsFromParticipants( + participantsList, + calculatedIouAmount > 0 ? CurrencyUtils.convertToDisplayString(calculatedIouAmount, iouCurrencyCode) : '', + ); + }, + [iouAmount, iouCurrencyCode], + ); + + // If completing a split bill fails, set didConfirm to false to allow the user to edit the fields again + if (isEditingSplitBill && didConfirm) { + setDidConfirm(false); + } + + const splitOrRequestOptions: DropdownOption[] = useMemo(() => { + let text; + if (isSplitBill && iouAmount === 0) { + text = translate('iou.split'); + } else if (!!(receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) { + text = translate('iou.request'); + if (iouAmount !== 0) { + text = translate('iou.requestAmount', {amount: formattedAmount}); + } + } else { + const translationKey = isSplitBill ? 'iou.splitAmount' : 'iou.requestAmount'; + text = translate(translationKey, {amount: formattedAmount}); + } + return [ + { + text: text[0].toUpperCase() + text.slice(1), + value: iouType, + }, + ]; + }, [isSplitBill, iouAmount, receiptPath, isTypeRequest, isDistanceRequestWithPendingRoute, iouType, translate, formattedAmount]); + + const selectedParticipantsMemo = useMemo(() => selectedParticipants.filter((participant) => participant.selected), [selectedParticipants]); + const payeePersonalDetailsMemo = useMemo(() => payeePersonalDetails ?? currentUserPersonalDetails, [payeePersonalDetails, currentUserPersonalDetails]); + const canModifyParticipantsValue = !isReadOnly && canModifyParticipants && hasMultipleParticipants; + + const optionSelectorSections: CategorySection[] = useMemo(() => { + const sections = []; + const unselectedParticipants = selectedParticipants.filter((participant) => !participant.selected); + if (hasMultipleParticipants) { + const formattedSelectedParticipants = getParticipantsWithAmount(selectedParticipantsMemo); + let formattedParticipantsList = [...new Set([...formattedSelectedParticipants, ...unselectedParticipants])]; + + if (!canModifyParticipantsValue) { + formattedParticipantsList = formattedParticipantsList.map((participant) => ({ + ...participant, + isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1), + })); + } + + const myIOUAmount = IOUUtils.calculateAmount(selectedParticipantsMemo.length, iouAmount, iouCurrencyCode ?? '', true); + const formattedPayeeOption = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail( + payeePersonalDetailsMemo, + iouAmount > 0 ? CurrencyUtils.convertToDisplayString(myIOUAmount, iouCurrencyCode) : '', + ); + + sections.push( + { + title: translate('moneyRequestConfirmationList.paidBy'), + data: [formattedPayeeOption], + shouldShow: true, + indexOffset: 0, + isDisabled: canModifyParticipantsValue, + }, + { + title: translate('moneyRequestConfirmationList.splitWith'), + data: formattedParticipantsList, + shouldShow: true, + indexOffset: 1, + }, + ); + } else { + const formattedSelectedParticipants = selectedParticipants.map((participant) => ({ + ...participant, + isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1), + })); + sections.push({ + title: translate('common.to'), + data: formattedSelectedParticipants, + shouldShow: true, + indexOffset: 0, + }); + } + return sections; + }, [ + selectedParticipants, + hasMultipleParticipants, + iouAmount, + iouCurrencyCode, + getParticipantsWithAmount, + payeePersonalDetailsMemo, + translate, + canModifyParticipantsValue, + selectedParticipantsMemo, + ]); + + const selectedOptions = useMemo(() => { + if (!hasMultipleParticipants) { + return []; + } + const myIOUAmount = IOUUtils.calculateAmount(selectedParticipantsMemo.length, iouAmount, iouCurrencyCode ?? '', true); + return [ + ...selectedParticipantsMemo, + OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(payeePersonalDetailsMemo, CurrencyUtils.convertToDisplayString(myIOUAmount, iouCurrencyCode)), + ]; + }, [hasMultipleParticipants, selectedParticipantsMemo, iouAmount, iouCurrencyCode, payeePersonalDetailsMemo]); + + useEffect(() => { + if (!isDistanceRequest) { + return; + } + /* + Set pending waypoints based on the route status. We should handle this dynamically to cover cases such as: + When the user completes the initial steps of the IOU flow offline and then goes online on the confirmation page. + In this scenario, the route will be fetched from the server, and the waypoints will no longer be pending. + */ + IOU.setMoneyRequestPendingFields(transactionID ?? '', {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null}); + const distanceMerchant = DistanceRequestUtils.getDistanceMerchant( + hasRoute, + distance, + mileageRate?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + mileageRate?.rate ?? 0, + mileageRate?.currency ?? 'USD', + translate, + toLocaleDigit, + ); + IOU.setMoneyRequestMerchant(transactionID ?? '', distanceMerchant, false); + }, [hasRoute, distance, mileageRate?.unit, mileageRate?.rate, mileageRate?.currency, translate, toLocaleDigit, isDistanceRequest, transactionID, isDistanceRequestWithPendingRoute]); + + const selectParticipant = useCallback( + (option: Participant) => { + // Return early if selected option is currently logged in user. + if (option.accountID === session?.accountID) { + return; + } + onSelectParticipant(option); + }, + [session?.accountID, onSelectParticipant], + ); + + /** + * Navigate to report details or profile of selected user + */ + const navigateToReportOrUserDetail = (option: Participant | OnyxTypes.Report) => { + if ('accountID' in option && option.accountID) { + const activeRoute = Navigation.getActiveRouteWithoutParams(); + + Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID, activeRoute)); + } else if ('reportID' in option && option.reportID) { + Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(option.reportID)); + } + }; + + const confirm = useCallback( + (paymentMethod: PaymentMethodType | undefined) => { + if (selectedParticipantsMemo.length === 0) { + return; + } + if (iouCategory && iouCategory.length > CONST.API_TRANSACTION_CATEGORY_MAX_LENGTH) { + setFormError('iou.error.invalidCategoryLength'); + return; + } + if (iouType === CONST.IOU.TYPE.SEND) { + if (!paymentMethod) { + return; + } + + setDidConfirm(true); + + Log.info(`[IOU] Sending money via: ${paymentMethod}`); + onSendMoney(paymentMethod); + } else { + // validate the amount for distance requests + const decimals = CurrencyUtils.getCurrencyDecimals(iouCurrencyCode); + if (isDistanceRequest && !isDistanceRequestWithPendingRoute && !MoneyRequestUtils.validateAmount(String(iouAmount), decimals)) { + setFormError('common.error.invalidAmount'); + return; + } + + if (isEditingSplitBill && TransactionUtils.areRequiredFieldsEmpty(transaction ?? null)) { + setDidConfirmSplit(true); + return; + } + + setDidConfirm(true); + onConfirm(selectedParticipantsMemo); + } + }, + [ + selectedParticipantsMemo, + iouCategory, + iouType, + onSendMoney, + iouCurrencyCode, + isDistanceRequest, + isDistanceRequestWithPendingRoute, + iouAmount, + isEditingSplitBill, + transaction, + onConfirm, + ], + ); + + const footerContent = useMemo(() => { + if (isReadOnly) { + return; + } + + const shouldShowSettlementButton = iouType === CONST.IOU.TYPE.SEND; + const shouldDisableButton = selectedParticipantsMemo.length === 0 || shouldDisplayMerchantError; + + const button = shouldShowSettlementButton ? ( + + ) : ( + confirm(value as PaymentMethodType)} + options={splitOrRequestOptions} + buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE} + enterKeyEventListenerPriority={1} + /> + ); + + return ( + <> + {!!formError && ( + + )} + {button} + + ); + }, [ + isReadOnly, + iouType, + selectedParticipantsMemo.length, + shouldDisplayMerchantError, + confirm, + bankAccountRoute, + iouCurrencyCode, + policyID, + splitOrRequestOptions, + formError, + styles.ph1, + styles.mb2, + ]); + + const receiptData = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction ?? null, receiptPath, receiptFilename) : null; + return ( + // @ts-expect-error TODO: Remove this once OptionsSelector (https://github.com/Expensify/App/issues/25125) is migrated to TypeScript. + + {isDistanceRequest && ( + + + + )} + {receiptData?.image ?? receiptData?.thumbnail ? ( + + ) : ( + // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") + PolicyUtils.isPaidGroupPolicy(policy) && + !isDistanceRequest && + iouType === CONST.IOU.TYPE.REQUEST && ( + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( + CONST.IOU.ACTION.CREATE, + iouType, + transaction?.transactionID ?? '', + reportID ?? '', + Navigation.getActiveRouteWithoutParams(), + ), + ) + } + /> + ) + )} + {shouldShowSmartScanFields && ( + { + if (isDistanceRequest) { + return; + } + if (isEditingSplitBill) { + Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID ?? '', reportActionID ?? '', CONST.EDIT_REQUEST_FIELD.AMOUNT)); + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_AMOUNT.getRoute(iouType, reportID)); + }} + style={[styles.moneyRequestMenuItem, styles.mt2]} + titleStyle={styles.moneyRequestConfirmationAmount} + disabled={didConfirm} + brickRoadIndicator={ + isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined + } + error={ + shouldDisplayMerchantError || (isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null)) + ? translate('common.error.enterMerchant') + : '' + } + /> + )} + { + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction?.transactionID ?? '', + reportID ?? '', + Navigation.getActiveRouteWithoutParams(), + ), + ); + }} + style={styles.moneyRequestMenuItem} + titleStyle={styles.flex1} + disabled={didConfirm} + interactive={!isReadOnly} + numberOfLinesTitle={2} + /> + {!shouldShowAllFields && ( + + )} + {shouldShowAllFields && ( + <> + {shouldShowDate && ( + { + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DATE.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction?.transactionID ?? '', + reportID ?? '', + Navigation.getActiveRouteWithoutParams(), + ), + ); + }} + disabled={didConfirm} + interactive={!isReadOnly} + brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction ?? null) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction ?? null) ? translate('common.error.enterDate') : ''} + /> + )} + {isDistanceRequest && ( + Navigation.navigate(ROUTES.MONEY_REQUEST_DISTANCE.getRoute(iouType, reportID))} + disabled={didConfirm || !isTypeRequest} + interactive={!isReadOnly} + /> + )} + {shouldShowMerchant && ( + { + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transactionID ?? '', + reportID ?? '', + Navigation.getActiveRouteWithoutParams(), + ), + ); + }} + disabled={didConfirm} + interactive={!isReadOnly} + brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={ + shouldDisplayMerchantError || (shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null)) + ? translate('common.error.enterMerchant') + : '' + } + /> + )} + {shouldShowCategories && ( + { + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction?.transactionID ?? '', + reportID ?? '', + Navigation.getActiveRouteWithoutParams(), + ), + ); + }} + style={styles.moneyRequestMenuItem} + titleStyle={styles.flex1} + disabled={didConfirm} + interactive={!isReadOnly} + rightLabel={canUseViolations && Boolean(policy?.requiresCategory) ? translate('common.required') : ''} + /> + )} + {shouldShowTags && + policyTagLists.map(({name}, index) => ( + { + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_TAG.getRoute( + CONST.IOU.ACTION.EDIT, + CONST.IOU.TYPE.SPLIT, + index, + transaction?.transactionID ?? '', + reportID ?? '', + Navigation.getActiveRouteWithoutParams(), + ), + ); + }} + style={styles.moneyRequestMenuItem} + disabled={didConfirm} + interactive={!isReadOnly} + rightLabel={canUseViolations && !!policy?.requiresTag ? translate('common.required') : ''} + /> + ))} + + {shouldShowTax && ( + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(iouType, transaction?.transactionID ?? '', reportID ?? '', Navigation.getActiveRouteWithoutParams()), + ) + } + disabled={didConfirm} + interactive={!isReadOnly} + /> + )} + + {shouldShowTax && ( + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(iouType, transaction?.transactionID ?? '', reportID ?? '', Navigation.getActiveRouteWithoutParams()), + ) + } + disabled={didConfirm} + interactive={!isReadOnly} + /> + )} + + {shouldShowBillable && ( + + {translate('common.billable')} + + + )} + + )} + + ); +} + +MoneyRequestConfirmationList.displayName = 'MoneyRequestConfirmationList'; + +export default withCurrentUserPersonalDetails( + withOnyx({ + policyCategories: { + key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + }, + policyTags: { + key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + }, + mileageRate: { + key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + selector: DistanceRequestUtils.getDefaultMileageRate, + }, + policy: { + key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + }, + iou: { + key: ONYXKEYS.IOU, + }, + session: { + key: ONYXKEYS.SESSION, + }, + })(MoneyRequestConfirmationList), +); diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx index b95de8844ee0..8b606bd4429d 100644 --- a/src/components/MultipleAvatars.tsx +++ b/src/components/MultipleAvatars.tsx @@ -5,6 +5,7 @@ import type {ValueOf} from 'type-fest'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as ReportUtils from '@libs/ReportUtils'; import type {AvatarSource} from '@libs/UserUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -106,7 +107,8 @@ function MultipleAvatars({ let avatarContainerStyles = StyleUtils.getContainerStyles(size, isInReportAction); const {singleAvatarStyle, secondAvatarStyles} = useMemo(() => avatarSizeToStylesMap[size as AvatarSizeToStyles] ?? avatarSizeToStylesMap.default, [size, avatarSizeToStylesMap]); - const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => icon.name) : ['']), [shouldShowTooltip, icons]); + const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => ReportUtils.getUserDetailTooltipText(Number(icon.id), icon.name)) : ['']), [shouldShowTooltip, icons]); + const avatarSize = useMemo(() => { if (isFocusMode) { return CONST.AVATAR_SIZE.MID_SUBSCRIPT; diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 1fa63f181dd6..c5c7c3ec50b0 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -1,8 +1,6 @@ -import {useIsFocused} from '@react-navigation/native'; import lodashGet from 'lodash/get'; -import lodashIsEqual from 'lodash/isEqual'; import PropTypes from 'prop-types'; -import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {Component} from 'react'; import {ScrollView, View} from 'react-native'; import _ from 'underscore'; import ArrowKeyFocusManager from '@components/ArrowKeyFocusManager'; @@ -13,10 +11,11 @@ import OptionsList from '@components/OptionsList'; import ReferralProgramCTA from '@components/ReferralProgramCTA'; import ShowMoreButton from '@components/ShowMoreButton'; import TextInput from '@components/TextInput'; -import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; -import useLocalize from '@hooks/useLocalize'; -import usePrevious from '@hooks/usePrevious'; -import useThemeStyles from '@hooks/useThemeStyles'; +import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import withNavigationFocus from '@components/withNavigationFocus'; +import withTheme, {withThemePropTypes} from '@components/withTheme'; +import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles'; +import compose from '@libs/compose'; import getPlatform from '@libs/getPlatform'; import KeyboardShortcut from '@libs/KeyboardShortcut'; import setSelection from '@libs/setSelection'; @@ -36,6 +35,9 @@ const propTypes = { /** List styles for OptionsList */ listStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), + /** Whether navigation is focused */ + isFocused: PropTypes.bool.isRequired, + /** Whether referral CTA should be displayed */ shouldShowReferralCTA: PropTypes.bool, @@ -43,9 +45,13 @@ const propTypes = { referralContentType: PropTypes.string, ...optionsSelectorPropTypes, + ...withLocalizePropTypes, + ...withThemeStylesPropTypes, + ...withThemePropTypes, }; const defaultProps = { + shouldDelayFocus: false, shouldShowReferralCTA: false, referralContentType: CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND, safeAreaPaddingBottomStyle: {}, @@ -55,657 +61,622 @@ const defaultProps = { ...optionsSelectorDefaultProps, }; -function BaseOptionsSelector(props) { - const isFocused = useIsFocused(); - const {translate} = useLocalize(); - const themeStyles = useThemeStyles(); - - const getInitiallyFocusedIndex = useCallback( - (allOptions) => { - let defaultIndex; - if (props.shouldTextInputAppearBelowOptions) { - defaultIndex = allOptions.length; - } else if (props.focusedIndex >= 0) { - defaultIndex = props.focusedIndex; - } else { - defaultIndex = props.selectedOptions.length; +class BaseOptionsSelector extends Component { + constructor(props) { + super(props); + + this.updateFocusedIndex = this.updateFocusedIndex.bind(this); + this.scrollToIndex = this.scrollToIndex.bind(this); + this.selectRow = this.selectRow.bind(this); + this.selectFocusedOption = this.selectFocusedOption.bind(this); + this.addToSelection = this.addToSelection.bind(this); + this.updateSearchValue = this.updateSearchValue.bind(this); + this.incrementPage = this.incrementPage.bind(this); + this.sliceSections = this.sliceSections.bind(this); + this.calculateAllVisibleOptionsCount = this.calculateAllVisibleOptionsCount.bind(this); + this.handleFocusIn = this.handleFocusIn.bind(this); + this.handleFocusOut = this.handleFocusOut.bind(this); + this.debouncedUpdateSearchValue = _.debounce(this.updateSearchValue, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + this.relatedTarget = null; + this.accessibilityRoles = _.values(CONST.ROLE); + this.isWebOrDesktop = [CONST.PLATFORM.DESKTOP, CONST.PLATFORM.WEB].includes(getPlatform()); + + const allOptions = this.flattenSections(); + const sections = this.sliceSections(); + const focusedIndex = this.getInitiallyFocusedIndex(allOptions); + this.focusedOption = allOptions[focusedIndex]; + + this.state = { + sections, + allOptions, + focusedIndex, + shouldDisableRowSelection: false, + errorMessage: '', + paginationPage: 1, + disableEnterShortCut: false, + value: '', + }; + } + + componentDidMount() { + this.subscribeToEnterShortcut(); + this.subscribeToCtrlEnterShortcut(); + this.subscribeActiveElement(); + + if (this.props.isFocused && this.props.autoFocus && this.textInput) { + this.focusTimeout = setTimeout(() => { + this.textInput.focus(); + }, CONST.ANIMATED_TRANSITION); + } + + this.scrollToIndex(this.props.selectedOptions.length ? 0 : this.state.focusedIndex, false); + } + + componentDidUpdate(prevProps, prevState) { + if (prevState.disableEnterShortCut !== this.state.disableEnterShortCut) { + // Unregister the shortcut before registering a new one to avoid lingering shortcut listener + this.unsubscribeEnter(); + if (!this.state.disableEnterShortCut) { + this.subscribeToEnterShortcut(); } - if (_.isUndefined(props.initiallyFocusedOptionKey)) { - return defaultIndex; + } + + if (prevProps.isFocused !== this.props.isFocused) { + // Unregister the shortcut before registering a new one to avoid lingering shortcut listener + this.unSubscribeFromKeyboardShortcut(); + if (this.props.isFocused) { + this.subscribeToEnterShortcut(); + this.subscribeToCtrlEnterShortcut(); } + } - const indexOfInitiallyFocusedOption = _.findIndex(allOptions, (option) => option.keyForList === props.initiallyFocusedOptionKey); - - return indexOfInitiallyFocusedOption; - }, - [props.shouldTextInputAppearBelowOptions, props.initiallyFocusedOptionKey, props.selectedOptions.length, props.focusedIndex], - ); - - const isWebOrDesktop = [CONST.PLATFORM.DESKTOP, CONST.PLATFORM.WEB].includes(getPlatform()); - const accessibilityRoles = _.values(CONST.ROLE); - - const [disabledOptionsIndexes, setDisabledOptionsIndexes] = useState([]); - const [shouldDisableRowSelection, setShouldDisableRowSelection] = useState(false); - const [errorMessage, setErrorMessage] = useState(''); - const [value, setValue] = useState(''); - const [paginationPage, setPaginationPage] = useState(1); - const [disableEnterShortCut, setDisableEnterShortCut] = useState(false); - - const relatedTarget = useRef(null); - const listRef = useRef(); - const textInputRef = useRef(); - const enterSubscription = useRef(); - const CTRLEnterSubscription = useRef(); - const focusTimeout = useRef(); - const prevLocale = useRef(props.preferredLocale); - const prevPaginationPage = useRef(paginationPage); - const prevSelectedOptions = useRef(props.selectedOptions); - const prevValue = useRef(value); - const previousSections = usePrevious(props.sections); - - useImperativeHandle(props.forwardedRef, () => textInputRef.current); + // Screen coming back into focus, for example + // when doing Cmd+Shift+K, then Cmd+K, then Cmd+Shift+K. + // Only applies to platforms that support keyboard shortcuts + if (this.isWebOrDesktop && !prevProps.isFocused && this.props.isFocused && this.props.autoFocus && this.textInput) { + setTimeout(() => { + this.textInput.focus(); + }, CONST.ANIMATED_TRANSITION); + } - /** - * Flattens the sections into a single array of options. - * Each object in this array is enhanced to have: - * - * 1. A `sectionIndex`, which represents the index of the section it came from - * 2. An `index`, which represents the index of the option within the section it came from. - * - * @returns {Array} - */ - const flattenSections = useCallback(() => { - const calcAllOptions = []; - const calcDisabledOptionsIndexes = []; - let index = 0; - _.each(props.sections, (section, sectionIndex) => { - _.each(section.data, (option, optionIndex) => { - calcAllOptions.push({ - ...option, - sectionIndex, - index: optionIndex, - }); - if (section.isDisabled || option.isDisabled) { - calcDisabledOptionsIndexes.push(index); - } - index += 1; + if (prevState.paginationPage !== this.state.paginationPage) { + const newSections = this.sliceSections(); + + this.setState({ + sections: newSections, }); - }); + } - setDisabledOptionsIndexes(calcDisabledOptionsIndexes); - return calcAllOptions; - }, [props.sections]); + if (prevState.focusedIndex !== this.state.focusedIndex) { + this.focusedOption = this.state.allOptions[this.state.focusedIndex]; + } - /** - * Maps sections to render only allowed count of them per section. - * - * @returns {Object[]} - */ - const sliceSections = useCallback( - () => - _.map(props.sections, (section) => { - if (_.isEmpty(section.data)) { - return section; + if (_.isEqual(this.props.sections, prevProps.sections)) { + return; + } + + const newSections = this.sliceSections(); + const newOptions = this.flattenSections(); + + if (prevProps.preferredLocale !== this.props.preferredLocale) { + this.setState({ + sections: newSections, + allOptions: newOptions, + }); + return; + } + const newFocusedIndex = this.props.selectedOptions.length; + const isNewFocusedIndex = newFocusedIndex !== this.state.focusedIndex; + const prevFocusedOption = _.find(newOptions, (option) => this.focusedOption && option.keyForList === this.focusedOption.keyForList); + const prevFocusedOptionIndex = prevFocusedOption ? _.findIndex(newOptions, (option) => this.focusedOption && option.keyForList === this.focusedOption.keyForList) : undefined; + // eslint-disable-next-line react/no-did-update-set-state + this.setState( + { + sections: newSections, + allOptions: newOptions, + focusedIndex: prevFocusedOptionIndex || (_.isNumber(this.props.focusedIndex) ? this.props.focusedIndex : newFocusedIndex), + }, + () => { + // If we just toggled an option on a multi-selection page or cleared the search input, scroll to top + if (this.props.selectedOptions.length !== prevProps.selectedOptions.length || (!!prevState.value && !this.state.value)) { + this.scrollToIndex(0); + return; + } + + // Otherwise, scroll to the focused index (as long as it's in range) + if (this.state.allOptions.length <= this.state.focusedIndex || !isNewFocusedIndex) { + return; } + this.scrollToIndex(this.state.focusedIndex); + }, + ); + } - const pagination = paginationPage || 1; + componentWillUnmount() { + if (this.focusTimeout) { + clearTimeout(this.focusTimeout); + } - return { - ...section, - data: section.data.slice(0, CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * pagination), - }; - }), - [paginationPage, props.sections], - ); + this.unSubscribeFromKeyboardShortcut(); + } - // eslint-disable-next-line react-hooks/exhaustive-deps - const initialAllOptions = useMemo(() => flattenSections(), []); - const [sections, setSections] = useState(sliceSections()); - const [allOptions, setAllOptions] = useState(initialAllOptions); - const [focusedIndex, setFocusedIndex] = useState(getInitiallyFocusedIndex(initialAllOptions)); - const [focusedOption, setFocusedOption] = useState(allOptions[focusedIndex]); + handleFocusIn() { + const activeElement = document.activeElement; + this.setState({ + disableEnterShortCut: activeElement && this.accessibilityRoles.includes(activeElement.role) && activeElement.role !== CONST.ROLE.PRESENTATION, + }); + } + + handleFocusOut() { + this.setState({ + disableEnterShortCut: false, + }); + } /** - * Completes the follow-up actions after a row is selected - * - * @param {Object} option - * @param {Object} ref - * @returns {Promise} + * @param {Array} allOptions + * @returns {Number} */ - const selectRow = useCallback( - (option, ref) => - new Promise((resolve) => { - if (props.shouldShowTextInput && props.shouldPreventDefaultFocusOnSelectRow) { - if (relatedTarget.current && ref === relatedTarget.current) { - textInputRef.current.focus(); - relatedTarget.current = null; - } - if (textInputRef.current.isFocused()) { - setSelection(textInputRef.current, 0, value.length); - } - } - const selectedOption = props.onSelectRow(option); - resolve(selectedOption); - - if (!props.canSelectMultipleOptions) { - return; - } + getInitiallyFocusedIndex(allOptions) { + let defaultIndex; + if (this.props.shouldTextInputAppearBelowOptions) { + defaultIndex = allOptions.length; + } else if (this.props.focusedIndex >= 0) { + defaultIndex = this.props.focusedIndex; + } else { + defaultIndex = this.props.selectedOptions.length; + } + if (_.isUndefined(this.props.initiallyFocusedOptionKey)) { + return defaultIndex; + } - // Focus the first unselected item from the list (i.e: the best result according to the current search term) - setFocusedIndex(props.selectedOptions.length); - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [props.shouldShowTextInput, props.shouldPreventDefaultFocusOnSelectRow, value.length, props.canSelectMultipleOptions, props.selectedOptions.length], - ); + const indexOfInitiallyFocusedOption = _.findIndex(allOptions, (option) => option.keyForList === this.props.initiallyFocusedOptionKey); - const selectFocusedOption = useCallback( - (e) => { - const focusedItemKey = lodashGet(e, ['target', 'attributes', 'id', 'value']); - const localFocusedOption = focusedItemKey ? _.find(allOptions, (option) => option.keyForList === focusedItemKey) : allOptions[focusedIndex]; + return indexOfInitiallyFocusedOption; + } - if (!localFocusedOption || !isFocused) { - return; + /** + * Maps sections to render only allowed count of them per section. + * + * @returns {Objects[]} + */ + sliceSections() { + return _.map(this.props.sections, (section) => { + if (_.isEmpty(section.data)) { + return section; } - if (props.canSelectMultipleOptions) { - selectRow(localFocusedOption); - } else if (!shouldDisableRowSelection) { - setShouldDisableRowSelection(true); + return { + ...section, + data: section.data.slice(0, CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * lodashGet(this.state, 'paginationPage', 1)), + }; + }); + } + + /** + * Calculates all currently visible options based on the sections that are currently being shown + * and the number of items of those sections. + * + * @returns {Number} + */ + calculateAllVisibleOptionsCount() { + let count = 0; - let result = selectRow(localFocusedOption); - if (!(result instanceof Promise)) { - result = Promise.resolve(); - } + _.forEach(this.state.sections, (section) => { + count += lodashGet(section, 'data.length', 0); + }); - setTimeout(() => { - result.finally(() => { - setShouldDisableRowSelection(false); - }); - }, 500); - } - }, - [props.canSelectMultipleOptions, focusedIndex, allOptions, isFocused, selectRow, shouldDisableRowSelection], - ); + return count; + } - const handleFocusIn = () => { - const activeElement = document.activeElement; - setDisableEnterShortCut(activeElement && accessibilityRoles.includes(activeElement.role) && activeElement.role !== CONST.ROLE.PRESENTATION); - }; + updateSearchValue(value) { + this.setState({ + paginationPage: 1, + errorMessage: value.length > this.props.maxLength ? ['common.error.characterLimitExceedCounter', {length: value.length, limit: this.props.maxLength}] : '', + value, + }); + + this.props.onChangeText(value); + } - const handleFocusOut = () => { - setDisableEnterShortCut(false); - }; + subscribeActiveElement() { + if (!this.isWebOrDesktop) { + return; + } + document.addEventListener('focusin', this.handleFocusIn); + document.addEventListener('focusout', this.handleFocusOut); + } - const subscribeActiveElement = () => { - if (!isWebOrDesktop) { + // eslint-disable-next-line react/no-unused-class-component-methods + unSubscribeActiveElement() { + if (!this.isWebOrDesktop) { return; } - document.addEventListener('focusin', handleFocusIn); - document.addEventListener('focusout', handleFocusOut); - }; + document.removeEventListener('focusin', this.handleFocusIn); + document.removeEventListener('focusout', this.handleFocusOut); + } - const subscribeToEnterShortcut = () => { + subscribeToEnterShortcut() { const enterConfig = CONST.KEYBOARD_SHORTCUTS.ENTER; - enterSubscription.current = KeyboardShortcut.subscribe( + this.unsubscribeEnter = KeyboardShortcut.subscribe( enterConfig.shortcutKey, - selectFocusedOption, + this.selectFocusedOption, enterConfig.descriptionKey, enterConfig.modifiers, true, - () => !allOptions[focusedIndex], + () => !this.state.allOptions[this.state.focusedIndex], ); - }; + } - const subscribeToCtrlEnterShortcut = () => { + subscribeToCtrlEnterShortcut() { const CTRLEnterConfig = CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER; - CTRLEnterSubscription.current = KeyboardShortcut.subscribe( + this.unsubscribeCTRLEnter = KeyboardShortcut.subscribe( CTRLEnterConfig.shortcutKey, () => { - if (props.canSelectMultipleOptions) { - props.onConfirmSelection(); + if (this.props.canSelectMultipleOptions) { + this.props.onConfirmSelection(); return; } - const localFocusedOption = allOptions[focusedIndex]; - if (!localFocusedOption) { + const focusedOption = this.state.allOptions[this.state.focusedIndex]; + if (!focusedOption) { return; } - selectRow(localFocusedOption); + this.selectRow(focusedOption); }, CTRLEnterConfig.descriptionKey, CTRLEnterConfig.modifiers, true, ); - }; + } - const unSubscribeFromKeyboardShortcut = () => { - if (enterSubscription.current) { - enterSubscription.current(); + unSubscribeFromKeyboardShortcut() { + if (this.unsubscribeEnter) { + this.unsubscribeEnter(); } - if (CTRLEnterSubscription.current) { - CTRLEnterSubscription.current(); + if (this.unsubscribeCTRLEnter) { + this.unsubscribeCTRLEnter(); } - }; + } - const selectOptions = useCallback(() => { - if (props.canSelectMultipleOptions) { - props.onConfirmSelection(); - return; - } + selectFocusedOption(e) { + const focusedItemKey = lodashGet(e, ['target', 'attributes', 'id', 'value']); + const focusedOption = focusedItemKey ? _.find(this.state.allOptions, (option) => option.keyForList === focusedItemKey) : this.state.allOptions[this.state.focusedIndex]; - const localFocusedOption = allOptions[focusedIndex]; - if (!localFocusedOption) { + if (!focusedOption || !this.props.isFocused) { return; } - selectRow(localFocusedOption); - // we don't need to include the whole props object as the dependency - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allOptions, focusedIndex, props.canSelectMultipleOptions, props.onConfirmSelection, selectRow]); - - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, { - shouldBubble: !allOptions[focusedIndex], - captureOnInputs: true, - }); - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, selectOptions, {captureOnInputs: true}); - - /** - * Scrolls to the focused index within the SectionList - * - * @param {Number} index - * @param {Boolean} animated - */ - const scrollToIndex = useCallback( - (index, animated = true) => { - const option = allOptions[index]; - if (!listRef.current || !option) { - return; - } - - const itemIndex = option.index; - const sectionIndex = option.sectionIndex; - - if (!lodashGet(sections, `[${sectionIndex}].data[${itemIndex}]`, null)) { - return; - } - - // Note: react-native's SectionList automatically strips out any empty sections. - // So we need to reduce the sectionIndex to remove any empty sections in front of the one we're trying to scroll to. - // Otherwise, it will cause an index-out-of-bounds error and crash the app. - let adjustedSectionIndex = sectionIndex; - for (let i = 0; i < sectionIndex; i++) { - if (_.isEmpty(lodashGet(sections, `[${i}].data`))) { - adjustedSectionIndex--; - } - } - - listRef.current.scrollToLocation({sectionIndex: adjustedSectionIndex, itemIndex, animated}); - }, - [allOptions, sections], - ); - - useEffect(() => { - subscribeToEnterShortcut(); - subscribeToCtrlEnterShortcut(); - subscribeActiveElement(); - - if (props.isFocused && props.autoFocus && textInputRef.current) { - focusTimeout.current = setTimeout(() => { - textInputRef.current.focus(); - }, CONST.ANIMATED_TRANSITION); - } - - scrollToIndex(props.selectedOptions.length ? 0 : focusedIndex, false); + if (this.props.canSelectMultipleOptions) { + this.selectRow(focusedOption); + } else if (!this.state.shouldDisableRowSelection) { + this.setState({shouldDisableRowSelection: true}); - return () => { - if (focusTimeout.current) { - clearTimeout(focusTimeout.current); + let result = this.selectRow(focusedOption); + if (!(result instanceof Promise)) { + result = Promise.resolve(); } - unSubscribeFromKeyboardShortcut(); - }; - // we want to run this effect only once, when the component is mounted - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - // Unregister the shortcut before registering a new one to avoid lingering shortcut listener - enterSubscription.current(); - if (!disableEnterShortCut) { - subscribeToEnterShortcut(); + setTimeout(() => { + result.finally(() => { + this.setState({shouldDisableRowSelection: false}); + }); + }, 500); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [disableEnterShortCut]); + } - useEffect(() => { - if (props.isFocused) { - subscribeToEnterShortcut(); - subscribeToCtrlEnterShortcut(); - } else { - unSubscribeFromKeyboardShortcut(); + // eslint-disable-next-line react/no-unused-class-component-methods + focus() { + if (!this.textInput) { + return; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.isFocused]); - - useEffect(() => { - const newSections = sliceSections(); - if (prevPaginationPage.current !== paginationPage) { - prevPaginationPage.current = paginationPage; - setSections(newSections); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [paginationPage]); + this.textInput.focus(); + } - useEffect(() => { - setFocusedOption(allOptions[focusedIndex]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [focusedIndex]); + /** + * Flattens the sections into a single array of options. + * Each object in this array is enhanced to have: + * + * 1. A `sectionIndex`, which represents the index of the section it came from + * 2. An `index`, which represents the index of the option within the section it came from. + * + * @returns {Array} + */ + flattenSections() { + const allOptions = []; + this.disabledOptionsIndexes = []; + let index = 0; + _.each(this.props.sections, (section, sectionIndex) => { + _.each(section.data, (option, optionIndex) => { + allOptions.push({ + ...option, + sectionIndex, + index: optionIndex, + }); + if (section.isDisabled || option.isDisabled) { + this.disabledOptionsIndexes.push(index); + } + index += 1; + }); + }); + return allOptions; + } - // eslint-disable-next-line rulesdir/prefer-early-return - useEffect(() => { - // Screen coming back into focus, for example - // when doing Cmd+Shift+K, then Cmd+K, then Cmd+Shift+K. - // Only applies to platforms that support keyboard shortcuts - if (isWebOrDesktop && isFocused && props.autoFocus && textInputRef.current) { - setTimeout(() => { - textInputRef.current.focus(); - }, CONST.ANIMATED_TRANSITION); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isFocused, props.autoFocus]); + /** + * @param {Number} index + */ + updateFocusedIndex(index) { + this.setState({focusedIndex: index}, () => this.scrollToIndex(index)); + } - useEffect(() => { - if (lodashIsEqual(props.sections, previousSections)) { + /** + * Scrolls to the focused index within the SectionList + * + * @param {Number} index + * @param {Boolean} animated + */ + scrollToIndex(index, animated = true) { + const option = this.state.allOptions[index]; + if (!this.list || !option) { return; } - const newSections = sliceSections(); - const newOptions = flattenSections(); + const itemIndex = option.index; + const sectionIndex = option.sectionIndex; - if (prevLocale.current !== props.preferredLocale) { - prevLocale.current = props.preferredLocale; - setAllOptions(newOptions); - setSections(newSections); + if (!lodashGet(this.state.sections, `[${sectionIndex}].data[${itemIndex}]`, null)) { return; } - const newFocusedIndex = props.selectedOptions.length; - const prevFocusedOption = _.find(newOptions, (option) => focusedOption && option.keyForList === focusedOption.keyForList); - const prevFocusedOptionIndex = prevFocusedOption ? _.findIndex(newOptions, (option) => focusedOption && option.keyForList === focusedOption.keyForList) : undefined; - - setSections(newSections); - setAllOptions(newOptions); - setFocusedIndex(prevFocusedOptionIndex || (_.isNumber(props.focusedIndex) ? props.focusedIndex : newFocusedIndex)); - // we want to run this effect only when the sections change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.sections, previousSections]); - - useEffect(() => { - // If we just toggled an option on a multi-selection page or cleared the search input, scroll to top - if (props.selectedOptions.length !== prevSelectedOptions.current.length || (!!prevValue.current && !value)) { - prevSelectedOptions.current = props.selectedOptions; - prevValue.current = value; - scrollToIndex(0); - return; - } - - // Otherwise, scroll to the focused index (as long as it's in range) - if (allOptions.length <= focusedIndex) { - return; - } - scrollToIndex(focusedIndex); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allOptions.length, focusedIndex, props.focusedIndex, props.selectedOptions, value]); - - const updateSearchValue = useCallback( - (searchValue) => { - setValue(searchValue); - setErrorMessage( - searchValue.length > props.maxLength - ? translate('common.error.characterLimitExceedCounter', { - length: searchValue.length, - limit: props.maxLength, - }) - : '', - ); - props.onChangeText(searchValue); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [props.onChangeText, props.maxLength, translate], - ); - - const debouncedUpdateSearchValue = _.debounce(updateSearchValue, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + this.list.scrollToLocation({sectionIndex, itemIndex, animated}); + } /** - * Calculates all currently visible options based on the sections that are currently being shown - * and the number of items of those sections. + * Completes the follow-up actions after a row is selected * - * @returns {Number} + * @param {Object} option + * @param {Object} ref + * @returns {Promise} */ - const calculateAllVisibleOptionsCount = useCallback(() => { - let count = 0; - - _.forEach(sections, (section) => { - count += lodashGet(section, 'data.length', 0); - }); + selectRow(option, ref) { + return new Promise((resolve) => { + if (this.props.shouldShowTextInput && this.props.shouldPreventDefaultFocusOnSelectRow) { + if (this.relatedTarget && ref === this.relatedTarget) { + this.textInput.focus(); + this.relatedTarget = null; + } + if (this.textInput.isFocused()) { + setSelection(this.textInput, 0, this.state.value.length); + } + } + const selectedOption = this.props.onSelectRow(option); + resolve(selectedOption); - return count; - }, [sections]); + if (!this.props.canSelectMultipleOptions) { + return; + } - /** - * @param {Number} index - */ - const updateFocusedIndex = useCallback((index) => { - setFocusedIndex(index); - }, []); + // Focus the first unselected item from the list (i.e: the best result according to the current search term) + this.setState({ + focusedIndex: this.props.selectedOptions.length, + }); + }); + } /** * Completes the follow-up action after clicking on multiple select button * @param {Object} option */ - const addToSelection = useCallback( - (option) => { - if (props.shouldShowTextInput && props.shouldPreventDefaultFocusOnSelectRow) { - textInputRef.current.focus(); - if (textInputRef.current.isFocused()) { - setSelection(textInputRef.current, 0, value.length); - } + addToSelection(option) { + if (this.props.shouldShowTextInput && this.props.shouldPreventDefaultFocusOnSelectRow) { + this.textInput.focus(); + if (this.textInput.isFocused()) { + setSelection(this.textInput, 0, this.state.value.length); } - props.onAddToSelection(option); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [props.onAddToSelection, props.shouldShowTextInput, props.shouldPreventDefaultFocusOnSelectRow, value.length], - ); + } + this.props.onAddToSelection(option); + } /** * Increments a pagination page to show more items */ - const incrementPage = useCallback(() => { - setPaginationPage((prev) => prev + 1); - }, []); - - const shouldShowShowMoreButton = allOptions.length > CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * paginationPage; - const shouldShowFooter = !props.isReadOnly && (props.shouldShowConfirmButton || props.footerContent) && !(props.canSelectMultipleOptions && _.isEmpty(props.selectedOptions)); - const defaultConfirmButtonText = _.isUndefined(props.confirmButtonText) ? translate('common.confirm') : props.confirmButtonText; - const shouldShowDefaultConfirmButton = !props.footerContent && defaultConfirmButtonText; - const safeAreaPaddingBottomStyle = shouldShowFooter ? undefined : props.safeAreaPaddingBottomStyle; - const listContainerStyles = props.listContainerStyles || [themeStyles.flex1]; - const optionHoveredStyle = props.optionHoveredStyle || themeStyles.hoveredComponentBG; - - const textInput = ( - { - if (!props.shouldPreventDefaultFocusOnSelectRow) { - return; - } - relatedTarget.current = e.relatedTarget; - }} - selectTextOnFocus - blurOnSubmit={Boolean(allOptions.length)} - spellCheck={false} - shouldInterceptSwipe={props.shouldTextInputInterceptSwipe} - isLoading={props.isLoadingNewOptions} - iconLeft={props.textIconLeft} - testID="options-selector-input" - /> - ); - const optionsList = ( - { - if (props.selectedOptions.length === 0) { - scrollToIndex(focusedIndex, false); - } + incrementPage() { + this.setState((prev) => ({ + paginationPage: prev.paginationPage + 1, + })); + } + + render() { + const shouldShowShowMoreButton = this.state.allOptions.length > CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * this.state.paginationPage; + const shouldShowFooter = + !this.props.isReadOnly && (this.props.shouldShowConfirmButton || this.props.footerContent) && !(this.props.canSelectMultipleOptions && _.isEmpty(this.props.selectedOptions)); + const defaultConfirmButtonText = _.isUndefined(this.props.confirmButtonText) ? this.props.translate('common.confirm') : this.props.confirmButtonText; + const shouldShowDefaultConfirmButton = !this.props.footerContent && defaultConfirmButtonText; + const safeAreaPaddingBottomStyle = shouldShowFooter ? undefined : this.props.safeAreaPaddingBottomStyle; + const listContainerStyles = this.props.listContainerStyles || [this.props.themeStyles.flex1]; + const optionHoveredStyle = this.props.optionHoveredStyle || this.props.themeStyles.hoveredComponentBG; + + const textInput = ( + (this.textInput = el)} + label={this.props.textInputLabel} + accessibilityLabel={this.props.textInputLabel} + role={CONST.ROLE.PRESENTATION} + onChangeText={this.debouncedUpdateSearchValue} + errorText={this.state.errorMessage} + onSubmitEditing={this.selectFocusedOption} + placeholder={this.props.placeholderText} + maxLength={this.props.maxLength + CONST.ADDITIONAL_ALLOWED_CHARACTERS} + keyboardType={this.props.keyboardType} + onBlur={(e) => { + if (!this.props.shouldPreventDefaultFocusOnSelectRow) { + return; + } + this.relatedTarget = e.relatedTarget; + }} + selectTextOnFocus + blurOnSubmit={Boolean(this.state.allOptions.length)} + spellCheck={false} + shouldInterceptSwipe={this.props.shouldTextInputInterceptSwipe} + isLoading={this.props.isLoadingNewOptions} + iconLeft={this.props.textIconLeft} + testID="options-selector-input" + /> + ); + const optionsList = ( + (this.list = el)} + optionHoveredStyle={optionHoveredStyle} + onSelectRow={this.props.onSelectRow ? this.selectRow : undefined} + sections={this.state.sections} + focusedIndex={this.state.focusedIndex} + disableFocusOptions={this.props.disableFocusOptions} + selectedOptions={this.props.selectedOptions} + canSelectMultipleOptions={this.props.canSelectMultipleOptions} + shouldShowMultipleOptionSelectorAsButton={this.props.shouldShowMultipleOptionSelectorAsButton} + multipleOptionSelectorButtonText={this.props.multipleOptionSelectorButtonText} + onAddToSelection={this.addToSelection} + hideSectionHeaders={this.props.hideSectionHeaders} + headerMessage={this.state.errorMessage ? '' : this.props.headerMessage} + boldStyle={this.props.boldStyle} + showTitleTooltip={this.props.showTitleTooltip} + isDisabled={this.props.isDisabled} + shouldHaveOptionSeparator={this.props.shouldHaveOptionSeparator} + highlightSelectedOptions={this.props.highlightSelectedOptions} + onLayout={() => { + if (this.props.selectedOptions.length === 0) { + this.scrollToIndex(this.state.focusedIndex, false); + } - if (props.onLayout) { - props.onLayout(); + if (this.props.onLayout) { + this.props.onLayout(); + } + }} + contentContainerStyles={[safeAreaPaddingBottomStyle, ...this.props.contentContainerStyles]} + sectionHeaderStyle={this.props.sectionHeaderStyle} + listContainerStyles={listContainerStyles} + listStyles={this.props.listStyles} + isLoading={!this.props.shouldShowOptions} + showScrollIndicator={this.props.showScrollIndicator} + isRowMultilineSupported={this.props.isRowMultilineSupported} + isLoadingNewOptions={this.props.isLoadingNewOptions} + shouldPreventDefaultFocusOnSelectRow={this.props.shouldPreventDefaultFocusOnSelectRow} + nestedScrollEnabled={this.props.nestedScrollEnabled} + bounces={!this.props.shouldTextInputAppearBelowOptions || !this.props.shouldAllowScrollingChildren} + renderFooterContent={ + shouldShowShowMoreButton && ( + + ) } - }} - contentContainerStyles={[safeAreaPaddingBottomStyle, ...props.contentContainerStyles]} - sectionHeaderStyle={props.sectionHeaderStyle} - listContainerStyles={listContainerStyles} - listStyles={props.listStyles} - isLoading={!props.shouldShowOptions} - showScrollIndicator={props.showScrollIndicator} - isRowMultilineSupported={props.isRowMultilineSupported} - isLoadingNewOptions={props.isLoadingNewOptions} - shouldPreventDefaultFocusOnSelectRow={props.shouldPreventDefaultFocusOnSelectRow} - nestedScrollEnabled={props.nestedScrollEnabled} - bounces={!props.shouldTextInputAppearBelowOptions || !props.shouldAllowScrollingChildren} - renderFooterContent={() => - shouldShowShowMoreButton && ( - - ) - } - /> - ); - - const optionsAndInputsBelowThem = ( - <> - {optionsList} - - {props.children} - {props.shouldShowTextInput && textInput} - - - ); - - return ( - {} : updateFocusedIndex} - shouldResetIndexOnEndReached={false} - > - - {/* - * The OptionsList component uses a SectionList which uses a VirtualizedList internally. - * VirtualizedList cannot be directly nested within ScrollViews of the same orientation. - * To work around this, we wrap the OptionsList component with a horizontal ScrollView. - */} - {props.shouldTextInputAppearBelowOptions && props.shouldAllowScrollingChildren && ( - - - {optionsAndInputsBelowThem} + /> + ); + + const optionsAndInputsBelowThem = ( + <> + + {optionsList} + + + {this.props.children} + {this.props.shouldShowTextInput && textInput} + + + ); + + return ( + {} : this.updateFocusedIndex} + shouldResetIndexOnEndReached={false} + > + + {/* + * The OptionsList component uses a SectionList which uses a VirtualizedList internally. + * VirtualizedList cannot be directly nested within ScrollViews of the same orientation. + * To work around this, we wrap the OptionsList component with a horizontal ScrollView. + */} + {this.props.shouldTextInputAppearBelowOptions && this.props.shouldAllowScrollingChildren && ( + + + {optionsAndInputsBelowThem} + - + )} + + {this.props.shouldTextInputAppearBelowOptions && !this.props.shouldAllowScrollingChildren && optionsAndInputsBelowThem} + + {!this.props.shouldTextInputAppearBelowOptions && ( + <> + + {this.props.children} + {this.props.shouldShowTextInput && textInput} + {Boolean(this.props.textInputAlert) && ( + + )} + + {optionsList} + + )} + + {this.props.shouldShowReferralCTA && ( + + + )} - {props.shouldTextInputAppearBelowOptions && !props.shouldAllowScrollingChildren && optionsAndInputsBelowThem} - - {!props.shouldTextInputAppearBelowOptions && ( - <> - - {props.children} - {props.shouldShowTextInput && textInput} - {Boolean(props.textInputAlert) && ( - - )} - - {optionsList} - + {shouldShowFooter && ( + + {shouldShowDefaultConfirmButton && ( +