diff --git a/src/CONST.ts b/src/CONST.ts
index 47ab30589612..f52e55b86e02 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -2902,6 +2902,12 @@ const CONST = {
BACK_BUTTON_NATIVE_ID: 'backButton',
+ /**
+ * The maximum count of items per page for OptionsSelector.
+ * When paginate, it multiplies by page number.
+ */
+ MAX_OPTIONS_SELECTOR_PAGE_LENGTH: 500,
+
/**
* Performance test setup - run the same test multiple times to get a more accurate result
*/
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index 6cf1b7e6cef1..7ece89bf97b8 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -28,17 +28,16 @@ import * as IOU from '@userActions/IOU';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import Button from './Button';
import ButtonWithDropdownMenu from './ButtonWithDropdownMenu';
import categoryPropTypes from './categoryPropTypes';
import ConfirmedRoute from './ConfirmedRoute';
import FormHelpMessage from './FormHelpMessage';
-import * as Expensicons from './Icon/Expensicons';
import Image from './Image';
import MenuItemWithTopDescription from './MenuItemWithTopDescription';
import optionPropTypes from './optionPropTypes';
import OptionsSelector from './OptionsSelector';
import SettlementButton from './SettlementButton';
+import ShowMoreButton from './ShowMoreButton';
import Switch from './Switch';
import tagPropTypes from './tagPropTypes';
import Text from './Text';
@@ -636,20 +635,10 @@ function MoneyRequestConfirmationList(props) {
numberOfLinesTitle={2}
/>
{!shouldShowAllFields && (
-
-
-
-
-
-
+
)}
{shouldShowAllFields && (
<>
diff --git a/src/components/OptionsList/BaseOptionsList.js b/src/components/OptionsList/BaseOptionsList.js
index 2f81e6d80e7d..b5b055164a5f 100644
--- a/src/components/OptionsList/BaseOptionsList.js
+++ b/src/components/OptionsList/BaseOptionsList.js
@@ -70,6 +70,7 @@ function BaseOptionsList({
isLoadingNewOptions,
nestedScrollEnabled,
bounces,
+ renderFooterContent,
}) {
const styles = useThemeStyles();
const flattenedData = useRef();
@@ -286,6 +287,7 @@ function BaseOptionsList({
viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}}
onViewableItemsChanged={onViewableItemsChanged}
bounces={bounces}
+ ListFooterComponent={renderFooterContent}
/>
>
)}
diff --git a/src/components/OptionsList/optionsListPropTypes.js b/src/components/OptionsList/optionsListPropTypes.js
index b841943f2402..2485ad1558ac 100644
--- a/src/components/OptionsList/optionsListPropTypes.js
+++ b/src/components/OptionsList/optionsListPropTypes.js
@@ -100,6 +100,9 @@ const propTypes = {
/** Whether the list should have a bounce effect on iOS */
bounces: PropTypes.bool,
+
+ /** Custom content to display in the floating footer */
+ renderFooterContent: PropTypes.func,
};
const defaultProps = {
@@ -130,6 +133,7 @@ const defaultProps = {
isLoadingNewOptions: false,
nestedScrollEnabled: true,
bounces: true,
+ renderFooterContent: undefined,
};
export {propTypes, defaultProps};
diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js
index c2f3e2b47330..caa7c52cf2bf 100755
--- a/src/components/OptionsSelector/BaseOptionsSelector.js
+++ b/src/components/OptionsSelector/BaseOptionsSelector.js
@@ -11,6 +11,7 @@ import Icon from '@components/Icon';
import {Info} from '@components/Icon/Expensicons';
import OptionsList from '@components/OptionsList';
import {PressableWithoutFeedback} from '@components/Pressable';
+import ShowMoreButton from '@components/ShowMoreButton';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
@@ -74,17 +75,23 @@ class BaseOptionsSelector extends Component {
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.relatedTarget = null;
const allOptions = this.flattenSections();
+ const sections = this.sliceSections();
const focusedIndex = this.getInitiallyFocusedIndex(allOptions);
this.state = {
+ sections,
allOptions,
focusedIndex,
shouldDisableRowSelection: false,
shouldShowReferralModal: false,
errorMessage: '',
+ paginationPage: 1,
};
}
@@ -100,7 +107,7 @@ class BaseOptionsSelector extends Component {
this.scrollToIndex(this.props.selectedOptions.length ? 0 : this.state.focusedIndex, false);
}
- componentDidUpdate(prevProps) {
+ componentDidUpdate(prevProps, prevState) {
if (prevProps.isFocused !== this.props.isFocused) {
if (this.props.isFocused) {
this.subscribeToKeyboardShortcut();
@@ -118,14 +125,24 @@ class BaseOptionsSelector extends Component {
}, CONST.ANIMATED_TRANSITION);
}
+ if (prevState.paginationPage !== this.state.paginationPage) {
+ const newSections = this.sliceSections();
+
+ this.setState({
+ sections: newSections,
+ });
+ }
+
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;
@@ -136,6 +153,7 @@ class BaseOptionsSelector extends Component {
// eslint-disable-next-line react/no-did-update-set-state
this.setState(
{
+ sections: newSections,
allOptions: newOptions,
focusedIndex: _.isNumber(this.props.initialFocusedIndex) ? this.props.initialFocusedIndex : newFocusedIndex,
},
@@ -189,8 +207,43 @@ class BaseOptionsSelector extends Component {
return defaultIndex;
}
+ /**
+ * 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;
+ }
+
+ 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;
+
+ _.forEach(this.state.sections, (section) => {
+ count += lodashGet(section, 'data.length', 0);
+ });
+
+ return count;
+ }
+
updateSearchValue(value) {
this.setState({
+ paginationPage: 1,
errorMessage: value.length > this.props.maxLength ? this.props.translate('common.error.characterLimitExceedCounter', {length: value.length, limit: this.props.maxLength}) : '',
});
@@ -328,12 +381,16 @@ class BaseOptionsSelector extends Component {
const itemIndex = option.index;
const sectionIndex = option.sectionIndex;
+ if (!lodashGet(this.state.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(this.props.sections, `[${i}].data`))) {
+ if (_.isEmpty(lodashGet(this.state.sections, `[${i}].data`))) {
adjustedSectionIndex--;
}
}
@@ -387,7 +444,17 @@ class BaseOptionsSelector extends Component {
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 && _.isEmpty(this.props.selectedOptions));
const defaultConfirmButtonText = _.isUndefined(this.props.confirmButtonText) ? this.props.translate('common.confirm') : this.props.confirmButtonText;
@@ -424,7 +491,7 @@ class BaseOptionsSelector extends Component {
ref={(el) => (this.list = el)}
optionHoveredStyle={this.props.optionHoveredStyle}
onSelectRow={this.props.onSelectRow ? this.selectRow : undefined}
- sections={this.props.sections}
+ sections={this.state.sections}
focusedIndex={this.state.focusedIndex}
selectedOptions={this.props.selectedOptions}
canSelectMultipleOptions={this.props.canSelectMultipleOptions}
@@ -458,6 +525,16 @@ class BaseOptionsSelector extends Component {
shouldPreventDefaultFocusOnSelectRow={this.props.shouldPreventDefaultFocusOnSelectRow}
nestedScrollEnabled={this.props.nestedScrollEnabled}
bounces={!this.props.shouldTextInputAppearBelowOptions || !this.props.shouldAllowScrollingChildren}
+ renderFooterContent={() =>
+ shouldShowShowMoreButton && (
+
+ )
+ }
/>
);
@@ -475,7 +552,7 @@ class BaseOptionsSelector extends Component {
{} : this.updateFocusedIndex}
shouldResetIndexOnEndReached={false}
>
diff --git a/src/components/ShowMoreButton/index.js b/src/components/ShowMoreButton/index.js
new file mode 100644
index 000000000000..f983a468cc1c
--- /dev/null
+++ b/src/components/ShowMoreButton/index.js
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import {Text, View} from 'react-native';
+import _ from 'underscore';
+import Button from '@components/Button';
+import * as Expensicons from '@components/Icon/Expensicons';
+import useLocalize from '@hooks/useLocalize';
+import * as NumberFormatUtils from '@libs/NumberFormatUtils';
+import stylePropTypes from '@styles/stylePropTypes';
+import styles from '@styles/styles';
+import themeColors from '@styles/themes/default';
+
+const propTypes = {
+ /** Additional styles for container */
+ containerStyle: stylePropTypes,
+
+ /** The number of currently shown items */
+ currentCount: PropTypes.number,
+
+ /** The total number of items that could be shown */
+ totalCount: PropTypes.number,
+
+ /** A handler that fires when button has been pressed */
+ onPress: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ containerStyle: {},
+ currentCount: undefined,
+ totalCount: undefined,
+};
+
+function ShowMoreButton({containerStyle, currentCount, totalCount, onPress}) {
+ const {translate, preferredLocale} = useLocalize();
+
+ const shouldShowCounter = _.isNumber(currentCount) && _.isNumber(totalCount);
+
+ return (
+
+ {shouldShowCounter && (
+
+ {`${translate('common.showing')} `}
+ {currentCount}
+ {` ${translate('common.of')} `}
+ {NumberFormatUtils.format(preferredLocale, totalCount)}
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+ShowMoreButton.displayName = 'ShowMoreButton';
+ShowMoreButton.propTypes = propTypes;
+ShowMoreButton.defaultProps = defaultProps;
+
+export default ShowMoreButton;
diff --git a/src/components/TagPicker/index.js b/src/components/TagPicker/index.js
index d6d49e3fe288..3f4668999e9a 100644
--- a/src/components/TagPicker/index.js
+++ b/src/components/TagPicker/index.js
@@ -6,12 +6,13 @@ import OptionsSelector from '@components/OptionsSelector';
import useLocalize from '@hooks/useLocalize';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
+import * as StyleUtils from '@styles/StyleUtils';
import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {defaultProps, propTypes} from './tagPickerPropTypes';
-function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubmit, shouldShowDisabledAndSelectedOption}) {
+function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, shouldShowDisabledAndSelectedOption, insets, onSubmit}) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [searchValue, setSearchValue] = useState('');
@@ -66,6 +67,7 @@ function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubm
return (
`${formattedAmount} request${comment ? ` for ${comment}` : ''}`,
threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} sent${comment ? ` for ${comment}` : ''}`,
- tagSelection: ({tagName}: TagSelectionParams) => `Select a ${tagName} to add additional organization to your money`,
- categorySelection: 'Select a category to add additional organization to your money',
+ tagSelection: ({tagName}: TagSelectionParams) => `Select a ${tagName} to add additional organization to your money.`,
+ categorySelection: 'Select a category to add additional organization to your money.',
error: {
invalidAmount: 'Please enter a valid amount before continuing.',
invalidSplit: 'Split amounts do not equal total amount',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 9bc9786f6d32..f2ddc2d51da8 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -258,6 +258,8 @@ export default {
selectCurrency: 'Selecciona una moneda',
card: 'Tarjeta',
required: 'Obligatorio',
+ showing: 'Mostrando',
+ of: 'de',
},
location: {
useCurrent: 'Usar ubicación actual',
@@ -578,8 +580,8 @@ export default {
`cambió la distancia a ${newDistanceToDisplay} (previamente ${oldDistanceToDisplay}), lo que cambió el importe a ${newAmountToDisplay} (previamente ${oldAmountToDisplay})`,
threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `Solicitud de ${formattedAmount}${comment ? ` para ${comment}` : ''}`,
threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} enviado${comment ? ` para ${comment}` : ''}`,
- tagSelection: ({tagName}: TagSelectionParams) => `Seleccione una ${tagName} para organizar mejor tu dinero`,
- categorySelection: 'Seleccione una categoría para organizar mejor tu dinero',
+ tagSelection: ({tagName}: TagSelectionParams) => `Seleccione una ${tagName} para organizar mejor tu dinero.`,
+ categorySelection: 'Seleccione una categoría para organizar mejor tu dinero.',
error: {
invalidAmount: 'Por favor ingresa un monto válido antes de continuar.',
invalidSplit: 'La suma de las partes no equivale al monto total',
diff --git a/src/pages/EditRequestTagPage.js b/src/pages/EditRequestTagPage.js
index 52b0fea01395..74cf69944f2c 100644
--- a/src/pages/EditRequestTagPage.js
+++ b/src/pages/EditRequestTagPage.js
@@ -36,18 +36,23 @@ function EditRequestTagPage({defaultTag, policyID, tagName, onSubmit}) {
shouldEnableMaxHeight
testID={EditRequestTagPage.displayName}
>
-
- {translate('iou.tagSelection', {tagName: tagName || translate('common.tag')})}
-
+ {({insets}) => (
+ <>
+
+ {translate('iou.tagSelection', {tagName: tagName || translate('common.tag')})}
+
+ >
+ )}
);
}
diff --git a/src/pages/iou/MoneyRequestTagPage.js b/src/pages/iou/MoneyRequestTagPage.js
index b8ef1dba6207..d16a7aa6679c 100644
--- a/src/pages/iou/MoneyRequestTagPage.js
+++ b/src/pages/iou/MoneyRequestTagPage.js
@@ -78,17 +78,22 @@ function MoneyRequestTagPage({route, report, policyTags, iou}) {
shouldEnableMaxHeight
testID={MoneyRequestTagPage.displayName}
>
-
- {translate('iou.tagSelection', {tagName: policyTagListName})}
-
+ {({insets}) => (
+ <>
+
+ {translate('iou.tagSelection', {tagName: policyTagListName})}
+
+ >
+ )}
);
}