From cb236c1f2b695b3bce4ad95f4f24453c270af71e Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Sat, 20 Apr 2024 14:52:06 +0800 Subject: [PATCH 01/36] parse the task description when saving it --- src/components/ReportActionItem/TaskAction.tsx | 6 +++++- src/components/ReportActionItem/TaskView.tsx | 2 +- src/libs/ReportUtils.ts | 2 +- src/libs/actions/Task.ts | 2 +- src/pages/tasks/TaskDescriptionPage.tsx | 4 ++-- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/ReportActionItem/TaskAction.tsx b/src/components/ReportActionItem/TaskAction.tsx index e85a2e708feb..a782d3e9e051 100644 --- a/src/components/ReportActionItem/TaskAction.tsx +++ b/src/components/ReportActionItem/TaskAction.tsx @@ -18,7 +18,11 @@ function TaskAction({action}: TaskActionProps) { return ( - {message.html ? ${message.html}`} /> : {message.text}} + {message.html ? ( + ${message.html}`} /> + ) : ( + {message.text} + )} ); } diff --git a/src/components/ReportActionItem/TaskView.tsx b/src/components/ReportActionItem/TaskView.tsx index 9711e126907f..9c15a57db3e6 100644 --- a/src/components/ReportActionItem/TaskView.tsx +++ b/src/components/ReportActionItem/TaskView.tsx @@ -139,7 +139,7 @@ function TaskView({report, shouldShowHorizontalRule, ...props}: TaskViewProps) { Navigation.navigate(ROUTES.REPORT_DESCRIPTION.getRoute(report.reportID))} diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index af55b4ca29be..ac8344cab9df 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4198,7 +4198,7 @@ function buildOptimisticEditedTaskFieldReportAction({title, description}: Task): { type: CONST.REPORT.MESSAGE.TYPE.COMMENT, text: changelog, - html: changelog, + html: description ? getParsedComment(changelog) : changelog, }, ], person: [ diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 2a37b900ddd4..cdec4d461ecf 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -438,7 +438,7 @@ function editTask(report: OnyxTypes.Report, {title, description}: OnyxTypes.Task const reportName = (title ?? report?.reportName)?.trim(); // Description can be unset, so we default to an empty string if so - const reportDescription = (description ?? report.description ?? '').trim(); + const reportDescription = ReportUtils.getParsedComment((description ?? report.description ?? '').trim()); const optimisticData: OnyxUpdate[] = [ { diff --git a/src/pages/tasks/TaskDescriptionPage.tsx b/src/pages/tasks/TaskDescriptionPage.tsx index 9abe15a5bb80..ce14ad45fb26 100644 --- a/src/pages/tasks/TaskDescriptionPage.tsx +++ b/src/pages/tasks/TaskDescriptionPage.tsx @@ -46,7 +46,7 @@ function TaskDescriptionPage({report, currentUserPersonalDetails}: TaskDescripti const submit = useCallback( (values: FormOnyxValues) => { - if (parser.htmlToMarkdown(parser.replace(values.description)) !== parser.htmlToMarkdown(parser.replace(report?.description ?? '')) && !isEmptyObject(report)) { + if (values.description !== parser.htmlToMarkdown(report?.description ?? '') && !isEmptyObject(report)) { // Set the description of the report in the store and then call EditTask API // to update the description of the report on the server Task.editTask(report, {description: values.description}); @@ -109,7 +109,7 @@ function TaskDescriptionPage({report, currentUserPersonalDetails}: TaskDescripti name={INPUT_IDS.DESCRIPTION} label={translate('newTaskPage.descriptionOptional')} accessibilityLabel={translate('newTaskPage.descriptionOptional')} - defaultValue={parser.htmlToMarkdown((report && parser.replace(report?.description ?? '')) || '')} + defaultValue={parser.htmlToMarkdown(report?.description ?? '')} ref={(element: AnimatedTextInputRef) => { if (!element) { return; From 0256948ca071b219dd0fab26bbbcf730e5bb8c04 Mon Sep 17 00:00:00 2001 From: Michal Muzyk Date: Tue, 23 Apr 2024 14:17:35 +0200 Subject: [PATCH 02/36] feat: refactor MoneyRequestConfirmationList --- .../MoneyRequestConfirmationList.tsx | 173 ++--- .../OptionsSelector/BaseOptionsSelector.js | 692 ------------------ .../OptionsSelector/index.android.js | 18 - src/components/OptionsSelector/index.js | 18 - .../optionsSelectorPropTypes.js | 185 ----- tests/perf-test/OptionsSelector.perf-test.tsx | 141 ---- 6 files changed, 91 insertions(+), 1136 deletions(-) delete mode 100755 src/components/OptionsSelector/BaseOptionsSelector.js delete mode 100644 src/components/OptionsSelector/index.android.js delete mode 100644 src/components/OptionsSelector/index.js delete mode 100644 src/components/OptionsSelector/optionsSelectorPropTypes.js delete mode 100644 tests/perf-test/OptionsSelector.perf-test.tsx diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 99901dd261de..82bf09458cdb 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -3,7 +3,6 @@ import {format} from 'date-fns'; import Str from 'expensify-common/lib/str'; import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react'; import {View} from 'react-native'; -import type {StyleProp, ViewStyle} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -41,12 +40,16 @@ import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; import type {DropdownOption} from './ButtonWithDropdownMenu/types'; import ConfirmedRoute from './ConfirmedRoute'; import ConfirmModal from './ConfirmModal'; +import FixedFooter from './FixedFooter'; import FormHelpMessage from './FormHelpMessage'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; -import OptionsSelector from './OptionsSelector'; import PDFThumbnail from './PDFThumbnail'; import ReceiptEmptyState from './ReceiptEmptyState'; import ReceiptImage from './ReceiptImage'; +import ScrollView from './ScrollView'; +import SelectionList from './SelectionList'; +import InviteMemberListItem from './SelectionList/InviteMemberListItem'; +import type {SectionListDataType} from './SelectionList/types'; import SettlementButton from './SettlementButton'; import ShowMoreButton from './ShowMoreButton'; import Switch from './Switch'; @@ -142,9 +145,6 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & /** File name of the receipt */ receiptFilename?: string; - /** List styles for OptionsSelector */ - listStyles?: StyleProp; - /** Transaction that represents the expense */ transaction?: OnyxEntry; @@ -173,6 +173,8 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & action?: IOUAction; }; +type MoneyRequestConfirmationListItem = (Participant | ReportUtils.OptionData) & {descriptiveText?: string}; + const getTaxAmount = (transaction: OnyxEntry, defaultTaxValue: string) => { const percentage = (transaction?.taxRate ? transaction?.taxRate?.data?.value : defaultTaxValue) ?? ''; return TransactionUtils.calculateTaxAmount(percentage, transaction?.amount ?? 0); @@ -209,7 +211,6 @@ function MoneyRequestConfirmationList({ receiptPath = '', iouComment, receiptFilename = '', - listStyles, iouCreated, iouIsBillable = false, onToggleBillable, @@ -417,27 +418,46 @@ function MoneyRequestConfirmationList({ const payeePersonalDetails = useMemo(() => payeePersonalDetailsProp ?? currentUserPersonalDetails, [payeePersonalDetailsProp, currentUserPersonalDetails]); const canModifyParticipants = !isReadOnly && canModifyParticipantsProp && hasMultipleParticipants; const shouldDisablePaidBySection = canModifyParticipants; - const optionSelectorSections = useMemo(() => { - const sections = []; + + const sections = useMemo(() => { + const options: Array> = []; const unselectedParticipants = selectedParticipantsProp.filter((participant) => !participant.selected); if (hasMultipleParticipants) { const formattedSelectedParticipants = getParticipantsWithAmount(selectedParticipants); - let formattedParticipantsList = [...new Set([...formattedSelectedParticipants, ...unselectedParticipants])]; + const getRightElement = (text: string) => ( + + {text} + + ); + + let formattedParticipantsList: MoneyRequestConfirmationListItem[] = [...new Set([...formattedSelectedParticipants, ...unselectedParticipants])]; if (!canModifyParticipants) { formattedParticipantsList = formattedParticipantsList.map((participant) => ({ ...participant, + isSelected: false, isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1), + rightElement: getRightElement(participant?.descriptiveText ?? ''), + })); + } else { + formattedParticipantsList = formattedParticipantsList.map((participant) => ({ + ...participant, + rightElement: getRightElement(participant?.descriptiveText ?? ''), })); } const myIOUAmount = IOUUtils.calculateAmount(selectedParticipants.length, iouAmount, iouCurrencyCode ?? '', true); - const formattedPayeeOption = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail( + const iouPayeeDetails = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail( payeePersonalDetails, iouAmount > 0 ? CurrencyUtils.convertToDisplayString(myIOUAmount, iouCurrencyCode) : '', ); + const formattedPayeeOption = { + ...iouPayeeDetails, + isSelected: !!canModifyParticipants, + rightElement: getRightElement(iouPayeeDetails.descriptiveText), + }; - sections.push( + options.push( { title: translate('moneyRequestConfirmationList.paidBy'), data: [formattedPayeeOption], @@ -453,35 +473,33 @@ function MoneyRequestConfirmationList({ } else { const formattedSelectedParticipants = selectedParticipants.map((participant) => ({ ...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; + return options; }, [ - selectedParticipants, selectedParticipantsProp, hasMultipleParticipants, + getParticipantsWithAmount, + selectedParticipants, + canModifyParticipants, iouAmount, iouCurrencyCode, - getParticipantsWithAmount, payeePersonalDetails, translate, shouldDisablePaidBySection, - canModifyParticipants, + styles.flexWrap, + styles.pl2, + styles.textLabel, ]); - const selectedOptions = useMemo(() => { - if (!hasMultipleParticipants) { - return []; - } - return [...selectedParticipants, OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(payeePersonalDetails)]; - }, [selectedParticipants, hasMultipleParticipants, payeePersonalDetails]); - useEffect(() => { if (!isDistanceRequest || isMovingTransactionFromTrackExpense) { return; @@ -554,7 +572,7 @@ function MoneyRequestConfirmationList({ /** * 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) { @@ -958,7 +976,7 @@ function MoneyRequestConfirmationList({ // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style previewSourceURL={resolvedReceiptImage as string} style={styles.moneyRequestImage} - // We don't support scaning password protected PDF receipt + // We don't support scanning password protected PDF receipt enabled={!isAttachmentInvalid} onPassword={() => setIsAttachmentInvalid(true)} /> @@ -979,64 +997,55 @@ function MoneyRequestConfirmationList({ ); 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) - - {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 && ( - + + + sections={sections} + ListItem={InviteMemberListItem} + onSelectRow={canModifyParticipants ? selectParticipant : navigateToReportOrUserDetail} + canSelectMultiple={canModifyParticipants} /> - )} - {shouldShowAllFields && supplementaryFields} - - + {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} + + + {footerContent} + ); } 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 && ( -