diff --git a/src/components/CountryPicker/CountrySelectorModal.js b/src/components/CountryPicker/CountrySelectorModal.js
index 74575c0021f3..146b023bbf0c 100644
--- a/src/components/CountryPicker/CountrySelectorModal.js
+++ b/src/components/CountryPicker/CountrySelectorModal.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import CONST from '../../CONST';
import useLocalize from '../../hooks/useLocalize';
import HeaderWithBackButton from '../HeaderWithBackButton';
-import SelectionListRadio from '../SelectionListRadio';
+import SelectionList from '../SelectionList';
import Modal from '../Modal';
import ScreenWrapper from '../ScreenWrapper';
import styles from '../../styles/styles';
@@ -73,7 +73,7 @@ function CountrySelectorModal({currentCountry, isVisible, onClose, onCountrySele
title={translate('common.country')}
onBackButtonPress={onClose}
/>
-
diff --git a/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js b/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js
index 44e35f6b27cb..00ab73966b4b 100644
--- a/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js
+++ b/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js
@@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
import _ from 'underscore';
import HeaderWithBackButton from '../../HeaderWithBackButton';
import CONST from '../../../CONST';
-import SelectionListRadio from '../../SelectionListRadio';
+import SelectionList from '../../SelectionList';
import Modal from '../../Modal';
-import {radioListItemPropTypes} from '../../SelectionListRadio/selectionListRadioPropTypes';
+import {radioListItemPropTypes} from '../../SelectionList/selectionListPropTypes';
import useLocalize from '../../../hooks/useLocalize';
import ScreenWrapper from '../../ScreenWrapper';
import styles from '../../../styles/styles';
@@ -15,7 +15,7 @@ const propTypes = {
isVisible: PropTypes.bool.isRequired,
/** The list of years to render */
- years: PropTypes.arrayOf(PropTypes.shape(radioListItemPropTypes)).isRequired,
+ years: PropTypes.arrayOf(PropTypes.shape(radioListItemPropTypes.item)).isRequired,
/** Currently selected year */
currentYear: PropTypes.number,
@@ -69,7 +69,7 @@ function YearPickerModal(props) {
title={translate('yearPickerPage.year')}
onBackButtonPress={props.onClose}
/>
- {
+ const allOptions = [];
+
+ const disabledOptionsIndexes = [];
+ let disabledIndex = 0;
+
+ let offset = 0;
+ const itemLayouts = [{length: 0, offset}];
+
+ const selectedOptions = [];
+
+ _.each(sections, (section, sectionIndex) => {
+ const sectionHeaderHeight = variables.optionsListSectionHeaderHeight;
+ itemLayouts.push({length: sectionHeaderHeight, offset});
+ offset += sectionHeaderHeight;
+
+ _.each(section.data, (item, optionIndex) => {
+ // Add item to the general flattened array
+ allOptions.push({
+ ...item,
+ sectionIndex,
+ index: optionIndex,
+ });
+
+ // If disabled, add to the disabled indexes array
+ if (section.isDisabled || item.isDisabled) {
+ disabledOptionsIndexes.push(disabledIndex);
+ }
+ disabledIndex += 1;
+
+ // Account for the height of the item in getItemLayout
+ const fullItemHeight = variables.optionRowHeight;
+ itemLayouts.push({length: fullItemHeight, offset});
+ offset += fullItemHeight;
+
+ if (item.isSelected) {
+ selectedOptions.push(item);
+ }
+ });
+
+ // We're not rendering any section footer, but we need to push to the array
+ // because React Native accounts for it in getItemLayout
+ itemLayouts.push({length: 0, offset});
+ });
+
+ // We're not rendering the list footer, but we need to push to the array
+ // because React Native accounts for it in getItemLayout
+ itemLayouts.push({length: 0, offset});
+
+ if (selectedOptions.length > 1 && !canSelectMultiple) {
+ Log.alert(
+ 'Dev error: SelectionList - multiple items are selected but prop `canSelectMultiple` is false. Please enable `canSelectMultiple` or make your list have only 1 item with `isSelected: true`.',
+ );
+ }
+
+ return {
+ allOptions,
+ selectedOptions,
+ disabledOptionsIndexes,
+ itemLayouts,
+ allSelected: selectedOptions.length > 0 && selectedOptions.length === allOptions.length - disabledOptionsIndexes.length,
+ };
+ }, [canSelectMultiple, sections]);
+
+ const [focusedIndex, setFocusedIndex] = useState(() => {
+ const defaultIndex = 0;
+
+ const indexOfInitiallyFocusedOption = _.findIndex(flattenedSections.allOptions, (option) => option.keyForList === initiallyFocusedOptionKey);
+
+ if (indexOfInitiallyFocusedOption >= 0) {
+ return indexOfInitiallyFocusedOption;
+ }
+
+ return defaultIndex;
+ });
+
+ /**
+ * Scrolls to the desired item index in the section list
+ *
+ * @param {Number} index - the index of the item to scroll to
+ * @param {Boolean} animated - whether to animate the scroll
+ */
+ const scrollToIndex = (index, animated) => {
+ const item = flattenedSections.allOptions[index];
+
+ if (!listRef.current || !item) {
+ return;
+ }
+
+ const itemIndex = item.index;
+ const sectionIndex = item.sectionIndex;
+
+ // 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});
+ };
+
+ const selectRow = (item, index) => {
+ // In single-selection lists we don't care about updating the focused index, because the list is closed after selecting an item
+ if (canSelectMultiple) {
+ if (sections.length === 1) {
+ // If the list has only 1 section (e.g. Workspace Members list), we always focus the next available item
+ const nextAvailableIndex = _.findIndex(flattenedSections.allOptions, (option, i) => i > index && !option.isDisabled);
+ setFocusedIndex(nextAvailableIndex);
+ } else {
+ // If the list has multiple sections (e.g. Workspace Invite list), we focus the first one after all the selected (selected items are always at the top)
+ const selectedOptionsCount = item.isSelected ? flattenedSections.selectedOptions.length - 1 : flattenedSections.selectedOptions.length + 1;
+ setFocusedIndex(selectedOptionsCount);
+ }
+ }
+
+ onSelectRow(item);
+ };
+
+ const selectFocusedOption = () => {
+ const focusedOption = flattenedSections.allOptions[focusedIndex];
+
+ if (!focusedOption || focusedOption.isDisabled) {
+ return;
+ }
+
+ selectRow(focusedOption, focusedIndex);
+ };
+
+ /**
+ * This function is used to compute the layout of any given item in our list.
+ * We need to implement it so that we can programmatically scroll to items outside the virtual render window of the SectionList.
+ *
+ * @param {Array} data - This is the same as the data we pass into the component
+ * @param {Number} flatDataArrayIndex - This index is provided by React Native, and refers to a flat array with data from all the sections. This flat array has some quirks:
+ *
+ * 1. It ALWAYS includes a list header and a list footer, even if we don't provide/render those.
+ * 2. Each section includes a header, even if we don't provide/render one.
+ *
+ * For example, given a list with two sections, two items in each section, no header, no footer, and no section headers, the flat array might look something like this:
+ *
+ * [{header}, {sectionHeader}, {item}, {item}, {sectionHeader}, {item}, {item}, {footer}]
+ *
+ * @returns {Object}
+ */
+ const getItemLayout = (data, flatDataArrayIndex) => {
+ const targetItem = flattenedSections.itemLayouts[flatDataArrayIndex];
+
+ return {
+ length: targetItem.length,
+ offset: targetItem.offset,
+ index: flatDataArrayIndex,
+ };
+ };
+
+ const renderSectionHeader = ({section}) => {
+ if (!section.title || _.isEmpty(section.data)) {
+ return null;
+ }
+
+ return (
+ // Note: The `optionsListSectionHeader` style provides an explicit height to section headers.
+ // We do this so that we can reference the height in `getItemLayout` –
+ // we need to know the heights of all list items up-front in order to synchronously compute the layout of any given list item.
+ // So be aware that if you adjust the content of the section header (for example, change the font size), you may need to adjust this explicit height as well.
+
+ {section.title}
+
+ );
+ };
+
+ const renderItem = ({item, index, section}) => {
+ const isDisabled = section.isDisabled;
+ const isFocused = !isDisabled && focusedIndex === index + lodashGet(section, 'indexOffset', 0);
+
+ if (canSelectMultiple) {
+ return (
+ selectRow(item, index)}
+ onDismissError={onDismissError}
+ />
+ );
+ }
+
+ return (
+ selectRow(item, index)}
+ />
+ );
+ };
+
+ /** Focuses the text input when the component mounts. If `props.shouldDelayFocus` is true, we wait for the animation to finish */
+ useEffect(() => {
+ if (shouldShowTextInput) {
+ if (shouldDelayFocus) {
+ focusTimeoutRef.current = setTimeout(() => textInputRef.current.focus(), CONST.ANIMATED_TRANSITION);
+ } else {
+ textInputRef.current.focus();
+ }
+ }
+
+ return () => {
+ if (!focusTimeoutRef.current) {
+ return;
+ }
+ clearTimeout(focusTimeoutRef.current);
+ };
+ }, [shouldDelayFocus, shouldShowTextInput]);
+
+ /** Selects row when pressing enter */
+ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, {
+ captureOnInputs: true,
+ shouldBubble: () => !flattenedSections.allOptions[focusedIndex],
+ });
+
+ return (
+ {
+ setFocusedIndex(newFocusedIndex);
+ scrollToIndex(newFocusedIndex, true);
+ }}
+ >
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+ {shouldShowTextInput && (
+
+
+
+ )}
+ {Boolean(headerMessage) && (
+
+ {headerMessage}
+
+ )}
+ {flattenedSections.allOptions.length === 0 && showLoadingPlaceholder ? (
+
+ ) : (
+ <>
+ {!headerMessage && canSelectMultiple && shouldShowSelectAll && (
+
+
+
+ {translate('workspace.people.selectAll')}
+
+
+ )}
+ item.keyForList}
+ extraData={focusedIndex}
+ indicatorStyle="white"
+ keyboardShouldPersistTaps="always"
+ showsVerticalScrollIndicator={showScrollIndicator}
+ initialNumToRender={12}
+ maxToRenderPerBatch={5}
+ windowSize={5}
+ viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}}
+ testID="selection-list"
+ onLayout={() => {
+ if (!firstLayoutRef.current) {
+ return;
+ }
+ scrollToIndex(focusedIndex, false);
+ firstLayoutRef.current = false;
+ }}
+ />
+ >
+ )}
+ {shouldShowConfirmButton && (
+
+
+
+ )}
+
+ )}
+
+
+ );
+}
+
+BaseSelectionList.displayName = 'BaseSelectionList';
+BaseSelectionList.propTypes = propTypes;
+
+export default withKeyboardState(BaseSelectionList);
diff --git a/src/components/SelectionList/CheckboxListItem.js b/src/components/SelectionList/CheckboxListItem.js
new file mode 100644
index 000000000000..539b436ba65a
--- /dev/null
+++ b/src/components/SelectionList/CheckboxListItem.js
@@ -0,0 +1,75 @@
+import React from 'react';
+import {View} from 'react-native';
+import _ from 'underscore';
+import lodashGet from 'lodash/get';
+import PressableWithFeedback from '../Pressable/PressableWithFeedback';
+import styles from '../../styles/styles';
+import Text from '../Text';
+import {checkboxListItemPropTypes} from './selectionListPropTypes';
+import Checkbox from '../Checkbox';
+import Avatar from '../Avatar';
+import OfflineWithFeedback from '../OfflineWithFeedback';
+import CONST from '../../CONST';
+
+function CheckboxListItem({item, isFocused = false, onSelectRow, onDismissError = () => {}}) {
+ const hasError = !_.isEmpty(item.errors);
+
+ return (
+ onDismissError(item)}
+ pendingAction={item.pendingAction}
+ errors={item.errors}
+ errorRowStyles={styles.ph5}
+ >
+ onSelectRow(item)}
+ disabled={item.isDisabled}
+ accessibilityLabel={item.text}
+ accessibilityRole="checkbox"
+ accessibilityState={{checked: item.isSelected}}
+ hoverDimmingValue={1}
+ hoverStyle={styles.hoveredComponentBG}
+ focusStyle={styles.hoveredComponentBG}
+ >
+ onSelectRow(item)}
+ style={item.isDisabled ? styles.buttonOpacityDisabled : {}}
+ />
+ {Boolean(item.avatar) && (
+
+ )}
+
+
+ {item.text}
+
+ {Boolean(item.alternateText) && (
+
+ {item.alternateText}
+
+ )}
+
+ {Boolean(item.rightElement) && item.rightElement}
+
+
+ );
+}
+
+CheckboxListItem.displayName = 'CheckboxListItem';
+CheckboxListItem.propTypes = checkboxListItemPropTypes;
+
+export default CheckboxListItem;
diff --git a/src/components/SelectionList/RadioListItem.js b/src/components/SelectionList/RadioListItem.js
new file mode 100644
index 000000000000..92e3e84b66c8
--- /dev/null
+++ b/src/components/SelectionList/RadioListItem.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import {View} from 'react-native';
+import PressableWithFeedback from '../Pressable/PressableWithFeedback';
+import styles from '../../styles/styles';
+import Text from '../Text';
+import Icon from '../Icon';
+import * as Expensicons from '../Icon/Expensicons';
+import themeColors from '../../styles/themes/default';
+import {radioListItemPropTypes} from './selectionListPropTypes';
+
+function RadioListItem({item, isFocused = false, isDisabled = false, onSelectRow}) {
+ return (
+ onSelectRow(item)}
+ disabled={isDisabled}
+ accessibilityLabel={item.text}
+ accessibilityRole="button"
+ hoverDimmingValue={1}
+ hoverStyle={styles.hoveredComponentBG}
+ focusStyle={styles.hoveredComponentBG}
+ >
+
+
+
+ {item.text}
+
+
+ {Boolean(item.alternateText) && (
+ {item.alternateText}
+ )}
+
+
+ {item.isSelected && (
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+RadioListItem.displayName = 'RadioListItem';
+RadioListItem.propTypes = radioListItemPropTypes;
+
+export default RadioListItem;
diff --git a/src/components/SelectionListRadio/index.android.js b/src/components/SelectionList/index.android.js
similarity index 53%
rename from src/components/SelectionListRadio/index.android.js
rename to src/components/SelectionList/index.android.js
index 53fc12b23d31..53d5b6bbce06 100644
--- a/src/components/SelectionListRadio/index.android.js
+++ b/src/components/SelectionList/index.android.js
@@ -1,9 +1,9 @@
import React, {forwardRef} from 'react';
import {Keyboard} from 'react-native';
-import BaseSelectionListRadio from './BaseSelectionListRadio';
+import BaseSelectionList from './BaseSelectionList';
-const SelectionListRadio = forwardRef((props, ref) => (
- (
+ (
/>
));
-SelectionListRadio.displayName = 'SelectionListRadio';
+SelectionList.displayName = 'SelectionList';
-export default SelectionListRadio;
+export default SelectionList;
diff --git a/src/components/SelectionListRadio/index.ios.js b/src/components/SelectionList/index.ios.js
similarity index 51%
rename from src/components/SelectionListRadio/index.ios.js
rename to src/components/SelectionList/index.ios.js
index b8faad18420b..7f2a282aeb89 100644
--- a/src/components/SelectionListRadio/index.ios.js
+++ b/src/components/SelectionList/index.ios.js
@@ -1,9 +1,9 @@
import React, {forwardRef} from 'react';
import {Keyboard} from 'react-native';
-import BaseSelectionListRadio from './BaseSelectionListRadio';
+import BaseSelectionList from './BaseSelectionList';
-const SelectionListRadio = forwardRef((props, ref) => (
- (
+ (
/>
));
-SelectionListRadio.displayName = 'SelectionListRadio';
+SelectionList.displayName = 'SelectionList';
-export default SelectionListRadio;
+export default SelectionList;
diff --git a/src/components/SelectionListRadio/index.js b/src/components/SelectionList/index.js
similarity index 85%
rename from src/components/SelectionListRadio/index.js
rename to src/components/SelectionList/index.js
index a4d019476168..d2ad9ab3cf13 100644
--- a/src/components/SelectionListRadio/index.js
+++ b/src/components/SelectionList/index.js
@@ -1,9 +1,9 @@
import React, {forwardRef, useEffect, useState} from 'react';
import {Keyboard} from 'react-native';
-import BaseSelectionListRadio from './BaseSelectionListRadio';
+import BaseSelectionList from './BaseSelectionList';
import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
-const SelectionListRadio = forwardRef((props, ref) => {
+const SelectionList = forwardRef((props, ref) => {
const [isScreenTouched, setIsScreenTouched] = useState(false);
const touchStart = () => setIsScreenTouched(true);
@@ -26,7 +26,7 @@ const SelectionListRadio = forwardRef((props, ref) => {
}, []);
return (
- {
);
});
-SelectionListRadio.displayName = 'SelectionListRadio';
+SelectionList.displayName = 'SelectionList';
-export default SelectionListRadio;
+export default SelectionList;
diff --git a/src/components/SelectionList/selectionListPropTypes.js b/src/components/SelectionList/selectionListPropTypes.js
new file mode 100644
index 000000000000..414a838d269f
--- /dev/null
+++ b/src/components/SelectionList/selectionListPropTypes.js
@@ -0,0 +1,158 @@
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+import CONST from '../../CONST';
+
+const checkboxListItemPropTypes = {
+ /** The section list item */
+ item: PropTypes.shape({
+ /** Text to display */
+ text: PropTypes.string.isRequired,
+
+ /** Alternate text to display */
+ alternateText: PropTypes.string,
+
+ /** Key used internally by React */
+ keyForList: PropTypes.string.isRequired,
+
+ /** Whether this option is selected */
+ isSelected: PropTypes.bool,
+
+ /** Whether this option is disabled for selection */
+ isDisabled: PropTypes.bool,
+
+ /** User accountID */
+ accountID: PropTypes.number,
+
+ /** User login */
+ login: PropTypes.string,
+
+ /** Element to show on the right side of the item */
+ rightElement: PropTypes.element,
+
+ /** Avatar for the user */
+ avatar: PropTypes.shape({
+ source: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
+ name: PropTypes.string,
+ type: PropTypes.string,
+ }),
+
+ /** Errors that this user may contain */
+ errors: PropTypes.objectOf(PropTypes.string),
+
+ /** The type of action that's pending */
+ pendingAction: PropTypes.oneOf(_.values(CONST.RED_BRICK_ROAD_PENDING_ACTION)),
+ }).isRequired,
+
+ /** Whether this item is focused (for arrow key controls) */
+ isFocused: PropTypes.bool,
+
+ /** Callback to fire when the item is pressed */
+ onSelectRow: PropTypes.func.isRequired,
+
+ /** Callback to fire when an error is dismissed */
+ onDismissError: PropTypes.func,
+};
+
+const radioListItemPropTypes = {
+ /** The section list item */
+ item: PropTypes.shape({
+ /** Text to display */
+ text: PropTypes.string.isRequired,
+
+ /** Alternate text to display */
+ alternateText: PropTypes.string,
+
+ /** Key used internally by React */
+ keyForList: PropTypes.string.isRequired,
+
+ /** Whether this option is selected */
+ isSelected: PropTypes.bool,
+ }).isRequired,
+
+ /** Whether this item is focused (for arrow key controls) */
+ isFocused: PropTypes.bool,
+
+ /** Whether this item is disabled */
+ isDisabled: PropTypes.bool,
+
+ /** Callback to fire when the item is pressed */
+ onSelectRow: PropTypes.func.isRequired,
+};
+
+const propTypes = {
+ /** Sections for the section list */
+ sections: PropTypes.arrayOf(
+ PropTypes.shape({
+ /** Title of the section */
+ title: PropTypes.string,
+
+ /** The initial index of this section given the total number of options in each section's data array */
+ indexOffset: PropTypes.number,
+
+ /** Array of options */
+ data: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.shape(checkboxListItemPropTypes.item), PropTypes.shape(radioListItemPropTypes.item)])),
+
+ /** Whether this section items disabled for selection */
+ isDisabled: PropTypes.bool,
+ }),
+ ).isRequired,
+
+ /** Whether this is a multi-select list */
+ canSelectMultiple: PropTypes.bool,
+
+ /** Callback to fire when a row is pressed */
+ onSelectRow: PropTypes.func.isRequired,
+
+ /** Callback to fire when "Select All" checkbox is pressed. Only use along with `canSelectMultiple` */
+ onSelectAll: PropTypes.func,
+
+ /** Callback to fire when an error is dismissed */
+ onDismissError: PropTypes.func,
+
+ /** Label for the text input */
+ textInputLabel: PropTypes.string,
+
+ /** Placeholder for the text input */
+ textInputPlaceholder: PropTypes.string,
+
+ /** Value for the text input */
+ textInputValue: PropTypes.string,
+
+ /** Max length for the text input */
+ textInputMaxLength: PropTypes.number,
+
+ /** Callback to fire when the text input changes */
+ onChangeText: PropTypes.func,
+
+ /** Keyboard type for the text input */
+ keyboardType: PropTypes.string,
+
+ /** Item `keyForList` to focus initially */
+ initiallyFocusedOptionKey: PropTypes.string,
+
+ /** Whether to delay focus on the text input when mounting. Used for a smoother animation on Android */
+ shouldDelayFocus: PropTypes.bool,
+
+ /** Callback to fire when the list is scrolled */
+ onScroll: PropTypes.func,
+
+ /** Callback to fire when the list is scrolled and the user begins dragging */
+ onScrollBeginDrag: PropTypes.func,
+
+ /** Message to display at the top of the list */
+ headerMessage: PropTypes.string,
+
+ /** Text to display on the confirm button */
+ confirmButtonText: PropTypes.string,
+
+ /** Callback to fire when the confirm button is pressed */
+ onConfirm: PropTypes.func,
+
+ /** Whether to show the vertical scroll indicator */
+ showScrollIndicator: PropTypes.bool,
+
+ /** Whether to show the loading placeholder */
+ showLoadingPlaceholder: PropTypes.bool,
+};
+
+export {propTypes, radioListItemPropTypes, checkboxListItemPropTypes};
diff --git a/src/components/SelectionListRadio/BaseSelectionListRadio.js b/src/components/SelectionListRadio/BaseSelectionListRadio.js
deleted file mode 100644
index c9e4c14d6b81..000000000000
--- a/src/components/SelectionListRadio/BaseSelectionListRadio.js
+++ /dev/null
@@ -1,279 +0,0 @@
-import React, {useEffect, useRef, useState} from 'react';
-import {View} from 'react-native';
-import _ from 'underscore';
-import lodashGet from 'lodash/get';
-import SectionList from '../SectionList';
-import Text from '../Text';
-import styles from '../../styles/styles';
-import TextInput from '../TextInput';
-import ArrowKeyFocusManager from '../ArrowKeyFocusManager';
-import CONST from '../../CONST';
-import variables from '../../styles/variables';
-import {propTypes as selectionListRadioPropTypes, defaultProps as selectionListRadioDefaultProps} from './selectionListRadioPropTypes';
-import RadioListItem from './RadioListItem';
-import useKeyboardShortcut from '../../hooks/useKeyboardShortcut';
-import SafeAreaConsumer from '../SafeAreaConsumer';
-import withKeyboardState, {keyboardStatePropTypes} from '../withKeyboardState';
-
-const propTypes = {
- ...keyboardStatePropTypes,
- ...selectionListRadioPropTypes,
-};
-
-function BaseSelectionListRadio(props) {
- const firstLayoutRef = useRef(true);
- const listRef = useRef(null);
- const textInputRef = useRef(null);
- const focusTimeoutRef = useRef(null);
- const shouldShowTextInput = Boolean(props.textInputLabel);
-
- /**
- * Iterates through the sections and items inside each section, and builds 3 arrays along the way:
- * - `allOptions`: Contains all the items in the list, flattened, regardless of section
- * - `disabledOptionsIndexes`: Contains the indexes of all the disabled items in the list, to be used by the ArrowKeyFocusManager
- * - `itemLayouts`: Contains the layout information for each item, header and footer in the list,
- * so we can calculate the position of any given item when scrolling programmatically
- *
- * @return {{itemLayouts: [{offset: number, length: number}], disabledOptionsIndexes: *[], allOptions: *[]}}
- */
- const getFlattenedSections = () => {
- const allOptions = [];
-
- const disabledOptionsIndexes = [];
- let disabledIndex = 0;
-
- let offset = 0;
- const itemLayouts = [{length: 0, offset}];
-
- _.each(props.sections, (section, sectionIndex) => {
- // We're not rendering any section header, but we need to push to the array
- // because React Native accounts for it in getItemLayout
- const sectionHeaderHeight = 0;
- itemLayouts.push({length: sectionHeaderHeight, offset});
- offset += sectionHeaderHeight;
-
- _.each(section.data, (option, optionIndex) => {
- // Add item to the general flattened array
- allOptions.push({
- ...option,
- sectionIndex,
- index: optionIndex,
- });
-
- // If disabled, add to the disabled indexes array
- if (section.isDisabled || option.isDisabled) {
- disabledOptionsIndexes.push(disabledIndex);
- }
- disabledIndex += 1;
-
- // Account for the height of the item in getItemLayout
- const fullItemHeight = variables.optionRowHeight;
- itemLayouts.push({length: fullItemHeight, offset});
- offset += fullItemHeight;
- });
-
- // We're not rendering any section footer, but we need to push to the array
- // because React Native accounts for it in getItemLayout
- itemLayouts.push({length: 0, offset});
- });
-
- // We're not rendering the list footer, but we need to push to the array
- // because React Native accounts for it in getItemLayout
- itemLayouts.push({length: 0, offset});
-
- return {
- allOptions,
- disabledOptionsIndexes,
- itemLayouts,
- };
- };
-
- const flattenedSections = getFlattenedSections();
-
- const [focusedIndex, setFocusedIndex] = useState(() => {
- const defaultIndex = 0;
-
- const indexOfInitiallyFocusedOption = _.findIndex(flattenedSections.allOptions, (option) => option.keyForList === props.initiallyFocusedOptionKey);
-
- if (indexOfInitiallyFocusedOption >= 0) {
- return indexOfInitiallyFocusedOption;
- }
-
- return defaultIndex;
- });
-
- /**
- * Scrolls to the desired item index in the section list
- *
- * @param {Number} index - the index of the item to scroll to
- * @param {Boolean} animated - whether to animate the scroll
- */
- const scrollToIndex = (index, animated) => {
- const item = flattenedSections.allOptions[index];
-
- if (!listRef.current || !item) {
- return;
- }
-
- const itemIndex = item.index;
- const sectionIndex = item.sectionIndex;
-
- // 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(props.sections, `[${i}].data`))) {
- adjustedSectionIndex--;
- }
- }
-
- listRef.current.scrollToLocation({sectionIndex: adjustedSectionIndex, itemIndex, animated});
- };
-
- /**
- * This function is used to compute the layout of any given item in our list.
- * We need to implement it so that we can programmatically scroll to items outside the virtual render window of the SectionList.
- *
- * @param {Array} data - This is the same as the data we pass into the component
- * @param {Number} flatDataArrayIndex - This index is provided by React Native, and refers to a flat array with data from all the sections. This flat array has some quirks:
- *
- * 1. It ALWAYS includes a list header and a list footer, even if we don't provide/render those.
- * 2. Each section includes a header, even if we don't provide/render one.
- *
- * For example, given a list with two sections, two items in each section, no header, no footer, and no section headers, the flat array might look something like this:
- *
- * [{header}, {sectionHeader}, {item}, {item}, {sectionHeader}, {item}, {item}, {footer}]
- *
- * @returns {Object}
- */
- const getItemLayout = (data, flatDataArrayIndex) => {
- const targetItem = flattenedSections.itemLayouts[flatDataArrayIndex];
-
- return {
- length: targetItem.length,
- offset: targetItem.offset,
- index: flatDataArrayIndex,
- };
- };
-
- const renderItem = ({item, index, section}) => {
- const isDisabled = section.isDisabled;
- const isFocused = !isDisabled && focusedIndex === index + lodashGet(section, 'indexOffset', 0);
-
- return (
-
- );
- };
-
- /** Focuses the text input when the component mounts. If `props.shouldDelayFocus` is true, we wait for the animation to finish */
- useEffect(() => {
- if (shouldShowTextInput) {
- if (props.shouldDelayFocus) {
- focusTimeoutRef.current = setTimeout(() => textInputRef.current.focus(), CONST.ANIMATED_TRANSITION);
- } else {
- textInputRef.current.focus();
- }
- }
-
- return () => {
- if (!focusTimeoutRef.current) {
- return;
- }
- clearTimeout(focusTimeoutRef.current);
- };
- }, [props.shouldDelayFocus, shouldShowTextInput]);
-
- const selectFocusedOption = () => {
- const focusedOption = flattenedSections.allOptions[focusedIndex];
-
- if (!focusedOption) {
- return;
- }
-
- props.onSelectRow(focusedOption);
- };
-
- useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, {
- captureOnInputs: true,
- shouldBubble: () => !flattenedSections.allOptions[focusedIndex],
- });
-
- return (
- {
- setFocusedIndex(newFocusedIndex);
- scrollToIndex(newFocusedIndex, true);
- }}
- >
-
- {({safeAreaPaddingBottomStyle}) => (
-
- {shouldShowTextInput && (
-
-
-
- )}
- {Boolean(props.headerMessage) && (
-
- {props.headerMessage}
-
- )}
- item.keyForList}
- extraData={focusedIndex}
- indicatorStyle="white"
- keyboardShouldPersistTaps="always"
- showsVerticalScrollIndicator={false}
- OP
- initialNumToRender={12}
- maxToRenderPerBatch={5}
- windowSize={5}
- viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}}
- onLayout={() => {
- if (!firstLayoutRef.current) {
- return;
- }
- scrollToIndex(focusedIndex, false);
- firstLayoutRef.current = false;
- }}
- />
-
- )}
-
-
- );
-}
-
-BaseSelectionListRadio.displayName = 'BaseSelectionListRadio';
-BaseSelectionListRadio.propTypes = propTypes;
-BaseSelectionListRadio.defaultProps = selectionListRadioDefaultProps;
-
-export default withKeyboardState(BaseSelectionListRadio);
diff --git a/src/components/SelectionListRadio/RadioListItem.js b/src/components/SelectionListRadio/RadioListItem.js
deleted file mode 100644
index 615619f928f4..000000000000
--- a/src/components/SelectionListRadio/RadioListItem.js
+++ /dev/null
@@ -1,78 +0,0 @@
-import React from 'react';
-import {View} from 'react-native';
-import PropTypes from 'prop-types';
-import PressableWithFeedback from '../Pressable/PressableWithFeedback';
-import styles from '../../styles/styles';
-import Text from '../Text';
-import Icon from '../Icon';
-import * as Expensicons from '../Icon/Expensicons';
-import themeColors from '../../styles/themes/default';
-import {radioListItemPropTypes} from './selectionListRadioPropTypes';
-
-const propTypes = {
- /** The section list item */
- item: PropTypes.shape(radioListItemPropTypes),
-
- /** Whether this item is focused (for arrow key controls) */
- isFocused: PropTypes.bool,
-
- /** Whether this item is disabled */
- isDisabled: PropTypes.bool,
-
- /** Callback to fire when the item is pressed */
- onSelectRow: PropTypes.func,
-};
-
-const defaultProps = {
- item: {},
- isFocused: false,
- isDisabled: false,
- onSelectRow: () => {},
-};
-
-function RadioListItem(props) {
- return (
- props.onSelectRow(props.item)}
- disabled={props.isDisabled}
- accessibilityLabel={props.item.text}
- accessibilityRole="button"
- hoverDimmingValue={1}
- hoverStyle={styles.hoveredComponentBG}
- >
-
-
-
- {props.item.text}
-
-
- {Boolean(props.item.alternateText) && (
-
- {props.item.alternateText}
-
- )}
-
-
- {props.item.isSelected && (
-
-
-
-
-
- )}
-
-
- );
-}
-
-RadioListItem.displayName = 'RadioListItem';
-RadioListItem.propTypes = propTypes;
-RadioListItem.defaultProps = defaultProps;
-
-export default RadioListItem;
diff --git a/src/components/SelectionListRadio/selectionListRadioPropTypes.js b/src/components/SelectionListRadio/selectionListRadioPropTypes.js
deleted file mode 100644
index 14e41b195d7b..000000000000
--- a/src/components/SelectionListRadio/selectionListRadioPropTypes.js
+++ /dev/null
@@ -1,88 +0,0 @@
-import PropTypes from 'prop-types';
-import CONST from '../../CONST';
-
-const radioListItemPropTypes = {
- /** Text to display */
- text: PropTypes.string,
-
- /** Alternate text to display */
- alternateText: PropTypes.string,
-
- /** Key used internally by React */
- keyForList: PropTypes.string,
-
- /** Whether this option is selected */
- isSelected: PropTypes.bool,
-};
-
-const propTypes = {
- /** Sections for the section list */
- sections: PropTypes.arrayOf(
- PropTypes.shape({
- /** Title of the section */
- title: PropTypes.string,
-
- /** The initial index of this section given the total number of options in each section's data array */
- indexOffset: PropTypes.number,
-
- /** Array of options */
- data: PropTypes.arrayOf(PropTypes.shape(radioListItemPropTypes)),
-
- /** Whether this section items disabled for selection */
- isDisabled: PropTypes.bool,
- }),
- ).isRequired,
-
- /** Callback to fire when a row is tapped */
- onSelectRow: PropTypes.func,
-
- /** Label for the text input */
- textInputLabel: PropTypes.string,
-
- /** Placeholder for the text input */
- textInputPlaceholder: PropTypes.string,
-
- /** Value for the text input */
- textInputValue: PropTypes.string,
-
- /** Max length for the text input */
- textInputMaxLength: PropTypes.number,
-
- /** Callback to fire when the text input changes */
- onChangeText: PropTypes.func,
-
- /** Keyboard type for the text input */
- keyboardType: PropTypes.string,
-
- /** Item `keyForList` to focus initially */
- initiallyFocusedOptionKey: PropTypes.string,
-
- /** Whether to delay focus on the text input when mounting. Used for a smoother animation on Android */
- shouldDelayFocus: PropTypes.bool,
-
- /** Callback to fire when the list is scrolled */
- onScroll: PropTypes.func,
-
- /** Callback to fire when the list is scrolled and the user begins dragging */
- onScrollBeginDrag: PropTypes.func,
-
- /** Message to display at the top of the list */
- headerMessage: PropTypes.string,
-};
-
-const defaultProps = {
- onSelectRow: () => {},
- textInputLabel: '',
- textInputPlaceholder: '',
- textInputValue: '',
- textInputMaxLength: undefined,
- keyboardType: CONST.KEYBOARD_TYPE.DEFAULT,
- onChangeText: () => {},
- initiallyFocusedOptionKey: '',
- shouldDelayFocus: false,
- onScroll: () => {},
- onScrollBeginDrag: () => {},
- headerMessage: '',
-};
-
-export {propTypes, radioListItemPropTypes, defaultProps};
diff --git a/src/components/StatePicker/StateSelectorModal.js b/src/components/StatePicker/StateSelectorModal.js
index a280a14e9614..91ee1b225a1f 100644
--- a/src/components/StatePicker/StateSelectorModal.js
+++ b/src/components/StatePicker/StateSelectorModal.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import CONST from '../../CONST';
import Modal from '../Modal';
import HeaderWithBackButton from '../HeaderWithBackButton';
-import SelectionListRadio from '../SelectionListRadio';
+import SelectionList from '../SelectionList';
import useLocalize from '../../hooks/useLocalize';
import ScreenWrapper from '../ScreenWrapper';
import styles from '../../styles/styles';
@@ -78,7 +78,7 @@ function StateSelectorModal({currentState, isVisible, onClose, onStateSelected,
shouldShowBackButton
onBackButtonPress={onClose}
/>
-
diff --git a/src/components/WorkspaceMembersPlaceholder.js b/src/components/WorkspaceMembersPlaceholder.js
deleted file mode 100644
index 81ba6a51ecb0..000000000000
--- a/src/components/WorkspaceMembersPlaceholder.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from 'react';
-import {View} from 'react-native';
-import PropTypes from 'prop-types';
-import styles from '../styles/styles';
-import Text from './Text';
-import OptionsListSkeletonView from './OptionsListSkeletonView';
-
-const propTypes = {
- isLoaded: PropTypes.bool,
- emptyText: PropTypes.string,
-};
-
-const defaultProps = {
- isLoaded: false,
- emptyText: undefined,
-};
-
-function WorkspaceMembersPlaceholder({isLoaded, emptyText}) {
- return isLoaded && emptyText ? (
-
- {emptyText}
-
- ) : (
-
- );
-}
-
-WorkspaceMembersPlaceholder.displayName = 'WorkspaceMembersPlaceholder';
-WorkspaceMembersPlaceholder.propTypes = propTypes;
-WorkspaceMembersPlaceholder.defaultProps = defaultProps;
-
-export default WorkspaceMembersPlaceholder;
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index a5185d8352aa..ffeaecc12b00 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -149,6 +149,7 @@ function getAvatarsForAccountIDs(accountIDs, personalDetails, defaultValues = {}
return _.map(accountIDs, (accountID) => {
const login = lodashGet(reversedDefaultValues, accountID, '');
const userPersonalDetail = lodashGet(personalDetails, accountID, {login, accountID, avatar: ''});
+
return {
id: accountID,
source: UserUtils.getAvatar(userPersonalDetail.avatar, userPersonalDetail.accountID),
@@ -1015,6 +1016,39 @@ function getShareDestinationOptions(
});
}
+/**
+ * Format personalDetails or userToInvite to be shown in the list
+ *
+ * @param {Object} member - personalDetails or userToInvite
+ * @param {Boolean} isSelected - whether the item is selected
+ * @returns {Object}
+ */
+function formatMemberForList(member, isSelected) {
+ if (!member) {
+ return undefined;
+ }
+
+ const avatarSource = lodashGet(member, 'participantsList[0].avatar', '') || lodashGet(member, 'avatar', '');
+ const accountID = lodashGet(member, 'accountID', '');
+
+ return {
+ text: lodashGet(member, 'text', '') || lodashGet(member, 'displayName', ''),
+ alternateText: lodashGet(member, 'alternateText', '') || lodashGet(member, 'login', ''),
+ keyForList: lodashGet(member, 'keyForList', '') || String(accountID),
+ isSelected,
+ isDisabled: false,
+ accountID,
+ login: lodashGet(member, 'login', ''),
+ rightElement: null,
+ avatar: {
+ source: UserUtils.getAvatar(avatarSource, accountID),
+ name: lodashGet(member, 'participantsList[0].login', '') || lodashGet(member, 'displayName', ''),
+ type: 'avatar',
+ },
+ pendingAction: lodashGet(member, 'pendingAction'),
+ };
+}
+
/**
* Build the options for the Workspace Member Invite view
*
@@ -1104,4 +1138,5 @@ export {
isSearchStringMatch,
shouldOptionShowTooltip,
getLastMessageTextForReport,
+ formatMemberForList,
};
diff --git a/src/pages/settings/Preferences/LanguagePage.js b/src/pages/settings/Preferences/LanguagePage.js
index 7075c998a56d..0eb93853726a 100644
--- a/src/pages/settings/Preferences/LanguagePage.js
+++ b/src/pages/settings/Preferences/LanguagePage.js
@@ -7,7 +7,7 @@ import withLocalize, {withLocalizePropTypes} from '../../../components/withLocal
import * as App from '../../../libs/actions/App';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
-import SelectionListRadio from '../../../components/SelectionListRadio';
+import SelectionList from '../../../components/SelectionList';
const propTypes = {
...withLocalizePropTypes,
@@ -30,7 +30,7 @@ function LanguagePage(props) {
title={props.translate('languagePage.language')}
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_PREFERENCES)}
/>
- App.setLocaleAndNavigate(language.value)}
initiallyFocusedOptionKey={_.find(localesToLanguages, (locale) => locale.isSelected).keyForList}
diff --git a/src/pages/settings/Preferences/PriorityModePage.js b/src/pages/settings/Preferences/PriorityModePage.js
index 17f86c6eb1d8..b32987e242de 100644
--- a/src/pages/settings/Preferences/PriorityModePage.js
+++ b/src/pages/settings/Preferences/PriorityModePage.js
@@ -12,7 +12,7 @@ import * as User from '../../../libs/actions/User';
import CONST from '../../../CONST';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
-import SelectionListRadio from '../../../components/SelectionListRadio';
+import SelectionList from '../../../components/SelectionList';
const propTypes = {
/** The chat priority mode */
@@ -52,7 +52,7 @@ function PriorityModePage(props) {
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_PREFERENCES)}
/>
{props.translate('priorityModePage.explainerText')}
- mode.isSelected).keyForList}
diff --git a/src/pages/settings/Preferences/ThemePage.js b/src/pages/settings/Preferences/ThemePage.js
index b3084e4c909c..a260caa283e3 100644
--- a/src/pages/settings/Preferences/ThemePage.js
+++ b/src/pages/settings/Preferences/ThemePage.js
@@ -7,7 +7,7 @@ import ScreenWrapper from '../../../components/ScreenWrapper';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
-import SelectionListRadio from '../../../components/SelectionListRadio';
+import SelectionList from '../../../components/SelectionList';
import styles from '../../../styles/styles';
import ONYXKEYS from '../../../ONYXKEYS';
import CONST from '../../../CONST';
@@ -45,7 +45,7 @@ function ThemePage(props) {
{props.translate('themePage.chooseThemeBelowOrSync')}
- User.updateTheme(theme.value)}
initiallyFocusedOptionKey={_.find(localesToThemes, (theme) => theme.isSelected).keyForList}
diff --git a/src/pages/settings/Profile/PronounsPage.js b/src/pages/settings/Profile/PronounsPage.js
index 90f934658def..50a231523834 100644
--- a/src/pages/settings/Profile/PronounsPage.js
+++ b/src/pages/settings/Profile/PronounsPage.js
@@ -12,7 +12,7 @@ import compose from '../../../libs/compose';
import CONST from '../../../CONST';
import ROUTES from '../../../ROUTES';
import Navigation from '../../../libs/Navigation/Navigation';
-import SelectionListRadio from '../../../components/SelectionListRadio';
+import SelectionList from '../../../components/SelectionList';
const propTypes = {
...withLocalizePropTypes,
@@ -104,7 +104,7 @@ function PronounsPage(props) {
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_PROFILE)}
/>
{props.translate('pronounsPage.isShownOnProfile')}
- Navigation.goBack(ROUTES.SETTINGS_TIMEZONE)}
/>
- (detailsMap[detail.login] = detail));
+ _.forEach(inviteOptions.personalDetails, (detail) => (detailsMap[detail.login] = OptionsListUtils.formatMemberForList(detail, false)));
const newSelectedOptions = [];
_.forEach(selectedOptions, (option) => {
- newSelectedOptions.push(_.has(detailsMap, option.login) ? detailsMap[option.login] : option);
+ newSelectedOptions.push(_.has(detailsMap, option.login) ? {...detailsMap[option.login], isSelected: true} : option);
});
setUserToInvite(inviteOptions.userToInvite);
@@ -115,20 +114,21 @@ function WorkspaceInvitePage(props) {
// Filtering out selected users from the search results
const selectedLogins = _.map(selectedOptions, ({login}) => login);
const personalDetailsWithoutSelected = _.filter(personalDetails, ({login}) => !_.contains(selectedLogins, login));
+ const personalDetailsFormatted = _.map(personalDetailsWithoutSelected, (personalDetail) => OptionsListUtils.formatMemberForList(personalDetail, false));
const hasUnselectedUserToInvite = userToInvite && !_.contains(selectedLogins, userToInvite.login);
sections.push({
title: translate('common.contacts'),
- data: personalDetailsWithoutSelected,
- shouldShow: !_.isEmpty(personalDetailsWithoutSelected),
+ data: personalDetailsFormatted,
+ shouldShow: !_.isEmpty(personalDetailsFormatted),
indexOffset,
});
- indexOffset += personalDetailsWithoutSelected.length;
+ indexOffset += personalDetailsFormatted.length;
if (hasUnselectedUserToInvite) {
sections.push({
title: undefined,
- data: [userToInvite],
+ data: [OptionsListUtils.formatMemberForList(userToInvite, false)],
shouldShow: true,
indexOffset,
});
@@ -146,7 +146,7 @@ function WorkspaceInvitePage(props) {
if (isOptionInList) {
newSelectedOptions = _.reject(selectedOptions, (selectedOption) => selectedOption.login === option.login);
} else {
- newSelectedOptions = [...selectedOptions, option];
+ newSelectedOptions = [...selectedOptions, {...option, isSelected: true}];
}
setSelectedOptions(newSelectedOptions);
@@ -170,7 +170,7 @@ function WorkspaceInvitePage(props) {
const invitedEmailsToAccountIDs = {};
_.each(selectedOptions, (option) => {
const login = option.login || '';
- const accountID = lodashGet(option, 'participantsList[0].accountID');
+ const accountID = lodashGet(option, 'accountID', '');
if (!login.toLowerCase().trim() || !accountID) {
return;
}
@@ -200,6 +200,7 @@ function WorkspaceInvitePage(props) {
{({didScreenTransitionEnd}) => {
const sections = didScreenTransitionEnd ? getSections() : [];
+
return (
-
-
-
+
{
- // If no search value, we return all members.
- if (_.isEmpty(search)) {
- return policyMembersPersonalDetails;
- }
-
- // We will filter through each policy member details to determine if they should be shown
- return _.filter(policyMembersPersonalDetails, (member) => {
- let memberDetails = '';
- if (member.login) {
- memberDetails += ` ${member.login.toLowerCase()}`;
- }
- if (member.firstName) {
- memberDetails += ` ${member.firstName.toLowerCase()}`;
- }
- if (member.lastName) {
- memberDetails += ` ${member.lastName.toLowerCase()}`;
- }
- if (member.displayName) {
- memberDetails += ` ${member.displayName.toLowerCase()}`;
- }
- if (member.phoneNumber) {
- memberDetails += ` ${member.phoneNumber.toLowerCase()}`;
- }
- return OptionsListUtils.isSearchStringMatch(search, memberDetails);
- });
- };
-
/**
* Open the modal to invite a user
*/
@@ -207,8 +165,16 @@ function WorkspaceMembersPage(props) {
* @param {Object} memberList
*/
const toggleAllUsers = (memberList) => {
- const accountIDList = _.map(_.keys(memberList), (memberAccountID) => Number(memberAccountID));
- setSelectedEmployees((prevSelected) => (!_.every(accountIDList, (memberAccountID) => _.contains(prevSelected, memberAccountID)) ? accountIDList : []));
+ const enabledAccounts = _.filter(memberList, (member) => !member.isDisabled);
+ const everyoneSelected = _.every(enabledAccounts, (member) => _.contains(selectedEmployees, Number(member.keyForList)));
+
+ if (everyoneSelected) {
+ setSelectedEmployees([]);
+ } else {
+ const everyAccountId = _.map(enabledAccounts, (member) => Number(member.keyForList));
+ setSelectedEmployees(everyAccountId);
+ }
+
validateSelection();
};
@@ -253,9 +219,9 @@ function WorkspaceMembersPage(props) {
// Add or remove the user if the checkbox is enabled
if (_.contains(selectedEmployees, Number(accountID))) {
- removeUser(accountID);
+ removeUser(Number(accountID));
} else {
- addUser(accountID);
+ addUser(Number(accountID));
}
},
[selectedEmployees, addUser, removeUser],
@@ -284,226 +250,158 @@ function WorkspaceMembersPage(props) {
* @returns {Boolean}
*/
const isDeletedPolicyMember = (policyMember) => !props.network.isOffline && policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && _.isEmpty(policyMember.errors);
-
- /**
- * Render a workspace member component
- *
- * @param {Object} args
- * @param {Object} args.item
- * @param {Number} args.index
- *
- * @returns {React.Component}
- */
- const renderItem = useCallback(
- ({item}) => {
- const disabled = props.session.email === item.login || item.role === CONST.POLICY.ROLE.ADMIN || item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
- const hasError = !_.isEmpty(item.errors) || errors[item.accountID];
- const isChecked = _.contains(selectedEmployees, Number(item.accountID));
- return (
- dismissError(item)}
- pendingAction={item.pendingAction}
- errors={item.errors}
- >
- toggleUser(item.accountID, item.pendingAction)}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.CHECKBOX}
- accessibilityState={{
- checked: isChecked,
- }}
- accessibilityLabel={props.formatPhoneNumber(item.displayName)}
- hoverDimmingValue={1}
- pressDimmingValue={0.7}
- >
- toggleUser(item.accountID, item.pendingAction)}
- accessibilityLabel={item.displayName}
- />
-
- toggleUser(item.accountID, item.pendingAction)}
- />
-
- {(props.session.accountID === item.accountID || item.role === CONST.POLICY.ROLE.ADMIN) && (
-
- {props.translate('common.admin')}
-
- )}
-
- {!_.isEmpty(errors[item.accountID]) && (
-
- )}
-
- );
- },
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [selectedEmployees, errors, props.session.accountID, dismissError, toggleUser],
- );
-
const policyOwner = lodashGet(props.policy, 'owner');
const currentUserLogin = lodashGet(props.currentUserPersonalDetails, 'login');
- const removableMembers = {};
- let data = [];
- _.each(props.policyMembers, (policyMember, accountID) => {
- if (isDeletedPolicyMember(policyMember)) {
- return;
- }
- const details = props.personalDetails[accountID];
- if (!details) {
- Log.hmmm(`[WorkspaceMembersPage] no personal details found for policy member with accountID: ${accountID}`);
- return;
- }
- data.push({
- ...policyMember,
- ...details,
- });
- });
- data = _.sortBy(data, (value) => value.displayName.toLowerCase());
- data = getMemberOptions(data, searchValue.trim().toLowerCase());
-
- // If this policy is owned by Expensify then show all support (expensify.com or team.expensify.com) emails
- // We don't want to show guides as policy members unless the user is a guide. Some customers get confused when they
- // see random people added to their policy, but guides having access to the policies help set them up.
- if (policyOwner && currentUserLogin && !PolicyUtils.isExpensifyTeam(policyOwner) && !PolicyUtils.isExpensifyTeam(currentUserLogin)) {
- data = _.reject(data, (member) => PolicyUtils.isExpensifyTeam(member.login || member.displayName));
- }
-
- _.each(data, (member) => {
- if (member.accountID === props.session.accountID || member.login === props.policy.owner || member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
- return;
- }
- removableMembers[member.accountID] = member;
- });
const policyID = lodashGet(props.route, 'params.policyID');
const policyName = lodashGet(props.policy, 'name');
+ const getMemberOptions = () => {
+ let result = [];
+
+ _.each(props.policyMembers, (policyMember, accountID) => {
+ if (isDeletedPolicyMember(policyMember)) {
+ return;
+ }
+
+ const details = props.personalDetails[accountID];
+
+ if (!details) {
+ Log.hmmm(`[WorkspaceMembersPage] no personal details found for policy member with accountID: ${accountID}`);
+ return;
+ }
+
+ // If search value is provided, filter out members that don't match the search value
+ if (searchValue.trim()) {
+ let memberDetails = '';
+ if (details.login) {
+ memberDetails += ` ${details.login.toLowerCase()}`;
+ }
+ if (details.firstName) {
+ memberDetails += ` ${details.firstName.toLowerCase()}`;
+ }
+ if (details.lastName) {
+ memberDetails += ` ${details.lastName.toLowerCase()}`;
+ }
+ if (details.displayName) {
+ memberDetails += ` ${details.displayName.toLowerCase()}`;
+ }
+ if (details.phoneNumber) {
+ memberDetails += ` ${details.phoneNumber.toLowerCase()}`;
+ }
+
+ if (!OptionsListUtils.isSearchStringMatch(searchValue.trim(), memberDetails)) {
+ return;
+ }
+ }
+
+ // If this policy is owned by Expensify then show all support (expensify.com or team.expensify.com) emails
+ // We don't want to show guides as policy members unless the user is a guide. Some customers get confused when they
+ // see random people added to their policy, but guides having access to the policies help set them up.
+ if (PolicyUtils.isExpensifyTeam(details.login || details.displayName)) {
+ if (policyOwner && currentUserLogin && !PolicyUtils.isExpensifyTeam(policyOwner) && !PolicyUtils.isExpensifyTeam(currentUserLogin)) {
+ return;
+ }
+ }
+
+ const isAdmin = props.session.email === details.login || policyMember.role === CONST.POLICY.ROLE.ADMIN;
+
+ result.push({
+ keyForList: accountID,
+ isSelected: _.contains(selectedEmployees, Number(accountID)),
+ isDisabled: accountID === props.session.accountID || details.login === props.policy.owner || policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
+ text: props.formatPhoneNumber(details.displayName),
+ alternateText: props.formatPhoneNumber(details.login),
+ rightElement: isAdmin ? (
+
+ {props.translate('common.admin')}
+
+ ) : null,
+ avatar: {
+ source: UserUtils.getAvatar(details.avatar, accountID),
+ name: details.login,
+ type: CONST.ICON_TYPE_AVATAR,
+ },
+ errors: policyMember.errors,
+ pendingAction: policyMember.pendingAction,
+ });
+ });
+
+ result = _.sortBy(result, (value) => value.text.toLowerCase());
+
+ return result;
+ };
+
+ const data = getMemberOptions();
+ const headerMessage = searchValue.trim() && !data.length ? props.translate('workspace.common.memberNotFound') : '';
+
return (
- {({safeAreaPaddingBottomStyle}) => (
- Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
- >
- {
- setSearchValue('');
- Navigation.goBack(ROUTES.getWorkspaceInitialRoute(policyID));
- }}
- shouldShowGetAssistanceButton
- guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS}
- />
- setRemoveMembersConfirmModalVisible(false)}
- prompt={props.translate('workspace.people.removeMembersPrompt')}
- confirmText={props.translate('common.remove')}
- cancelText={props.translate('common.cancel')}
- />
-
-
-
-
-
-
-
-
- {data.length > 0 ? (
-
-
- toggleAllUsers(removableMembers)}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.CHECKBOX}
- accessibilityState={{
- checked: !_.isEmpty(removableMembers) && _.every(_.keys(removableMembers), (accountID) => _.contains(selectedEmployees, Number(accountID))),
- }}
- accessibilityLabel={props.translate('workspace.people.selectAll')}
- hoverDimmingValue={1}
- pressDimmingValue={0.7}
- >
- _.contains(selectedEmployees, Number(accountID)))}
- onPress={() => toggleAllUsers(removableMembers)}
- accessibilityLabel={props.translate('workspace.people.selectAll')}
- />
-
-
- {props.translate('workspace.people.selectAll')}
-
-
- item.login}
- showsVerticalScrollIndicator
- style={[styles.ph5, styles.pb5]}
- contentContainerStyle={safeAreaPaddingBottomStyle}
- keyboardShouldPersistTaps="handled"
- />
-
- ) : (
-
-
-
- )}
+ Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
+ >
+ {
+ setSearchValue('');
+ Navigation.goBack(ROUTES.getWorkspaceInitialRoute(policyID));
+ }}
+ shouldShowGetAssistanceButton
+ guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS}
+ />
+ setRemoveMembersConfirmModalVisible(false)}
+ prompt={props.translate('workspace.people.removeMembersPrompt')}
+ confirmText={props.translate('common.remove')}
+ cancelText={props.translate('common.cancel')}
+ />
+
+
+
+
+
+
+ toggleUser(item.keyForList)}
+ onSelectAll={() => toggleAllUsers(data)}
+ onDismissError={dismissError}
+ showLoadingPlaceholder={!OptionsListUtils.isPersonalDetailsReady(props.personalDetails) || _.isEmpty(props.policyMembers)}
+ initiallyFocusedOptionKey={lodashGet(
+ _.find(data, (item) => !item.isDisabled),
+ 'keyForList',
+ undefined,
+ )}
+ />
-
- )}
+
+
);
}
diff --git a/src/stories/SelectionList.stories.js b/src/stories/SelectionList.stories.js
new file mode 100644
index 000000000000..c4510611306e
--- /dev/null
+++ b/src/stories/SelectionList.stories.js
@@ -0,0 +1,397 @@
+import React, {useMemo, useState} from 'react';
+import _ from 'underscore';
+import {View} from 'react-native';
+import SelectionList from '../components/SelectionList';
+import CONST from '../CONST';
+import styles from '../styles/styles';
+import Text from '../components/Text';
+
+/**
+ * We use the Component Story Format for writing stories. Follow the docs here:
+ *
+ * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format
+ */
+const story = {
+ title: 'Components/SelectionList',
+ component: SelectionList,
+};
+
+const SECTIONS = [
+ {
+ data: [
+ {
+ text: 'Option 1',
+ keyForList: 'option-1',
+ isSelected: false,
+ },
+ {
+ text: 'Option 2',
+ keyForList: 'option-2',
+ isSelected: false,
+ },
+ {
+ text: 'Option 3',
+ keyForList: 'option-3',
+ isSelected: false,
+ },
+ ],
+ indexOffset: 0,
+ isDisabled: false,
+ },
+ {
+ data: [
+ {
+ text: 'Option 4',
+ keyForList: 'option-4',
+ isSelected: false,
+ },
+ {
+ text: 'Option 5',
+ keyForList: 'option-5',
+ isSelected: false,
+ },
+ {
+ text: 'Option 6',
+ keyForList: 'option-6',
+ isSelected: false,
+ },
+ ],
+ indexOffset: 3,
+ isDisabled: false,
+ },
+];
+
+function Default(args) {
+ const [selectedIndex, setSelectedIndex] = useState(1);
+
+ const sections = _.map(args.sections, (section) => {
+ const data = _.map(section.data, (item, index) => {
+ const isSelected = selectedIndex === index + section.indexOffset;
+ return {...item, isSelected};
+ });
+
+ return {...section, data};
+ });
+
+ const onSelectRow = (item) => {
+ _.forEach(sections, (section) => {
+ const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
+
+ if (newSelectedIndex >= 0) {
+ setSelectedIndex(newSelectedIndex + section.indexOffset);
+ }
+ });
+ };
+
+ return (
+
+ );
+}
+
+Default.args = {
+ sections: SECTIONS,
+ onSelectRow: () => {},
+ initiallyFocusedOptionKey: 'option-2',
+};
+
+function WithTextInput(args) {
+ const [searchText, setSearchText] = useState('');
+ const [selectedIndex, setSelectedIndex] = useState(1);
+
+ const sections = _.map(args.sections, (section) => {
+ const data = _.reduce(
+ section.data,
+ (memo, item, index) => {
+ if (!item.text.toLowerCase().includes(searchText.trim().toLowerCase())) {
+ return memo;
+ }
+
+ const isSelected = selectedIndex === index + section.indexOffset;
+ memo.push({...item, isSelected});
+ return memo;
+ },
+ [],
+ );
+
+ return {...section, data};
+ });
+
+ const onSelectRow = (item) => {
+ _.forEach(sections, (section) => {
+ const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
+
+ if (newSelectedIndex >= 0) {
+ setSelectedIndex(newSelectedIndex + section.indexOffset);
+ }
+ });
+ };
+
+ return (
+
+ );
+}
+
+WithTextInput.args = {
+ sections: SECTIONS,
+ textInputLabel: 'Option list',
+ textInputPlaceholder: 'Search something...',
+ textInputMaxLength: 4,
+ keyboardType: CONST.KEYBOARD_TYPE.NUMBER_PAD,
+ initiallyFocusedOptionKey: 'option-2',
+ onSelectRow: () => {},
+ onChangeText: () => {},
+};
+
+function WithHeaderMessage(props) {
+ return (
+
+ );
+}
+
+WithHeaderMessage.args = {
+ ...WithTextInput.args,
+ headerMessage: 'No results found',
+ sections: [],
+};
+
+function WithAlternateText(args) {
+ const [selectedIndex, setSelectedIndex] = useState(1);
+
+ const sections = _.map(args.sections, (section) => {
+ const data = _.map(section.data, (item, index) => {
+ const isSelected = selectedIndex === index + section.indexOffset;
+
+ return {
+ ...item,
+ alternateText: `Alternate ${index + 1}`,
+ isSelected,
+ };
+ });
+
+ return {...section, data};
+ });
+
+ const onSelectRow = (item) => {
+ _.forEach(sections, (section) => {
+ const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
+
+ if (newSelectedIndex >= 0) {
+ setSelectedIndex(newSelectedIndex + section.indexOffset);
+ }
+ });
+ };
+ return (
+
+ );
+}
+
+WithAlternateText.args = {
+ ...Default.args,
+};
+
+function MultipleSelection(args) {
+ const [selectedIds, setSelectedIds] = useState(['option-1', 'option-2']);
+
+ const memo = useMemo(() => {
+ const allIds = [];
+
+ const sections = _.map(args.sections, (section) => {
+ const data = _.map(section.data, (item, index) => {
+ allIds.push(item.keyForList);
+ const isSelected = _.contains(selectedIds, item.keyForList);
+ const isAdmin = index + section.indexOffset === 0;
+
+ return {
+ ...item,
+ isSelected,
+ alternateText: `${item.keyForList}@email.com`,
+ accountID: item.keyForList,
+ login: item.text,
+ rightElement: isAdmin && (
+
+ Admin
+
+ ),
+ };
+ });
+
+ return {...section, data};
+ });
+
+ return {sections, allIds};
+ }, [args.sections, selectedIds]);
+
+ const onSelectRow = (item) => {
+ const newSelectedIds = _.contains(selectedIds, item.keyForList) ? _.without(selectedIds, item.keyForList) : [...selectedIds, item.keyForList];
+ setSelectedIds(newSelectedIds);
+ };
+
+ const onSelectAll = () => {
+ if (selectedIds.length === memo.allIds.length) {
+ setSelectedIds([]);
+ } else {
+ setSelectedIds(memo.allIds);
+ }
+ };
+
+ return (
+
+ );
+}
+
+MultipleSelection.args = {
+ ...Default.args,
+ canSelectMultiple: true,
+ onSelectAll: () => {},
+};
+
+function WithSectionHeader(args) {
+ const [selectedIds, setSelectedIds] = useState(['option-1', 'option-2']);
+
+ const memo = useMemo(() => {
+ const allIds = [];
+
+ const sections = _.map(args.sections, (section, sectionIndex) => {
+ const data = _.map(section.data, (item, itemIndex) => {
+ allIds.push(item.keyForList);
+ const isSelected = _.contains(selectedIds, item.keyForList);
+ const isAdmin = itemIndex + section.indexOffset === 0;
+
+ return {
+ ...item,
+ isSelected,
+ alternateText: `${item.keyForList}@email.com`,
+ accountID: item.keyForList,
+ login: item.text,
+ rightElement: isAdmin && (
+
+ Admin
+
+ ),
+ };
+ });
+
+ return {...section, data, title: `Section ${sectionIndex + 1}`};
+ });
+
+ return {sections, allIds};
+ }, [args.sections, selectedIds]);
+
+ const onSelectRow = (item) => {
+ const newSelectedIds = _.contains(selectedIds, item.keyForList) ? _.without(selectedIds, item.keyForList) : [...selectedIds, item.keyForList];
+ setSelectedIds(newSelectedIds);
+ };
+
+ const onSelectAll = () => {
+ if (selectedIds.length === memo.allIds.length) {
+ setSelectedIds([]);
+ } else {
+ setSelectedIds(memo.allIds);
+ }
+ };
+
+ return (
+
+ );
+}
+
+WithSectionHeader.args = {
+ ...MultipleSelection.args,
+};
+
+function WithConfirmButton(args) {
+ const [selectedIds, setSelectedIds] = useState(['option-1', 'option-2']);
+
+ const memo = useMemo(() => {
+ const allIds = [];
+
+ const sections = _.map(args.sections, (section, sectionIndex) => {
+ const data = _.map(section.data, (item, itemIndex) => {
+ allIds.push(item.keyForList);
+ const isSelected = _.contains(selectedIds, item.keyForList);
+ const isAdmin = itemIndex + section.indexOffset === 0;
+
+ return {
+ ...item,
+ isSelected,
+ alternateText: `${item.keyForList}@email.com`,
+ accountID: item.keyForList,
+ login: item.text,
+ rightElement: isAdmin && (
+
+ Admin
+
+ ),
+ };
+ });
+
+ return {...section, data, title: `Section ${sectionIndex + 1}`};
+ });
+
+ return {sections, allIds};
+ }, [args.sections, selectedIds]);
+
+ const onSelectRow = (item) => {
+ const newSelectedIds = _.contains(selectedIds, item.keyForList) ? _.without(selectedIds, item.keyForList) : [...selectedIds, item.keyForList];
+ setSelectedIds(newSelectedIds);
+ };
+
+ const onSelectAll = () => {
+ if (selectedIds.length === memo.allIds.length) {
+ setSelectedIds([]);
+ } else {
+ setSelectedIds(memo.allIds);
+ }
+ };
+
+ return (
+
+ );
+}
+
+WithConfirmButton.args = {
+ ...MultipleSelection.args,
+ onConfirm: () => {},
+ confirmButtonText: 'Confirm',
+};
+
+export {Default, WithTextInput, WithHeaderMessage, WithAlternateText, MultipleSelection, WithSectionHeader, WithConfirmButton};
+export default story;
diff --git a/src/stories/SelectionListRadio.stories.js b/src/stories/SelectionListRadio.stories.js
deleted file mode 100644
index 698e4743f25a..000000000000
--- a/src/stories/SelectionListRadio.stories.js
+++ /dev/null
@@ -1,207 +0,0 @@
-import React, {useState} from 'react';
-import _ from 'underscore';
-import SelectionListRadio from '../components/SelectionListRadio';
-import CONST from '../CONST';
-
-/**
- * We use the Component Story Format for writing stories. Follow the docs here:
- *
- * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format
- */
-const story = {
- title: 'Components/SelectionListRadio',
- component: SelectionListRadio,
-};
-
-const SECTIONS = [
- {
- data: [
- {
- text: 'Option 1',
- keyForList: 'option-1',
- isSelected: false,
- },
- {
- text: 'Option 2',
- keyForList: 'option-2',
- isSelected: false,
- },
- {
- text: 'Option 3',
- keyForList: 'option-3',
- isSelected: false,
- },
- ],
- indexOffset: 0,
- isDisabled: false,
- },
- {
- data: [
- {
- text: 'Option 4',
- keyForList: 'option-4',
- isSelected: false,
- },
- {
- text: 'Option 5',
- keyForList: 'option-5',
- isSelected: false,
- },
- {
- text: 'Option 6',
- keyForList: 'option-6',
- isSelected: false,
- },
- ],
- indexOffset: 3,
- isDisabled: false,
- },
-];
-
-function Default(args) {
- const [selectedIndex, setSelectedIndex] = useState(1);
-
- const sections = _.map(args.sections, (section) => {
- const data = _.map(section.data, (item, index) => {
- const isSelected = selectedIndex === index + section.indexOffset;
- return {...item, isSelected};
- });
-
- return {...section, data};
- });
-
- const onSelectRow = (item) => {
- _.forEach(sections, (section) => {
- const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
-
- if (newSelectedIndex >= 0) {
- setSelectedIndex(newSelectedIndex + section.indexOffset);
- }
- });
- };
-
- return (
-
- );
-}
-
-Default.args = {
- sections: SECTIONS,
- initiallyFocusedOptionKey: 'option-2',
-};
-
-function WithTextInput(args) {
- const [searchText, setSearchText] = useState('');
- const [selectedIndex, setSelectedIndex] = useState(1);
-
- const sections = _.map(args.sections, (section) => {
- const data = _.reduce(
- section.data,
- (memo, item, index) => {
- if (!item.text.toLowerCase().includes(searchText.trim().toLowerCase())) {
- return memo;
- }
-
- const isSelected = selectedIndex === index + section.indexOffset;
- memo.push({...item, isSelected});
- return memo;
- },
- [],
- );
-
- return {...section, data};
- });
-
- const onSelectRow = (item) => {
- _.forEach(sections, (section) => {
- const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
-
- if (newSelectedIndex >= 0) {
- setSelectedIndex(newSelectedIndex + section.indexOffset);
- }
- });
- };
-
- return (
-
- );
-}
-
-WithTextInput.args = {
- sections: SECTIONS,
- textInputLabel: 'Option list',
- textInputPlaceholder: 'Search something...',
- textInputMaxLength: 4,
- keyboardType: CONST.KEYBOARD_TYPE.NUMBER_PAD,
- initiallyFocusedOptionKey: 'option-2',
-};
-
-function WithHeaderMessage(props) {
- return (
-
- );
-}
-
-WithHeaderMessage.args = {
- ...WithTextInput.args,
- headerMessage: 'No results found',
- sections: [],
-};
-
-function WithAlternateText(args) {
- const [selectedIndex, setSelectedIndex] = useState(1);
-
- const sections = _.map(args.sections, (section) => {
- const data = _.map(section.data, (item, index) => {
- const isSelected = selectedIndex === index + section.indexOffset;
-
- return {
- ...item,
- alternateText: `Alternate ${index + 1}`,
- isSelected,
- };
- });
-
- return {...section, data};
- });
-
- const onSelectRow = (item) => {
- _.forEach(sections, (section) => {
- const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
-
- if (newSelectedIndex >= 0) {
- setSelectedIndex(newSelectedIndex + section.indexOffset);
- }
- });
- };
- return (
-
- );
-}
-
-WithAlternateText.args = {
- ...Default.args,
-};
-
-export {Default, WithTextInput, WithHeaderMessage, WithAlternateText};
-export default story;
diff --git a/src/styles/styles.js b/src/styles/styles.js
index 03ff84eb0665..05ca2679529e 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -2912,7 +2912,7 @@ const styles = {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
- ...spacing.pt2,
+ ...spacing.ph5,
},
peopleRowBorderBottom: {
diff --git a/src/styles/utilities/spacing.js b/src/styles/utilities/spacing.js
index 4adb7e79a9ff..e4254102df9b 100644
--- a/src/styles/utilities/spacing.js
+++ b/src/styles/utilities/spacing.js
@@ -399,6 +399,10 @@ export default {
paddingLeft: 8,
},
+ pl3: {
+ paddingLeft: 12,
+ },
+
pl5: {
paddingLeft: 20,
},
diff --git a/tests/perf-test/SelectionList.perf-test.js b/tests/perf-test/SelectionList.perf-test.js
new file mode 100644
index 000000000000..82cec956713f
--- /dev/null
+++ b/tests/perf-test/SelectionList.perf-test.js
@@ -0,0 +1,120 @@
+import React, {useState} from 'react';
+import {measurePerformance} from 'reassure';
+import {fireEvent} from '@testing-library/react-native';
+import _ from 'underscore';
+import SelectionList from '../../src/components/SelectionList';
+import variables from '../../src/styles/variables';
+
+jest.mock('../../src/components/Icon/Expensicons');
+
+jest.mock('../../src/hooks/useLocalize', () =>
+ jest.fn(() => ({
+ translate: jest.fn(),
+ })),
+);
+
+jest.mock('../../src/components/withLocalize', () => (Component) => (props) => (
+ ''}
+ />
+));
+
+jest.mock('../../src/components/withKeyboardState', () => (Component) => (props) => (
+
+));
+
+function SelectionListWrapper(args) {
+ const [selectedIds, setSelectedIds] = useState([]);
+
+ const sections = [
+ {
+ data: Array.from({length: 1000}, (__, i) => ({
+ text: `Item ${i}`,
+ keyForList: `item-${i}`,
+ isSelected: _.contains(selectedIds, `item-${i}`),
+ })),
+ indexOffset: 0,
+ isDisabled: false,
+ },
+ ];
+
+ const onSelectRow = (item) => {
+ if (args.canSelectMultiple) {
+ if (_.contains(selectedIds, item.keyForList)) {
+ setSelectedIds(_.without(selectedIds, item.keyForList));
+ } else {
+ setSelectedIds([...selectedIds, item.keyForList]);
+ }
+ } else {
+ setSelectedIds([item.keyForList]);
+ }
+ };
+
+ return (
+
+ );
+}
+
+test('should render 1 section and a thousand items', () => {
+ measurePerformance();
+});
+
+test('should press a list item', () => {
+ const scenario = (screen) => {
+ fireEvent.press(screen.getByText('Item 5'));
+ };
+
+ measurePerformance(, {scenario});
+});
+
+test('should render multiple selection and select 3 items', () => {
+ const scenario = (screen) => {
+ fireEvent.press(screen.getByText('Item 1'));
+ fireEvent.press(screen.getByText('Item 2'));
+ fireEvent.press(screen.getByText('Item 3'));
+ };
+
+ measurePerformance(, {scenario});
+});
+
+test('should scroll and select a few items', () => {
+ const eventData = {
+ nativeEvent: {
+ contentOffset: {
+ y: variables.optionRowHeight * 5,
+ },
+ contentSize: {
+ // Dimensions of the scrollable content
+ height: variables.optionRowHeight * 10,
+ width: 100,
+ },
+ layoutMeasurement: {
+ // Dimensions of the device
+ height: variables.optionRowHeight * 5,
+ width: 100,
+ },
+ },
+ };
+
+ const scenario = (screen) => {
+ fireEvent.press(screen.getByText('Item 1'));
+ fireEvent.scroll(screen.getByTestId('selection-list'), eventData);
+ fireEvent.press(screen.getByText('Item 7'));
+ fireEvent.press(screen.getByText('Item 15'));
+ };
+
+ measurePerformance(, {scenario});
+});
diff --git a/tests/perf-test/SelectionListRadio.perf-test.js b/tests/perf-test/SelectionListRadio.perf-test.js
deleted file mode 100644
index b0f6d7aa1d4a..000000000000
--- a/tests/perf-test/SelectionListRadio.perf-test.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import React, {useState} from 'react';
-import {measurePerformance} from 'reassure';
-import {fireEvent} from '@testing-library/react-native';
-import SelectionListRadio from '../../src/components/SelectionListRadio';
-
-jest.mock('../../src/components/Icon/Expensicons');
-
-function SelectionListRadioWrapper() {
- const [selectedIndex, setSelectedIndex] = useState(0);
-
- const sections = [
- {
- data: Array.from({length: 1000}, (__, i) => ({
- text: `Item ${i}`,
- keyForList: `item-${i}`,
- isSelected: selectedIndex === i,
- })),
- indexOffset: 0,
- isDisabled: false,
- },
- ];
-
- const onSelectRow = (item) => {
- const index = Number(item.keyForList.split('-')[1]);
- setSelectedIndex(index);
- };
-
- return (
-
- );
-}
-
-test('should render SelectionListRadio with 1 section and a thousand items', () => {
- measurePerformance();
-});
-
-test('should press a list item', () => {
- const scenario = (screen) => {
- fireEvent.press(screen.getByText('Item 5'));
- };
-
- measurePerformance(, {scenario});
-});
diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js
index 1c176bdb1ce4..1c143aa7c323 100644
--- a/tests/unit/OptionsListUtilsTest.js
+++ b/tests/unit/OptionsListUtilsTest.js
@@ -628,4 +628,28 @@ describe('OptionsListUtils', () => {
expect(results.personalDetails.length).toBe(1);
expect(results.personalDetails[0].text).toBe('Spider-Man');
});
+
+ it('formatMemberForList()', () => {
+ const formattedMembers = _.map(PERSONAL_DETAILS, (personalDetail, key) => OptionsListUtils.formatMemberForList(personalDetail, key === '1'));
+
+ // We're only formatting items inside the array, so the order should be the same as the original PERSONAL_DETAILS array
+ expect(formattedMembers[0].text).toBe('Mister Fantastic');
+ expect(formattedMembers[1].text).toBe('Iron Man');
+ expect(formattedMembers[2].text).toBe('Spider-Man');
+
+ // We should expect only the first item to be selected
+ expect(formattedMembers[0].isSelected).toBe(true);
+
+ // And all the others to be unselected
+ expect(_.every(formattedMembers.slice(1), (personalDetail) => !personalDetail.isSelected)).toBe(true);
+
+ // `isDisabled` is always false
+ expect(_.every(formattedMembers, (personalDetail) => !personalDetail.isDisabled)).toBe(true);
+
+ // `rightElement` is always null
+ expect(_.every(formattedMembers, (personalDetail) => personalDetail.rightElement === null)).toBe(true);
+
+ // The PERSONAL_DETAILS list doesn't specify `participantsList[n].avatar`, so the default one should be used
+ expect(_.every(formattedMembers, (personalDetail) => Boolean(personalDetail.avatar.source))).toBe(true);
+ });
});