diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 85a75d9bf952..a470e5aa044c 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -2,8 +2,8 @@ import {useIsFocused} from '@react-navigation/native'; import {format} from 'date-fns'; import Str from 'expensify-common/lib/str'; import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react'; +import type {TextStyle} from 'react-native'; import {View} from 'react-native'; -import type {StyleProp, ViewStyle} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -48,10 +48,14 @@ import ConfirmModal from './ConfirmModal'; import FormHelpMessage from './FormHelpMessage'; import MenuItem from './MenuItem'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; -import OptionsSelector from './OptionsSelector'; +import MoneyRequestAmountInput from './MoneyRequestAmountInput'; import PDFThumbnail from './PDFThumbnail'; +import {PressableWithFeedback} from './Pressable'; import ReceiptEmptyState from './ReceiptEmptyState'; import ReceiptImage from './ReceiptImage'; +import SelectionList from './SelectionList'; +import type {SectionListDataType} from './SelectionList/types'; +import UserListItem from './SelectionList/UserListItem'; import SettlementButton from './SettlementButton'; import ShowMoreButton from './ShowMoreButton'; import Switch from './Switch'; @@ -73,9 +77,6 @@ type MoneyRequestConfirmationListOnyxProps = { /** The draft policy of the report */ policyDraft: OnyxEntry; - /** The session of the logged in user */ - session: OnyxEntry; - /** Unit and rate used for if the expense is a distance expense */ mileageRates: OnyxEntry>; @@ -99,9 +100,6 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & /** Callback to parent modal to pay someone */ onSendMoney?: (paymentMethod: PaymentMethodType | undefined) => void; - /** Callback to inform a participant is selected */ - onSelectParticipant?: (option: Participant) => void; - /** IOU amount */ iouAmount: number; @@ -153,9 +151,6 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & /** File name of the receipt */ receiptFilename?: string; - /** List styles for OptionsSelector */ - listStyles?: StyleProp; - /** Transaction that represents the expense */ transaction?: OnyxEntry; @@ -181,6 +176,8 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & action?: IOUAction; }; +type MoneyRequestConfirmationListItem = Participant | ReportUtils.OptionData; + const getTaxAmount = (transaction: OnyxEntry, policy: OnyxEntry) => { const defaultTaxCode = TransactionUtils.getDefaultTaxCode(policy, transaction) ?? ''; @@ -192,7 +189,6 @@ function MoneyRequestConfirmationList({ transaction = null, onSendMoney, onConfirm, - onSelectParticipant, iouType = CONST.IOU.TYPE.SUBMIT, iouAmount, policyCategories: policyCategoriesReal, @@ -210,7 +206,6 @@ function MoneyRequestConfirmationList({ iouMerchant, selectedParticipants: selectedParticipantsProp, payeePersonalDetails: payeePersonalDetailsProp, - session, isReadOnly = false, bankAccountRoute = '', policyID = '', @@ -218,7 +213,6 @@ function MoneyRequestConfirmationList({ receiptPath = '', iouComment, receiptFilename = '', - listStyles, iouCreated, iouIsBillable = false, onToggleBillable, @@ -332,9 +326,10 @@ function MoneyRequestConfirmationList({ const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); - const navigateBack = () => { - Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID)); - }; + const navigateBack = useCallback( + () => Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID)), + [iouType, reportID, transactionID], + ); const shouldDisplayFieldError: boolean = useMemo(() => { if (!isEditingSplitBill) { @@ -460,14 +455,15 @@ function MoneyRequestConfirmationList({ IOU.adjustRemainingSplitShares(transaction); }, [isTypeSplit, transaction]); - const payeePersonalDetails = useMemo(() => payeePersonalDetailsProp ?? currentUserPersonalDetails, [payeePersonalDetailsProp, currentUserPersonalDetails]); const selectedParticipants = useMemo(() => selectedParticipantsProp.filter((participant) => participant.selected), [selectedParticipantsProp]); + const payeePersonalDetails = useMemo(() => payeePersonalDetailsProp ?? currentUserPersonalDetails, [payeePersonalDetailsProp, currentUserPersonalDetails]); const shouldShowReadOnlySplits = useMemo(() => isPolicyExpenseChat || isReadOnly || isScanRequest, [isPolicyExpenseChat, isReadOnly, isScanRequest]); const splitParticipants = useMemo(() => { if (!isTypeSplit) { - return; + return []; } + const payeeOption = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(payeePersonalDetails); if (shouldShowReadOnlySplits) { return [payeeOption, ...selectedParticipants].map((participantOption: Participant) => { @@ -480,7 +476,12 @@ function MoneyRequestConfirmationList({ } return { ...participantOption, - descriptiveText: amount ? CurrencyUtils.convertToDisplayString(amount, iouCurrencyCode) : '', + isSelected: false, + rightElement: ( + + {amount ? CurrencyUtils.convertToDisplayString(amount, iouCurrencyCode) : ''} + + ), }; }); } @@ -489,21 +490,51 @@ function MoneyRequestConfirmationList({ const prefixPadding = StyleUtils.getCharacterPadding(currencySymbol ?? ''); const formattedTotalAmount = CurrencyUtils.convertToDisplayStringWithoutCurrency(iouAmount, iouCurrencyCode); const amountWidth = StyleUtils.getWidthStyle(formattedTotalAmount.length * 9 + prefixPadding); + return [payeeOption, ...selectedParticipants].map((participantOption: Participant) => ({ ...participantOption, tabIndex: -1, - shouldShowAmountInput: true, - amountInputProps: { - amount: transaction?.splitShares?.[participantOption.accountID ?? 0]?.amount, - currency: iouCurrencyCode, - prefixCharacter: currencySymbol, - containerStyle: [amountWidth], - inputStyle: [amountWidth], - maxLength: formattedTotalAmount.length, - onAmountChange: (value: string) => onSplitShareChange(participantOption.accountID ?? 0, Number(value)), - }, + isSelected: false, + rightElement: ( + onSplitShareChange(participantOption.accountID ?? 0, Number(value))} + maxLength={formattedTotalAmount.length} + /> + ), })); - }, [isTypeSplit, transaction, iouCurrencyCode, onSplitShareChange, payeePersonalDetails, selectedParticipants, currencyList, iouAmount, shouldShowReadOnlySplits, StyleUtils]); + }, [ + isTypeSplit, + payeePersonalDetails, + shouldShowReadOnlySplits, + currencyList, + iouCurrencyCode, + StyleUtils, + iouAmount, + selectedParticipants, + styles.flexWrap, + styles.pl2, + styles.textLabel, + styles.pv0, + styles.optionRowAmountInput, + styles.pl1.paddingLeft, + styles.textInputContainer, + transaction?.comment?.splits, + transaction?.splitShares, + onSplitShareChange, + ]); const isSplitModified = useMemo(() => { if (!transaction?.splitShares) { @@ -512,10 +543,44 @@ function MoneyRequestConfirmationList({ return Object.keys(transaction.splitShares).some((key) => transaction.splitShares?.[Number(key) ?? -1]?.isModified); }, [transaction?.splitShares]); - const optionSelectorSections = useMemo(() => { - const sections = []; + const getSplitSectionHeader = useCallback( + () => ( + + {translate('moneyRequestConfirmationList.splitAmounts')} + {!shouldShowReadOnlySplits && isSplitModified && ( + { + IOU.resetSplitShares(transaction); + }} + accessibilityLabel={CONST.ROLE.BUTTON} + role={CONST.ROLE.BUTTON} + shouldUseAutoHitSlop + > + {translate('common.reset')} + + )} + + ), + [ + isSplitModified, + shouldShowReadOnlySplits, + styles.flexRow, + styles.justifyContentBetween, + styles.link, + styles.mb1, + styles.mt2, + styles.ph5, + styles.pr5, + styles.textLabelSupporting, + transaction, + translate, + ], + ); + + const sections = useMemo(() => { + const options: Array> = []; if (isTypeSplit) { - sections.push( + options.push( ...[ { title: translate('moneyRequestConfirmationList.paidBy'), @@ -523,36 +588,28 @@ function MoneyRequestConfirmationList({ shouldShow: true, }, { - title: translate('moneyRequestConfirmationList.splitAmounts'), + CustomSectionHeader: getSplitSectionHeader, data: splitParticipants, shouldShow: true, - shouldShowActionButton: !shouldShowReadOnlySplits && isSplitModified, - onActionButtonPress: () => IOU.resetSplitShares(transaction), - actionButtonTitle: translate('common.reset'), }, ], ); - sections.push(); + options.push(); } else { const formattedSelectedParticipants = selectedParticipants.map((participant) => ({ - isDisabled: !participant.isPolicyExpenseChat && !participant.isSelfDM && ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1), ...participant, + isSelected: false, + isDisabled: !participant.isPolicyExpenseChat && !participant.isSelfDM && ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1), })); - sections.push({ + options.push({ title: translate('common.to'), data: formattedSelectedParticipants, shouldShow: true, }); } - return sections; - }, [selectedParticipants, isTypeSplit, translate, splitParticipants, transaction, shouldShowReadOnlySplits, isSplitModified, payeePersonalDetails]); - const selectedOptions = useMemo(() => { - if (!isTypeSplit) { - return []; - } - return [...selectedParticipants, OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(payeePersonalDetails)]; - }, [selectedParticipants, isTypeSplit, payeePersonalDetails]); + return options; + }, [isTypeSplit, translate, payeePersonalDetails, getSplitSectionHeader, splitParticipants, selectedParticipants]); useEffect(() => { if (!isDistanceRequest || isMovingTransactionFromTrackExpense) { @@ -613,23 +670,10 @@ function MoneyRequestConfirmationList({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [policyTagLists, policyTags, canUseViolations]); - /** - */ - 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: ReportUtils.OptionData) => { + const navigateToReportOrUserDetail = (option: MoneyRequestConfirmationListItem) => { const activeRoute = Navigation.getActiveRouteWithoutParams(); if (option.isSelfDM) { @@ -1050,7 +1094,7 @@ function MoneyRequestConfirmationList({ setIsAttachmentInvalid(true)} /> @@ -1084,93 +1128,117 @@ function MoneyRequestConfirmationList({ ], ); + const listFooterContent = useMemo( + () => ( + <> + {isTypeInvoice && ( + { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SEND_FROM.getRoute(iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams())); + }} + style={styles.moneyRequestMenuItem} + labelStyle={styles.mt2} + titleStyle={styles.flex1} + disabled={didConfirm || !canUpdateSenderWorkspace} + /> + )} + {isDistanceRequest && ( + + + + )} + {!isDistanceRequest && + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + (receiptImage || receiptThumbnail + ? receiptThumbnailContent + : // 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.SUBMIT && ( + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()), + ) + } + /> + ))} + {primaryFields} + {!shouldShowAllFields && ( + + )} + {shouldShowAllFields && supplementaryFields} + + + ), + [ + canUpdateSenderWorkspace, + didConfirm, + iouType, + isAttachmentInvalid, + isDistanceRequest, + isReadOnly, + isTypeInvoice, + navigateBack, + policy, + primaryFields, + receiptImage, + receiptThumbnail, + receiptThumbnailContent, + reportID, + senderWorkspace?.avatarURL, + senderWorkspace?.name, + shouldShowAllFields, + styles.confirmationListMapItem, + styles.flex1, + styles.mb2, + styles.mb5, + styles.moneyRequestMenuItem, + styles.mt1, + styles.mt2, + supplementaryFields, + transaction, + transactionID, + translate, + ], + ); + return ( - /** @ts-expect-error This component is deprecated and will not be migrated to TypeScript (context: https://expensify.slack.com/archives/C01GTK53T8Q/p1709232289899589?thread_ts=1709156803.359359&cid=C01GTK53T8Q) */ - + sections={sections} + ListItem={UserListItem} onSelectRow={navigateToReportOrUserDetail} - onAddToSelection={selectParticipant} - onConfirmSelection={confirm} - selectedOptions={selectedOptions} - disableArrowKeysActions - disableFocusOptions - boldStyle - showTitleTooltip - shouldTextInputAppearBelowOptions - shouldShowTextInput={false} - shouldUseStyleForChildren={false} + canSelectMultiple={false} + shouldPreventDefaultFocusOnSelectRow footerContent={footerContent} - listStyles={listStyles} - shouldAllowScrollingChildren - > - {isDistanceRequest && ( - - - - )} - {isTypeInvoice && ( - { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SEND_FROM.getRoute(iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams())); - }} - style={styles.moneyRequestMenuItem} - labelStyle={styles.mt2} - titleStyle={styles.flex1} - disabled={didConfirm || !canUpdateSenderWorkspace} - /> - )} - {!isDistanceRequest && - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - (receiptImage || receiptThumbnail - ? receiptThumbnailContent - : // 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.SUBMIT && ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()), - ) - } - /> - ))} - {primaryFields} - {!shouldShowAllFields && ( - - )} - {shouldShowAllFields && supplementaryFields} - - + listFooterContent={listFooterContent} + /> ); } MoneyRequestConfirmationList.displayName = 'MoneyRequestConfirmationList'; export default withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, policyCategories: { key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, }, diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js deleted file mode 100755 index 6515333e4015..000000000000 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ /dev/null @@ -1,692 +0,0 @@ -import lodashDebounce from 'lodash/debounce'; -import lodashFind from 'lodash/find'; -import lodashFindIndex from 'lodash/findIndex'; -import lodashGet from 'lodash/get'; -import lodashIsEqual from 'lodash/isEqual'; -import lodashMap from 'lodash/map'; -import lodashValues from 'lodash/values'; -import PropTypes from 'prop-types'; -import React, {Component} from 'react'; -import {View} from 'react-native'; -import ArrowKeyFocusManager from '@components/ArrowKeyFocusManager'; -import Button from '@components/Button'; -import FixedFooter from '@components/FixedFooter'; -import FormHelpMessage from '@components/FormHelpMessage'; -import OptionsList from '@components/OptionsList'; -import ReferralProgramCTA from '@components/ReferralProgramCTA'; -import ScrollView from '@components/ScrollView'; -import ShowMoreButton from '@components/ShowMoreButton'; -import TextInput from '@components/TextInput'; -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'; -import CONST from '@src/CONST'; -import {defaultProps as optionsSelectorDefaultProps, propTypes as optionsSelectorPropTypes} from './optionsSelectorPropTypes'; - -const propTypes = { - /** padding bottom style of safe area */ - safeAreaPaddingBottomStyle: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), - - /** Content container styles for OptionsList */ - contentContainerStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), - - /** List container styles for OptionsList */ - listContainerStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), - - /** 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, - - /** Referral content type */ - referralContentType: PropTypes.string, - - ...optionsSelectorPropTypes, - ...withLocalizePropTypes, - ...withThemeStylesPropTypes, - ...withThemePropTypes, -}; - -const defaultProps = { - shouldDelayFocus: false, - shouldShowReferralCTA: false, - referralContentType: CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND, - safeAreaPaddingBottomStyle: {}, - contentContainerStyles: [], - listContainerStyles: undefined, - listStyles: [], - ...optionsSelectorDefaultProps, -}; - -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 = lodashDebounce(this.updateSearchValue, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); - this.relatedTarget = null; - this.accessibilityRoles = lodashValues(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 (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.subscribeActiveElement(); - this.subscribeToEnterShortcut(); - this.subscribeToCtrlEnterShortcut(); - } else { - this.unSubscribeActiveElement(); - } - } - - // 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); - } - - if (prevState.paginationPage !== this.state.paginationPage) { - const newSections = this.sliceSections(); - - this.setState({ - sections: newSections, - }); - } - - if (prevState.focusedIndex !== this.state.focusedIndex) { - this.focusedOption = this.state.allOptions[this.state.focusedIndex]; - } - - if (lodashIsEqual(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 = lodashFind(newOptions, (option) => this.focusedOption && option.keyForList === this.focusedOption.keyForList); - const prevFocusedOptionIndex = prevFocusedOption ? lodashFindIndex(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 || (typeof this.props.focusedIndex === 'number' ? 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); - }, - ); - } - - componentWillUnmount() { - if (this.focusTimeout) { - clearTimeout(this.focusTimeout); - } - - this.unSubscribeFromKeyboardShortcut(); - } - - handleFocusIn() { - const activeElement = document.activeElement; - this.setState({ - disableEnterShortCut: activeElement && this.accessibilityRoles.includes(activeElement.role) && activeElement.role !== CONST.ROLE.PRESENTATION, - }); - } - - handleFocusOut() { - this.setState({ - disableEnterShortCut: false, - }); - } - - /** - * @param {Array} allOptions - * @returns {Number} - */ - 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 (this.props.initiallyFocusedOptionKey === undefined) { - return defaultIndex; - } - - const indexOfInitiallyFocusedOption = lodashFindIndex(allOptions, (option) => option.keyForList === this.props.initiallyFocusedOptionKey); - - return indexOfInitiallyFocusedOption; - } - - /** - * Maps sections to render only allowed count of them per section. - * - * @returns {Objects[]} - */ - sliceSections() { - return lodashMap(this.props.sections, (section) => { - if (section.data.length === 0) { - return section; - } - - 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; - - this.state.sections.forEach((section) => { - count += lodashGet(section, 'data.length', 0); - }); - - return count; - } - - 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); - } - - subscribeActiveElement() { - if (!this.isWebOrDesktop) { - return; - } - document.addEventListener('focusin', this.handleFocusIn); - document.addEventListener('focusout', this.handleFocusOut); - } - - // eslint-disable-next-line react/no-unused-class-component-methods - unSubscribeActiveElement() { - if (!this.isWebOrDesktop) { - return; - } - document.removeEventListener('focusin', this.handleFocusIn); - document.removeEventListener('focusout', this.handleFocusOut); - } - - subscribeToEnterShortcut() { - const enterConfig = CONST.KEYBOARD_SHORTCUTS.ENTER; - this.unsubscribeEnter = KeyboardShortcut.subscribe( - enterConfig.shortcutKey, - this.selectFocusedOption, - enterConfig.descriptionKey, - enterConfig.modifiers, - true, - () => !this.state.allOptions[this.state.focusedIndex], - ); - } - - subscribeToCtrlEnterShortcut() { - const CTRLEnterConfig = CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER; - this.unsubscribeCTRLEnter = KeyboardShortcut.subscribe( - CTRLEnterConfig.shortcutKey, - () => { - if (this.props.canSelectMultipleOptions) { - this.props.onConfirmSelection(); - return; - } - - const focusedOption = this.state.allOptions[this.state.focusedIndex]; - if (!focusedOption) { - return; - } - - this.selectRow(focusedOption); - }, - CTRLEnterConfig.descriptionKey, - CTRLEnterConfig.modifiers, - true, - ); - } - - unSubscribeFromKeyboardShortcut() { - if (this.unsubscribeEnter) { - this.unsubscribeEnter(); - } - - if (this.unsubscribeCTRLEnter) { - this.unsubscribeCTRLEnter(); - } - } - - selectFocusedOption(e) { - const focusedItemKey = lodashGet(e, ['target', 'attributes', 'id', 'value']); - const focusedOption = focusedItemKey ? lodashFind(this.state.allOptions, (option) => option.keyForList === focusedItemKey) : this.state.allOptions[this.state.focusedIndex]; - - if (!focusedOption || !this.props.isFocused) { - return; - } - - if (this.props.canSelectMultipleOptions) { - this.selectRow(focusedOption); - } else if (!this.state.shouldDisableRowSelection) { - this.setState({shouldDisableRowSelection: true}); - - let result = this.selectRow(focusedOption); - if (!(result instanceof Promise)) { - result = Promise.resolve(); - } - - setTimeout(() => { - result.finally(() => { - this.setState({shouldDisableRowSelection: false}); - }); - }, 500); - } - } - - // eslint-disable-next-line react/no-unused-class-component-methods - focus() { - if (!this.textInput) { - return; - } - - this.textInput.focus(); - } - - /** - * 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; - this.props.sections.forEach((section, sectionIndex) => { - section.data.forEach((option, optionIndex) => { - allOptions.push({ - ...option, - sectionIndex, - index: optionIndex, - }); - if (section.isDisabled || option.isDisabled) { - this.disabledOptionsIndexes.push(index); - } - index += 1; - }); - }); - return allOptions; - } - - /** - * @param {Number} index - */ - updateFocusedIndex(index) { - this.setState({focusedIndex: index}, () => this.scrollToIndex(index)); - } - - /** - * 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 itemIndex = option.index; - const sectionIndex = option.sectionIndex; - - if (!lodashGet(this.state.sections, `[${sectionIndex}].data[${itemIndex}]`, null)) { - return; - } - - this.list.scrollToLocation({sectionIndex, itemIndex, animated}); - } - - /** - * Completes the follow-up actions after a row is selected - * - * @param {Object} option - * @param {Object} ref - * @returns {Promise} - */ - 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); - - if (!this.props.canSelectMultipleOptions) { - return; - } - - // 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 - */ - addToSelection(option) { - if (this.props.shouldShowTextInput && this.props.shouldPreventDefaultFocusOnSelectRow) { - this.textInput.focus(); - if (this.textInput.isFocused()) { - setSelection(this.textInput, 0, this.state.value.length); - } - } - this.props.onAddToSelection(option); - } - - /** - * Increments a pagination page to show more items - */ - 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 && this.props.selectedOptions.length === 0); - const defaultConfirmButtonText = this.props.confirmButtonText === undefined ? 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 (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 && ( - - ) - } - /> - ); - - 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 && ( - - - - )} - - {shouldShowFooter && ( - - {shouldShowDefaultConfirmButton && ( -