diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b682da76a5bb..2f5ff2faa3ea 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3899,7 +3899,7 @@ const CONST = { // Character Limits FORM_CHARACTER_LIMIT: 50, STANDARD_LENGTH_LIMIT: 100, - STANDARD_LIST_ITEM_LIMIT: 8, + STANDARD_LIST_ITEM_LIMIT: 12, LEGAL_NAMES_CHARACTER_LIMIT: 150, LOGIN_CHARACTER_LIMIT: 254, CATEGORY_NAME_LIMIT: 256, diff --git a/src/components/Search/FilterDropdowns/MultiSelectPopup.tsx b/src/components/Search/FilterDropdowns/MultiSelectPopup.tsx index e582e35ce5f1..0b0d10faf51d 100644 --- a/src/components/Search/FilterDropdowns/MultiSelectPopup.tsx +++ b/src/components/Search/FilterDropdowns/MultiSelectPopup.tsx @@ -5,14 +5,17 @@ import SelectionList from '@components/SelectionList'; import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; import type {ListItem} from '@components/SelectionList/ListItem/types'; import Text from '@components/Text'; +import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import type {Icon} from '@src/types/onyx/OnyxCommon'; type MultiSelectItem = { text: string; value: T; + icons?: Icon[]; }; type MultiSelectPopupProps = { @@ -30,23 +33,34 @@ type MultiSelectPopupProps = { /** Function to call when changes are applied */ onChange: (item: Array>) => void; + + /** Whether the search input should be displayed. */ + isSearchable?: boolean; + + /** Search input placeholder. Defaults to 'common.search' when not provided. */ + searchPlaceholder?: string; }; -function MultiSelectPopup({label, value, items, closeOverlay, onChange}: MultiSelectPopupProps) { +function MultiSelectPopup({label, value, items, closeOverlay, onChange, isSearchable, searchPlaceholder}: MultiSelectPopupProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); const {windowHeight} = useWindowDimensions(); const [selectedItems, setSelectedItems] = useState(value); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const listData: ListItem[] = useMemo(() => { - return items.map((item) => ({ + const filteredItems = isSearchable ? items.filter((item) => item.text.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) : items; + return filteredItems.map((item) => ({ text: item.text, keyForList: item.value, isSelected: !!selectedItems.find((i) => i.value === item.value), + icons: item.icons, })); - }, [items, selectedItems]); + }, [items, selectedItems, isSearchable, debouncedSearchTerm]); + + const headerMessage = isSearchable && listData.length === 0 ? translate('common.noResultsFound') : undefined; const updateSelectedItems = useCallback( (item: ListItem) => { @@ -74,16 +88,27 @@ function MultiSelectPopup({label, value, items, closeOverlay, closeOverlay(); }, [closeOverlay, onChange]); + const textInputOptions = useMemo( + () => ({ + value: searchTerm, + label: isSearchable ? (searchPlaceholder ?? translate('common.search')) : undefined, + onChangeText: setSearchTerm, + headerMessage, + }), + [searchTerm, isSearchable, searchPlaceholder, translate, setSearchTerm, headerMessage], + ); + return ( {isSmallScreenWidth && {label}} - + diff --git a/src/components/Search/FilterDropdowns/UserSelectPopup.tsx b/src/components/Search/FilterDropdowns/UserSelectPopup.tsx index 4673d1b616f5..5fb13d77c01f 100644 --- a/src/components/Search/FilterDropdowns/UserSelectPopup.tsx +++ b/src/components/Search/FilterDropdowns/UserSelectPopup.tsx @@ -42,9 +42,16 @@ type UserSelectPopupProps = { /** Function to call when changes are applied */ onChange: (value: string[]) => void; + + /** + * Whether the search input should be displayed. + * When undefined, defaults to showing search when user count >= CONST.STANDARD_LIST_ITEM_LIMIT (12 users). + * Set to true to always show search, or false to never show search regardless of user count. + */ + isSearchable?: boolean; }; -function UserSelectPopup({value, closeOverlay, onChange}: UserSelectPopupProps) { +function UserSelectPopup({value, closeOverlay, onChange, isSearchable}: UserSelectPopupProps) { const selectionListRef = useRef(null); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -171,21 +178,25 @@ function UserSelectPopup({value, closeOverlay, onChange}: UserSelectPopupProps) }, [closeOverlay, onChange]); const isLoadingNewOptions = !!isSearchingForReports; - const dataLength = listData.length; + const totalOptionsCount = optionsList.personalDetails.length + optionsList.recentReports.length; + const shouldShowSearchInput = isSearchable ?? totalOptionsCount >= CONST.STANDARD_LIST_ITEM_LIMIT; const textInputOptions = useMemo( - () => ({ - value: searchTerm, - label: translate('selectionList.searchForSomeone'), - onChangeText: setSearchTerm, - headerMessage, - disableAutoFocus: !shouldFocusInputOnScreenFocus, - }), - [searchTerm, translate, headerMessage, shouldFocusInputOnScreenFocus], + () => + shouldShowSearchInput + ? { + value: searchTerm, + label: translate('selectionList.searchForSomeone'), + onChangeText: setSearchTerm, + headerMessage, + disableAutoFocus: !shouldFocusInputOnScreenFocus, + } + : undefined, + [searchTerm, translate, headerMessage, shouldFocusInputOnScreenFocus, shouldShowSearchInput], ); return ( - + >>(() => { return workspaces .flatMap((section) => section.data) - .filter((workspace): workspace is typeof workspace & {policyID: string} => !!workspace.policyID) + .filter((workspace): workspace is typeof workspace & {policyID: string; icons: Icon[]} => !!workspace.policyID && !!workspace.icons) .map((workspace) => ({ text: workspace.text, value: workspace.policyID, + icons: workspace.icons, })); }, [workspaces]); @@ -424,6 +426,7 @@ function SearchFiltersBar({ items: Array>, value: Array>, onChangeCallback: (selectedItems: Array>) => void, + isSearchable?: boolean, ) => { return ({closeOverlay}: PopoverComponentProps) => { return ( @@ -433,6 +436,7 @@ function SearchFiltersBar({ value={value} closeOverlay={closeOverlay} onChange={onChangeCallback} + isSearchable={isSearchable} /> ); }; @@ -508,12 +512,28 @@ function SearchFiltersBar({ [filterFormValues.from, updateFilterForm], ); - const workspaceComponent = useMemo(() => { - const updateWorkspaceFilterForm = (items: Array>) => { + const handleWorkspaceChange = useCallback( + (items: Array>) => { updateFilterForm({policyID: items.map((item) => item.value)}); - }; - return createMultiSelectComponent('workspace.common.workspace', workspaceOptions, selectedWorkspaceOptions, updateWorkspaceFilterForm); - }, [createMultiSelectComponent, workspaceOptions, selectedWorkspaceOptions, updateFilterForm]); + }, + [updateFilterForm], + ); + + const workspaceComponent = useCallback( + ({closeOverlay}: PopoverComponentProps) => { + return ( + + ); + }, + [workspaceOptions, selectedWorkspaceOptions, handleWorkspaceChange, shouldShowWorkspaceSearchInput, translate], + ); const workspaceValue = useMemo(() => selectedWorkspaceOptions.map((option) => option.text), [selectedWorkspaceOptions]); diff --git a/src/components/SelectionList/ListItem/MultiSelectListItem.tsx b/src/components/SelectionList/ListItem/MultiSelectListItem.tsx index 0e49869bae0b..965d7e69f3ad 100644 --- a/src/components/SelectionList/ListItem/MultiSelectListItem.tsx +++ b/src/components/SelectionList/ListItem/MultiSelectListItem.tsx @@ -1,12 +1,15 @@ -import React, {useCallback} from 'react'; +import React, {useCallback, useMemo} from 'react'; +import {View} from 'react-native'; +import Avatar from '@components/Avatar'; import Checkbox from '@components/Checkbox'; import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; import RadioListItem from './RadioListItem'; import type {ListItem, MultiSelectListItemProps} from './types'; /** - * MultiSelectListItem mirrors the behavior of a default RadioListItem, but adds support - * for the new style of multi selection lists. + * MultiSelectListItem extends RadioListItem with multi-selection support. + * Renders an avatar when icons are provided. */ function MultiSelectListItem({ item, @@ -25,6 +28,7 @@ function MultiSelectListItem({ titleStyles, }: MultiSelectListItemProps) { const styles = useThemeStyles(); + const icon = item.icons?.at(0); const checkboxComponent = useCallback(() => { return ( @@ -37,9 +41,36 @@ function MultiSelectListItem({ ); }, [item, onSelectRow]); + const {itemWithAvatar, computedWrapperStyle} = useMemo(() => { + if (!icon) { + return { + itemWithAvatar: item, + computedWrapperStyle: [wrapperStyle, styles.optionRowCompact], + }; + } + + const avatarElement = ( + + + + ); + + return { + itemWithAvatar: {...item, leftElement: avatarElement}, + computedWrapperStyle: [wrapperStyle, styles.pv0, styles.mnh13], + }; + }, [icon, item, wrapperStyle, styles.mentionSuggestionsAvatarContainer, styles.mr3, styles.optionRowCompact, styles.pv0, styles.mnh13]); + return ( ({ alternateTextNumberOfLines={alternateTextNumberOfLines} onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} - wrapperStyle={[wrapperStyle, styles.optionRowCompact]} + wrapperStyle={computedWrapperStyle} titleStyles={titleStyles} /> ); diff --git a/src/pages/ReportParticipantsPage.tsx b/src/pages/ReportParticipantsPage.tsx index 543c66f37961..f703f7e22840 100755 --- a/src/pages/ReportParticipantsPage.tsx +++ b/src/pages/ReportParticipantsPage.tsx @@ -124,7 +124,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) { return !pendingMember || isOffline || pendingMember.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; }); - // Include the search bar when there are 8 or more active members in the selection list + // Include the search bar when there are STANDARD_LIST_ITEM_LIMIT or more active members in the selection list const shouldShowTextInput = activeParticipants.length >= CONST.STANDARD_LIST_ITEM_LIMIT; useEffect(() => { @@ -417,6 +417,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) { Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID, backTo)); } }} + // eslint-disable-next-line @typescript-eslint/no-deprecated subtitle={StringUtils.lineBreaksToSpaces(getReportName(report, reportAttributes))} /> {headerButtons} diff --git a/src/pages/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx index eb948aba0348..ed3315078026 100644 --- a/src/pages/RoomMembersPage.tsx +++ b/src/pages/RoomMembersPage.tsx @@ -200,7 +200,7 @@ function RoomMembersPage({report, policy}: RoomMembersPageProps) { } }; - /** Include the search bar when there are 8 or more active members in the selection list */ + /** Include the search bar when there are STANDARD_LIST_ITEM_LIMIT or more active members in the selection list */ const shouldShowTextInput = useMemo(() => { // Get the active chat members by filtering out the pending members with delete action const activeParticipants = participants.filter((accountID) => { @@ -403,6 +403,7 @@ function RoomMembersPage({report, policy}: RoomMembersPageProps) { > { if (isMobileSelectionModeEnabled) { diff --git a/src/styles/index.ts b/src/styles/index.ts index 19fba200fd0a..9f67829dab3e 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5888,10 +5888,10 @@ const dynamicStyles = (theme: ThemeColors) => return {height}; }, - getUserSelectionListPopoverHeight: (itemCount: number, windowHeight: number, shouldUseNarrowLayout: boolean) => { + getUserSelectionListPopoverHeight: (itemCount: number, windowHeight: number, shouldUseNarrowLayout: boolean, isSearchable = true) => { const BUTTON_HEIGHT = 40; - const SEARCHBAR_HEIGHT = 50; - const SEARCHBAR_MARGIN = 14; + const SEARCHBAR_HEIGHT = isSearchable ? 50 : 0; + const SEARCHBAR_MARGIN = isSearchable ? 14 : 0; const PADDING = 44 - (shouldUseNarrowLayout ? 32 : 0); const ESTIMATED_LIST_HEIGHT = itemCount * variables.optionRowHeightCompact + SEARCHBAR_HEIGHT + SEARCHBAR_MARGIN + BUTTON_HEIGHT + PADDING; diff --git a/tests/unit/CategoryOptionListUtilsTest.ts b/tests/unit/CategoryOptionListUtilsTest.ts index 0b2e53f0f22d..f632a5729c39 100644 --- a/tests/unit/CategoryOptionListUtilsTest.ts +++ b/tests/unit/CategoryOptionListUtilsTest.ts @@ -284,6 +284,33 @@ describe('CategoryOptionListUtils', () => { externalID: '', origin: '', }, + Entertainment: { + enabled: true, + name: 'Entertainment', + unencodedName: 'Entertainment', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Office Supplies': { + enabled: true, + name: 'Office Supplies', + unencodedName: 'Office Supplies', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + Utilities: { + enabled: true, + name: 'Utilities', + unencodedName: 'Utilities', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, }; const largeResultList: CategoryTreeSection[] = [ { @@ -321,7 +348,7 @@ describe('CategoryOptionListUtils', () => { { title: 'All', shouldShow: true, - indexOffset: 11, + indexOffset: 14, data: [ { text: 'Cars', @@ -350,6 +377,15 @@ describe('CategoryOptionListUtils', () => { isSelected: false, pendingAction: undefined, }, + { + text: 'Entertainment', + keyForList: 'Entertainment', + searchText: 'Entertainment', + tooltipText: 'Entertainment', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, { text: 'Food', keyForList: 'Food', @@ -377,6 +413,15 @@ describe('CategoryOptionListUtils', () => { isSelected: false, pendingAction: undefined, }, + { + text: 'Office Supplies', + keyForList: 'Office Supplies', + searchText: 'Office Supplies', + tooltipText: 'Office Supplies', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, { text: 'Restaurant', keyForList: 'Restaurant', @@ -422,6 +467,15 @@ describe('CategoryOptionListUtils', () => { isSelected: false, pendingAction: undefined, }, + { + text: 'Utilities', + keyForList: 'Utilities', + searchText: 'Utilities', + tooltipText: 'Utilities', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, ], }, ]; diff --git a/tests/unit/TagsOptionsListUtilsTest.ts b/tests/unit/TagsOptionsListUtilsTest.ts index 94c92c46ec6d..b937070e5ed1 100644 --- a/tests/unit/TagsOptionsListUtilsTest.ts +++ b/tests/unit/TagsOptionsListUtilsTest.ts @@ -199,6 +199,26 @@ describe('TagsOptionsListUtils', () => { name: 'Benefits', accountID: undefined, }, + Communications: { + enabled: true, + name: 'Communications', + accountID: undefined, + }, + Legal: { + enabled: true, + name: 'Legal', + accountID: undefined, + }, + Marketing: { + enabled: true, + name: 'Marketing', + accountID: undefined, + }, + Operations: { + enabled: true, + name: 'Operations', + accountID: undefined, + }, }; const largeResultList: Section[] = [ { @@ -263,6 +283,15 @@ describe('TagsOptionsListUtils', () => { isSelected: false, pendingAction: undefined, }, + { + text: 'Communications', + keyForList: 'Communications', + searchText: 'Communications', + tooltipText: 'Communications', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, { text: 'Food', keyForList: 'Food', @@ -281,6 +310,33 @@ describe('TagsOptionsListUtils', () => { isSelected: false, pendingAction: undefined, }, + { + text: 'Legal', + keyForList: 'Legal', + searchText: 'Legal', + tooltipText: 'Legal', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Marketing', + keyForList: 'Marketing', + searchText: 'Marketing', + tooltipText: 'Marketing', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Operations', + keyForList: 'Operations', + searchText: 'Operations', + tooltipText: 'Operations', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, { text: 'Software', keyForList: 'Software', @@ -325,6 +381,15 @@ describe('TagsOptionsListUtils', () => { isSelected: false, pendingAction: undefined, }, + { + text: 'Marketing', + keyForList: 'Marketing', + searchText: 'Marketing', + tooltipText: 'Marketing', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, ], }, ];