diff --git a/src/components/Search/SearchFiltersChatsSelector.tsx b/src/components/Search/SearchFiltersChatsSelector.tsx index e3d484a3408f..8daf55b0548b 100644 --- a/src/components/Search/SearchFiltersChatsSelector.tsx +++ b/src/components/Search/SearchFiltersChatsSelector.tsx @@ -9,10 +9,11 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import {createOptionFromReport, filterAndOrderOptions, formatSectionsFromSearchTerm, getAlternateText, getSearchOptions} from '@libs/OptionsListUtils'; +import {createOptionFromReport, filterAndOrderOptions, getAlternateText, getFirstSelectedItem, getSearchOptions} from '@libs/OptionsListUtils'; import type {Option, Section} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; +import variables from '@styles/variables'; import {searchInServer} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -67,61 +68,44 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen return getSearchOptions(options, undefined, false); }, [areOptionsInitialized, isScreenTransitionEnd, options]); + const defaultOptionsModified = useMemo(() => { + return { + ...defaultOptions, + recentReports: defaultOptions.recentReports.map((item) => (selectedReportIDs.includes(item.reportID) ? {...item, isSelected: true} : item)), + }; + }, [defaultOptions, selectedReportIDs]); + const chatOptions = useMemo(() => { - return filterAndOrderOptions(defaultOptions, cleanSearchTerm, { + return filterAndOrderOptions(defaultOptionsModified, cleanSearchTerm, { selectedOptions, excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, }); - }, [defaultOptions, cleanSearchTerm, selectedOptions]); + }, [cleanSearchTerm, selectedOptions, defaultOptionsModified]); - const {sections, headerMessage} = useMemo(() => { + const {sections, headerMessage, firstKeyForList} = useMemo(() => { const newSections: Section[] = []; + let firstKey = ''; if (!areOptionsInitialized) { - return {sections: [], headerMessage: undefined}; + return {sections: [], headerMessage: undefined, firstKeyForList: firstKey}; } - const formattedResults = formatSectionsFromSearchTerm( - cleanSearchTerm, - selectedOptions, - chatOptions.recentReports, - chatOptions.personalDetails, - personalDetails, - false, - undefined, - reportAttributesDerived, - ); - - newSections.push(formattedResults.section); - - const visibleReportsWhenSearchTermNonEmpty = chatOptions.recentReports.map((report) => (selectedReportIDs.includes(report.reportID) ? getSelectedOptionData(report) : report)); - const visibleReportsWhenSearchTermEmpty = chatOptions.recentReports.filter((report) => !selectedReportIDs.includes(report.reportID)); - const reportsFiltered = cleanSearchTerm === '' ? visibleReportsWhenSearchTermEmpty : visibleReportsWhenSearchTermNonEmpty; - newSections.push({ title: undefined, - data: reportsFiltered, + data: chatOptions.recentReports, shouldShow: chatOptions.recentReports.length > 0, }); - - const areResultsFound = didScreenTransitionEnd && formattedResults.section.data.length === 0 && reportsFiltered.length === 0; + if (!firstKey) { + firstKey = getFirstSelectedItem(chatOptions.recentReports); + } + const areResultsFound = didScreenTransitionEnd && chatOptions.recentReports.length === 0; const message = areResultsFound ? translate('common.noResultsFound') : undefined; return { sections: newSections, headerMessage: message, + firstKeyForList: firstKey, }; - }, [ - areOptionsInitialized, - chatOptions.personalDetails, - chatOptions.recentReports, - cleanSearchTerm, - didScreenTransitionEnd, - personalDetails, - reportAttributesDerived, - selectedOptions, - selectedReportIDs, - translate, - ]); + }, [areOptionsInitialized, chatOptions.recentReports, didScreenTransitionEnd, translate]); useEffect(() => { searchInServer(debouncedSearchTerm.trim()); @@ -180,6 +164,8 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen onSelectRow={handleParticipantSelection} isLoadingNewOptions={isLoadingNewOptions} showLoadingPlaceholder={showLoadingPlaceholder} + initiallyFocusedOptionKey={firstKeyForList} + getItemHeight={() => variables.optionRowHeight} /> ); } diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index b72b5051673d..29f76a822184 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -8,11 +8,11 @@ import useOnyx from '@hooks/useOnyx'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import memoize from '@libs/memoize'; -import {filterAndOrderOptions, filterSelectedOptions, formatSectionsFromSearchTerm, getValidOptions} from '@libs/OptionsListUtils'; +import {filterAndOrderOptions, getFirstSelectedItem, getValidOptions} from '@libs/OptionsListUtils'; import type {Option, Section} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; -import {getDisplayNameForParticipant} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -46,7 +46,6 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: shouldInitialize: didScreenTransitionEnd, }); const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {canBeMissing: false, initWithStoredValues: false}); - const [reportAttributesDerived] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {canBeMissing: true, selector: (val) => val?.reports}); const [selectedOptions, setSelectedOptions] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const cleanSearchTerm = useMemo(() => searchTerm.trim().toLowerCase(), [searchTerm]); @@ -64,86 +63,55 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: { excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, includeCurrentUser: true, + includeSelectedOptions: true, }, ); }, [areOptionsInitialized, options.personalDetails, options.reports]); - - const unselectedOptions = useMemo(() => { - return filterSelectedOptions(defaultOptions, new Set(selectedOptions.map((option) => option.accountID))); - }, [defaultOptions, selectedOptions]); + const selectedAccountIDsSet = useMemo(() => new Set(selectedOptions.map(({accountID}) => accountID)), [selectedOptions]); + const defaultOptionsModified = useMemo(() => { + return { + ...defaultOptions, + recentReports: defaultOptions.recentReports.map((item) => (selectedAccountIDsSet.has(item.accountID) ? {...item, isSelected: true} : item)), + personalDetails: defaultOptions.personalDetails.map((item) => (selectedAccountIDsSet.has(item.accountID) ? {...item, isSelected: true} : item)), + }; + }, [defaultOptions, selectedAccountIDsSet]); const chatOptions = useMemo(() => { - const filteredOptions = filterAndOrderOptions(unselectedOptions, cleanSearchTerm, { + const filteredOptions = filterAndOrderOptions(defaultOptionsModified, cleanSearchTerm, { selectedOptions, excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, canInviteUser: false, }); - const {currentUserOption} = unselectedOptions; - - // Ensure current user is not in personalDetails when they should be excluded - if (currentUserOption) { - filteredOptions.personalDetails = filteredOptions.personalDetails.filter((detail) => detail.accountID !== currentUserOption.accountID); - } - return filteredOptions; - }, [unselectedOptions, cleanSearchTerm, selectedOptions]); + }, [defaultOptionsModified, cleanSearchTerm, selectedOptions]); - const {sections, headerMessage} = useMemo(() => { + const {sections, headerMessage, firstKeyForList} = useMemo(() => { const newSections: Section[] = []; - if (!areOptionsInitialized) { - return {sections: [], headerMessage: undefined}; - } - - const formattedResults = formatSectionsFromSearchTerm( - cleanSearchTerm, - selectedOptions, - chatOptions.recentReports, - chatOptions.personalDetails, - personalDetails, - true, - undefined, - reportAttributesDerived, - ); - - const selectedCurrentUser = formattedResults.section.data.find((option) => option.accountID === chatOptions.currentUserOption?.accountID); + let firstKey = ''; - // If the current user is already selected, remove them from the recent reports and personal details - if (selectedCurrentUser) { - chatOptions.recentReports = chatOptions.recentReports.filter((report) => report.accountID !== selectedCurrentUser.accountID); - chatOptions.personalDetails = chatOptions.personalDetails.filter((detail) => detail.accountID !== selectedCurrentUser.accountID); - } - - // If the current user is not selected, add them to the top of the list - if (!selectedCurrentUser && chatOptions.currentUserOption) { - const formattedName = getDisplayNameForParticipant({ - accountID: chatOptions.currentUserOption.accountID, - shouldAddCurrentUserPostfix: true, - personalDetailsData: personalDetails, - }); - chatOptions.currentUserOption.text = formattedName; - - newSections.push({ - title: '', - data: [chatOptions.currentUserOption], - shouldShow: true, - }); + if (!areOptionsInitialized) { + return {sections: [], headerMessage: undefined, firstKeyForList: firstKey}; } - newSections.push(formattedResults.section); - newSections.push({ title: '', data: chatOptions.recentReports, shouldShow: chatOptions.recentReports.length > 0, }); + if (!firstKey) { + firstKey = getFirstSelectedItem(chatOptions.recentReports); + } newSections.push({ title: '', data: chatOptions.personalDetails, shouldShow: chatOptions.personalDetails.length > 0, }); + if (!firstKey) { + firstKey = getFirstSelectedItem(chatOptions.personalDetails); + } const noResultsFound = chatOptions.personalDetails.length === 0 && chatOptions.recentReports.length === 0 && !chatOptions.currentUserOption; const message = noResultsFound ? translate('common.noResultsFound') : undefined; @@ -151,8 +119,9 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: return { sections: newSections, headerMessage: message, + firstKeyForList: firstKey, }; - }, [areOptionsInitialized, cleanSearchTerm, selectedOptions, chatOptions, personalDetails, reportAttributesDerived, translate]); + }, [areOptionsInitialized, chatOptions, translate]); const resetChanges = useCallback(() => { setSelectedOptions([]); @@ -195,10 +164,6 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: return true; } - if (selectedOption.reportID && selectedOption.reportID === option?.reportID) { - return true; - } - return false; }); @@ -242,6 +207,8 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: onSelectRow={handleParticipantSelection} isLoadingNewOptions={isLoadingNewOptions} showLoadingPlaceholder={showLoadingPlaceholder} + initiallyFocusedOptionKey={firstKeyForList} + getItemHeight={() => variables.optionRowHeightCompact} /> ); } diff --git a/src/components/Search/SearchMultipleSelectionPicker.tsx b/src/components/Search/SearchMultipleSelectionPicker.tsx index 6cc701f42a10..9fd110dfcafe 100644 --- a/src/components/Search/SearchMultipleSelectionPicker.tsx +++ b/src/components/Search/SearchMultipleSelectionPicker.tsx @@ -4,8 +4,10 @@ import MultiSelectListItem from '@components/SelectionList/MultiSelectListItem'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; +import {getFirstSelectedItem} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; import {sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils'; +import variables from '@styles/variables'; import ROUTES from '@src/ROUTES'; import SearchFilterPageFooterButtons from './SearchFilterPageFooterButtons'; @@ -33,47 +35,32 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit }, [initiallySelectedItems]); const {sections, noResultsFound} = useMemo(() => { - const selectedItemsSection = selectedItems - .filter((item) => item?.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase())) + const itemsSection = items + .filter((item) => item?.name?.toLowerCase().includes(debouncedSearchTerm?.toLowerCase())) .sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare)) .map((item) => ({ text: item.name, keyForList: item.name, - isSelected: true, + isSelected: selectedItems.some((selectedItem) => selectedItem.value === item.value), value: item.value, })); - const remainingItemsSection = items - .filter( - (item) => - !selectedItems.some((selectedItem) => selectedItem.value.toString() === item.value.toString()) && item?.name?.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()), - ) - .sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare)) - .map((item) => ({ - text: item.name, - keyForList: item.name, - isSelected: false, - value: item.value, - })); - const isEmpty = !selectedItemsSection.length && !remainingItemsSection.length; + const isEmpty = !itemsSection.length; return { sections: isEmpty ? [] : [ - { - title: undefined, - data: selectedItemsSection, - shouldShow: selectedItemsSection.length > 0, - }, { title: pickerTitle, - data: remainingItemsSection, - shouldShow: remainingItemsSection.length > 0, + data: itemsSection, + shouldShow: itemsSection.length > 0, }, ], noResultsFound: isEmpty, }; }, [selectedItems, items, pickerTitle, debouncedSearchTerm, localeCompare]); + const firstKey = getFirstSelectedItem(sections?.at(0)?.data); + const onSelectItem = useCallback( (item: Partial) => { if (!item.text || !item.keyForList || !item.value) { @@ -120,6 +107,8 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit shouldShowTooltips canSelectMultiple ListItem={MultiSelectListItem} + initiallyFocusedOptionKey={firstKey} + getItemHeight={() => variables.optionRowHeightCompact} /> ); } diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index e0b4c0e0ee64..30b71028d405 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -166,6 +166,7 @@ function BaseSelectionList({ const {isKeyboardShown} = useKeyboardState(); const [itemsToHighlight, setItemsToHighlight] = useState | null>(null); const itemFocusTimeoutRef = useRef(null); + const shouldPreventScrollAnimationRef = useRef(false); const isItemSelected = useCallback( (item: TItem) => item.isSelected ?? ((isSelected?.(item) ?? selectedItems.includes(item.keyForList ?? '')) && canSelectMultiple), [isSelected, selectedItems, canSelectMultiple], @@ -403,7 +404,7 @@ function BaseSelectionList({ if (targetItem && indexToScroll < CONST.MAX_SELECTION_LIST_PAGE_LENGTH * currentPage) { pendingScrollIndexRef.current = null; - scrollToIndex(indexToScroll, true); + scrollToIndex(indexToScroll, false); } }, [currentPage, scrollToIndex, flattenedSections.allOptions]); @@ -431,7 +432,8 @@ function BaseSelectionList({ onArrowFocus(focusedItem); } if (shouldScrollToFocusedIndex) { - (shouldDebounceScrolling ? debouncedScrollToIndex : scrollToIndex)(index, true); + (shouldDebounceScrolling ? debouncedScrollToIndex : scrollToIndex)(index, !shouldPreventScrollAnimationRef.current && true); + shouldPreventScrollAnimationRef.current = false; } }, ...(!hasKeyBeenPressed.current && {setHasKeyBeenPressed}), @@ -453,6 +455,7 @@ function BaseSelectionList({ if (selectedItemIndex === -1 || selectedItemIndex === focusedIndex || textInputValue) { return; } + shouldPreventScrollAnimationRef.current = true; setFocusedIndex(selectedItemIndex); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [selectedItemIndex]); @@ -478,11 +481,6 @@ function BaseSelectionList({ } // 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 && !isItemSelected(item)) { - // If we're selecting an item, scroll to its position at the top, so we can see it - scrollToIndex(0, true); - } - if (shouldShowTextInput) { clearInputAfterSelect(); } else if (isSmallScreenWidth) { @@ -511,10 +509,7 @@ function BaseSelectionList({ onSelectRow, shouldShowTextInput, shouldPreventDefaultFocusOnSelectRow, - sections.length, - isItemSelected, isSmallScreenWidth, - scrollToIndex, clearInputAfterSelect, onCheckboxPress, setFocusedIndex, @@ -842,6 +837,10 @@ function BaseSelectionList({ } } + // Avoid clearing focus on initial render + if (isInitialSectionListRender) { + return; + } // Remove the focus if the search input is empty and prev search input not empty or selected options length is changed (and allOptions length remains the same) // else focus on the first non disabled item const newSelectedIndex = @@ -864,6 +863,7 @@ function BaseSelectionList({ flattenedSections.allOptions, isItemSelected, slicedSections.length, + isInitialSectionListRender, ]); useEffect( diff --git a/src/hooks/useWorkspaceList.ts b/src/hooks/useWorkspaceList.ts index 37ebea65220d..ccb81cf31cc0 100644 --- a/src/hooks/useWorkspaceList.ts +++ b/src/hooks/useWorkspaceList.ts @@ -3,7 +3,8 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import type {ListItem, SectionListDataType} from '@components/SelectionList/types'; -import {isPolicyAdmin, shouldShowPolicy, sortWorkspacesBySelected} from '@libs/PolicyUtils'; +import {getFirstSelectedItem} from '@libs/OptionsListUtils'; +import {isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; @@ -61,14 +62,11 @@ function useWorkspaceList({policies, currentUserLogin, selectedPolicyIDs, search }, [policies, shouldShowPendingDeletePolicy, currentUserLogin, additionalFilter, selectedPolicyIDs]); const filteredAndSortedUserWorkspaces = useMemo( - () => - tokenizedSearch(usersWorkspaces, searchTerm, (policy) => [policy.text]).sort((policy1, policy2) => - sortWorkspacesBySelected({policyID: policy1.policyID, name: policy1.text}, {policyID: policy2.policyID, name: policy2.text}, selectedPolicyIDs, localeCompare), - ), - [searchTerm, usersWorkspaces, selectedPolicyIDs, localeCompare], + () => tokenizedSearch(usersWorkspaces, searchTerm, (policy) => [policy.text]).sort((policy1, policy2) => localeCompare(policy1.text, policy2.text)), + [searchTerm, usersWorkspaces, localeCompare], ); - const sections = useMemo(() => { + const {sections, firstKeyForList} = useMemo(() => { const options: Array> = [ { data: filteredAndSortedUserWorkspaces, @@ -76,7 +74,8 @@ function useWorkspaceList({policies, currentUserLogin, selectedPolicyIDs, search indexOffset: 1, }, ]; - return options; + const firstKey = getFirstSelectedItem(filteredAndSortedUserWorkspaces); + return {sections: options, firstKeyForList: firstKey}; }, [filteredAndSortedUserWorkspaces]); const shouldShowNoResultsFoundMessage = filteredAndSortedUserWorkspaces.length === 0 && usersWorkspaces.length; @@ -86,6 +85,7 @@ function useWorkspaceList({policies, currentUserLogin, selectedPolicyIDs, search sections, shouldShowNoResultsFoundMessage, shouldShowSearchInput, + firstKeyForList, }; } diff --git a/src/libs/CardFeedUtils.ts b/src/libs/CardFeedUtils.ts index d1190ea5fdfc..32d81e54b7e7 100644 --- a/src/libs/CardFeedUtils.ts +++ b/src/libs/CardFeedUtils.ts @@ -22,7 +22,7 @@ import type {OptionData} from './ReportUtils'; type CardFilterItem = Partial & AdditionalCardProps & {isCardFeed?: boolean; correspondingCards?: string[]; cardFeedKey: string; plaidUrl?: string}; type DomainFeedData = {bank: string; domainName: string; correspondingCardIDs: string[]; fundID?: string}; -type ItemsGroupedBySelection = {selected: CardFilterItem[]; unselected: CardFilterItem[]}; +type ItemsGroupedBySelection = CardFilterItem[]; type CardFeedNamesWithType = Record; type CardFeedData = {cardName: string; bank: string; label?: string; type: 'domain' | 'workspace'}; type GetCardFeedData = { @@ -128,16 +128,8 @@ function buildCardsData( }); const allCardItems = [...userAssignedCards, ...allWorkspaceCards]; - const selectedCardItems: CardFilterItem[] = []; - const unselectedCardItems: CardFilterItem[] = []; - allCardItems.forEach((card) => { - if (card.isSelected) { - selectedCardItems.push(card); - } else { - unselectedCardItems.push(card); - } - }); - return {selected: selectedCardItems, unselected: unselectedCardItems}; + + return allCardItems; } /** @@ -316,8 +308,7 @@ function buildCardFeedsData( translate: LocaleContextProps['translate'], illustrations: IllustrationsType, ): ItemsGroupedBySelection { - const selectedFeeds: CardFilterItem[] = []; - const unselectedFeeds: CardFilterItem[] = []; + const cardFeed: CardFilterItem[] = []; const repeatingBanks = getRepeatingBanks(Object.keys(workspaceCardFeeds), domainFeedsData); Object.values(domainFeedsData).forEach((domainFeed) => { @@ -335,11 +326,7 @@ function buildCardFeedsData( selectedCards, illustrations, }); - if (feedItem.isSelected) { - selectedFeeds.push(feedItem); - } else { - unselectedFeeds.push(feedItem); - } + cardFeed.push(feedItem); }); filterOutDomainCards(workspaceCardFeeds).forEach(([workspaceFeedKey, workspaceFeed]) => { @@ -363,14 +350,10 @@ function buildCardFeedsData( selectedCards, illustrations, }); - if (feedItem.isSelected) { - selectedFeeds.push(feedItem); - } else { - unselectedFeeds.push(feedItem); - } + cardFeed.push(feedItem); }); - return {selected: selectedFeeds, unselected: unselectedFeeds}; + return cardFeed; } function getSelectedCardsFromFeeds(cards: CardList | undefined, workspaceCardFeeds?: Record, selectedFeeds?: string[]): string[] { diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index cd44898c518f..207796e0924b 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -8,6 +8,7 @@ import Onyx from 'react-native-onyx'; import type {SetNonNullable} from 'type-fest'; import {FallbackAvatar} from '@components/Icon/Expensicons'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import type {WorkspaceListItem} from '@hooks/useWorkspaceList'; import {getEnabledCategoriesCount} from '@libs/CategoryUtils'; import filterArrayByMatch from '@libs/filterArrayByMatch'; import {isReportMessageAttachment} from '@libs/isReportMessageAttachment'; @@ -1994,8 +1995,8 @@ function getAttendeeOptions( }); } - const filteredRecentAttendees = recentAttendees - .filter((attendee) => !attendees.find(({email, displayName}) => (attendee.email ? email === attendee.email : displayName === attendee.displayName))) + // attendees list might lack some personal data such as accountID and login at this point - fetch those here + const RecentAttendeesWithDetails = recentAttendees .map((attendee) => ({ ...attendee, login: attendee.email ?? attendee.displayName, @@ -2012,11 +2013,11 @@ function getAttendeeOptions( includeOwnedWorkspaceChats, includeRecentReports: false, includeP2P, - includeSelectedOptions: false, + includeSelectedOptions: true, includeSelfDM: false, includeInvoiceRooms, action, - recentAttendees: filteredRecentAttendees, + recentAttendees: RecentAttendeesWithDetails, }, ); } @@ -2224,6 +2225,17 @@ function getFirstKeyForList(data?: Option[] | null) { return firstNonEmptyDataObj?.keyForList ? firstNonEmptyDataObj?.keyForList : ''; } +/** + * Helper method to get the `keyForList` for the first selected item + */ +function getFirstSelectedItem(data?: Option[] | WorkspaceListItem[] | null) { + if (!data?.length) { + return ''; + } + + return data.find((item) => item.isSelected)?.keyForList ?? ''; +} + function getPersonalDetailSearchTerms(item: Partial) { if (item.accountID === currentUserAccountID) { return getCurrentUserSearchTerms(item); @@ -2567,6 +2579,7 @@ export { getCurrentUserSearchTerms, getEmptyOptions, getFirstKeyForList, + getFirstSelectedItem, getHeaderMessage, getHeaderMessageForNonUserList, getIOUConfirmationOptionsFromPayeePersonalDetail, diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index bbe83f58ee16..a767a93e0ab9 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -21,21 +21,12 @@ import {appendCountryCode} from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ParticipantsNavigatorParamList} from '@libs/Navigation/types'; -import { - filterAndOrderOptions, - formatMemberForList, - getEmptyOptions, - getHeaderMessage, - getMemberInviteOptions, - getSearchValueForPhoneOrEmail, - isPersonalDetailsReady, -} from '@libs/OptionsListUtils'; +import {filterAndOrderOptions, formatMemberForList, getEmptyOptions, getHeaderMessage, getMemberInviteOptions, isPersonalDetailsReady} from '@libs/OptionsListUtils'; import type {MemberForList} from '@libs/OptionsListUtils'; import {getLoginsByAccountIDs} from '@libs/PersonalDetailsUtils'; import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber'; import {getGroupChatName, getParticipantsAccountIDsForDisplay} from '@libs/ReportUtils'; import type {OptionData} from '@libs/ReportUtils'; -import tokenizedSearch from '@libs/tokenizedSearch'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -105,7 +96,7 @@ function InviteReportParticipantsPage({betas, report, didScreenTransitionEnd}: I setSelectedOptions(newSelectedOptions); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change - }, [personalDetails, betas, debouncedSearchTerm, excludedUsers, options]); + }, [personalDetails, betas, excludedUsers, options]); const sections = useMemo(() => { const sectionsArr: Sections = []; @@ -114,30 +105,11 @@ function InviteReportParticipantsPage({betas, report, didScreenTransitionEnd}: I return []; } - // Filter all options that is a part of the search term or in the personal details - let filterSelectedOptions = selectedOptions; - if (debouncedSearchTerm !== '') { - const processedSearchValue = getSearchValueForPhoneOrEmail(debouncedSearchTerm); - filterSelectedOptions = tokenizedSearch(selectedOptions, processedSearchValue, (option) => [option.text ?? '', option.login ?? '']).filter((option) => { - const accountID = option?.accountID; - const isOptionInPersonalDetails = inviteOptions.personalDetails.some((personalDetail) => accountID && personalDetail?.accountID === accountID); - const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(processedSearchValue) || !!option.login?.toLowerCase().includes(processedSearchValue); - return isPartOfSearchTerm || isOptionInPersonalDetails; - }); - } - const filterSelectedOptionsFormatted = filterSelectedOptions.map((selectedOption) => formatMemberForList(selectedOption)); - - sectionsArr.push({ - title: undefined, - data: filterSelectedOptionsFormatted, - }); - - // Filtering out selected users from the search results const selectedLogins = selectedOptions.map(({login}) => login); - const recentReportsWithoutSelected = inviteOptions.recentReports.filter(({login}) => !selectedLogins.includes(login)); - const recentReportsFormatted = recentReportsWithoutSelected.map((reportOption) => formatMemberForList(reportOption)); - const personalDetailsWithoutSelected = inviteOptions.personalDetails.filter(({login}) => !selectedLogins.includes(login)); - const personalDetailsFormatted = personalDetailsWithoutSelected.map((personalDetail) => formatMemberForList(personalDetail)); + const recentReportsModified = inviteOptions.recentReports.map((item) => (selectedLogins.includes(item.login) ? {...item, isSelected: true} : item)); + const recentReportsFormatted = recentReportsModified.map((reportOption) => formatMemberForList(reportOption)); + const personalDetailsModified = inviteOptions.personalDetails.map((item) => (selectedLogins.includes(item.login) ? {...item, isSelected: true} : item)); + const personalDetailsFormatted = personalDetailsModified.map((personalDetail) => formatMemberForList(personalDetail)); const hasUnselectedUserToInvite = inviteOptions.userToInvite && !selectedLogins.includes(inviteOptions.userToInvite.login); sectionsArr.push({ @@ -158,7 +130,7 @@ function InviteReportParticipantsPage({betas, report, didScreenTransitionEnd}: I } return sectionsArr; - }, [areOptionsInitialized, selectedOptions, debouncedSearchTerm, inviteOptions.recentReports, inviteOptions.personalDetails, inviteOptions.userToInvite, translate]); + }, [areOptionsInitialized, selectedOptions, inviteOptions.recentReports, inviteOptions.personalDetails, inviteOptions.userToInvite, translate]); const toggleOption = useCallback( (option: MemberForList) => { diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index ba82bcf03840..b8875403eae5 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -30,7 +30,6 @@ import Navigation from '@libs/Navigation/Navigation'; import type {Option, Section} from '@libs/OptionsListUtils'; import { filterAndOrderOptions, - filterSelectedOptions, formatSectionsFromSearchTerm, getFirstKeyForList, getHeaderMessage, @@ -77,21 +76,32 @@ function useOptions() { { betas: betas ?? [], includeSelfDM: true, + includeSelectedOptions: true, }, ); return filteredOptions; }, [betas, listOptions.personalDetails, listOptions.reports, contacts]); - const unselectedOptions = useMemo(() => filterSelectedOptions(defaultOptions, new Set(selectedOptions.map(({accountID}) => accountID))), [defaultOptions, selectedOptions]); + const defaultOptionsModified = useMemo(() => { + return { + ...defaultOptions, + recentReports: defaultOptions.recentReports.map((item) => + selectedOptions.some((selectedOption) => selectedOption.accountID === item.accountID) ? {...item, isSelected: true} : item, + ), + personalDetails: defaultOptions.personalDetails.map((item) => + selectedOptions.some((selectedOption) => selectedOption.accountID === item.accountID) ? {...item, isSelected: true} : item, + ), + }; + }, [defaultOptions, selectedOptions]); const options = useMemo(() => { - const filteredOptions = filterAndOrderOptions(unselectedOptions, debouncedSearchTerm, { + const filteredOptions = filterAndOrderOptions(defaultOptionsModified, debouncedSearchTerm, { selectedOptions, maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, }); return filteredOptions; - }, [debouncedSearchTerm, unselectedOptions, selectedOptions]); + }, [defaultOptionsModified, debouncedSearchTerm, selectedOptions]); const cleanSearchTerm = useMemo(() => debouncedSearchTerm.trim().toLowerCase(), [debouncedSearchTerm]); const headerMessage = useMemo(() => { return getHeaderMessage( @@ -180,16 +190,26 @@ function NewChatPage(_: unknown, ref: React.Ref) { focus: selectionListRef.current?.focusTextInput, })); - const {headerMessage, searchTerm, debouncedSearchTerm, setSearchTerm, selectedOptions, setSelectedOptions, recentReports, personalDetails, userToInvite, areOptionsInitialized} = + const {headerMessage, searchTerm, setSearchTerm, debouncedSearchTerm, selectedOptions, setSelectedOptions, recentReports, personalDetails, userToInvite, areOptionsInitialized} = useOptions(); const [sections, firstKeyForList] = useMemo(() => { const sectionsList: Section[] = []; let firstKey = ''; + // Participants whose data does not exist in chatOptions + const filteredSelectedOptions = selectedOptions + .filter((participant) => !recentReports.some((item) => participant.login === item.login) && !personalDetails.some((item) => participant.login === item.login)) + .map((participant) => ({ + ...participant, + reportID: CONST.DEFAULT_NUMBER_ID.toString(), + selected: true, + login: participant.login, + })); + const formatResults = formatSectionsFromSearchTerm( debouncedSearchTerm, - selectedOptions as OptionData[], + filteredSelectedOptions as OptionData[], recentReports, personalDetails, undefined, @@ -198,11 +218,9 @@ function NewChatPage(_: unknown, ref: React.Ref) { reportAttributesDerived, ); sectionsList.push(formatResults.section); - if (!firstKey) { firstKey = getFirstKeyForList(formatResults.section.data); } - sectionsList.push({ title: translate('common.recents'), data: selectedOptions.length ? recentReports.filter((option) => !option.isSelfDM) : recentReports, @@ -233,7 +251,7 @@ function NewChatPage(_: unknown, ref: React.Ref) { } return [sectionsList, firstKey]; - }, [debouncedSearchTerm, selectedOptions, recentReports, personalDetails, reportAttributesDerived, translate, userToInvite]); + }, [selectedOptions, debouncedSearchTerm, reportAttributesDerived, recentReports, personalDetails, translate, userToInvite]); /** * Removes a selected option from list if already selected. If not already selected add this option to the list. @@ -248,7 +266,6 @@ function NewChatPage(_: unknown, ref: React.Ref) { newSelectedOptions = reject(selectedOptions, (selectedOption) => selectedOption.login === option.login); } else { newSelectedOptions = [...selectedOptions, {...option, isSelected: true, selected: true, reportID: option.reportID}]; - selectionListRef?.current?.scrollToIndex(0, true); } selectionListRef?.current?.clearInputAfterSelect?.(); @@ -399,7 +416,7 @@ function NewChatPage(_: unknown, ref: React.Ref) { headerMessage={headerMessage} onSelectRow={selectOption} shouldSingleExecuteRowSelect - onConfirm={(e, option) => (selectedOptions.length > 0 ? createGroup() : selectOption(option))} + onConfirm={(_e, option) => (selectedOptions.length > 0 ? createGroup() : selectOption(option))} rightHandSideComponent={itemRightSideComponent} footerContent={footerContent} showLoadingPlaceholder={!areOptionsInitialized} diff --git a/src/pages/OnboardingWorkspaceInvite/BaseOnboardingWorkspaceInvite.tsx b/src/pages/OnboardingWorkspaceInvite/BaseOnboardingWorkspaceInvite.tsx index ec8e3df2eaeb..77aa89641606 100644 --- a/src/pages/OnboardingWorkspaceInvite/BaseOnboardingWorkspaceInvite.tsx +++ b/src/pages/OnboardingWorkspaceInvite/BaseOnboardingWorkspaceInvite.tsx @@ -27,7 +27,7 @@ import HttpUtils from '@libs/HttpUtils'; import {appendCountryCode} from '@libs/LoginUtils'; import {navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import type {MemberForList} from '@libs/OptionsListUtils'; -import {filterAndOrderOptions, formatMemberForList, getHeaderMessage, getMemberInviteOptions, getSearchValueForPhoneOrEmail} from '@libs/OptionsListUtils'; +import {filterAndOrderOptions, formatMemberForList, getHeaderMessage, getMemberInviteOptions} from '@libs/OptionsListUtils'; import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber'; import {getIneligibleInvitees, getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -157,31 +157,9 @@ function BaseOnboardingWorkspaceInvite({shouldUseNativeStyles}: BaseOnboardingWo return []; } - // Filter all options that is a part of the search term or in the personal details - let filterSelectedOptions = selectedOptions; - if (debouncedSearchTerm !== '') { - filterSelectedOptions = selectedOptions.filter((option) => { - const accountID = option.accountID; - const isOptionInPersonalDetails = Object.values(personalDetails).some((personalDetail) => personalDetail.accountID === accountID); - - const searchValue = getSearchValueForPhoneOrEmail(debouncedSearchTerm); - - const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(searchValue) || !!option.login?.toLowerCase().includes(searchValue); - return isPartOfSearchTerm || isOptionInPersonalDetails; - }); - } - - sectionsArr.push({ - title: undefined, - data: filterSelectedOptions, - shouldShow: true, - }); - - // Filtering out selected users from the search results const selectedLoginsSet = new Set(selectedOptions.map(({login}) => login)); - const personalDetailsFormatted = Object.values(personalDetails) - .filter(({login}) => !selectedLoginsSet.has(login ?? '')) - .map(formatMemberForList); + const personalDetailsModified = Object.values(personalDetails).map((item) => (selectedLoginsSet.has(item.login ?? '') ? {...item, isSelected: true} : item)); + const personalDetailsFormatted = personalDetailsModified.map((item) => formatMemberForList(item)); sectionsArr.push({ title: translate('common.contacts'), @@ -202,7 +180,7 @@ function BaseOnboardingWorkspaceInvite({shouldUseNativeStyles}: BaseOnboardingWo }); return sectionsArr; - }, [areOptionsInitialized, selectedOptions, debouncedSearchTerm, personalDetails, translate, usersToInvite]); + }, [areOptionsInitialized, selectedOptions, personalDetails, translate, usersToInvite]); const toggleOption = (option: MemberForList) => { const isOptionInList = selectedOptions.some((selectedOption) => selectedOption.login === option.login); diff --git a/src/pages/RoomInvitePage.tsx b/src/pages/RoomInvitePage.tsx index 6499e08d5810..1c379160e23f 100644 --- a/src/pages/RoomInvitePage.tsx +++ b/src/pages/RoomInvitePage.tsx @@ -1,4 +1,3 @@ -import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import type {SectionListData} from 'react-native'; import {View} from 'react-native'; @@ -122,29 +121,11 @@ function RoomInvitePage({ return []; } - // Filter all options that is a part of the search term or in the personal details - let filterSelectedOptions = selectedOptions; - if (debouncedSearchTerm !== '') { - filterSelectedOptions = selectedOptions.filter((option) => { - const accountID = option?.accountID; - const isOptionInPersonalDetails = personalDetails ? personalDetails.some((personalDetail) => accountID && personalDetail?.accountID === accountID) : false; - const parsedPhoneNumber = parsePhoneNumber(appendCountryCode(Str.removeSMSDomain(debouncedSearchTerm))); - const searchValue = parsedPhoneNumber.possible && parsedPhoneNumber.number ? parsedPhoneNumber.number.e164 : debouncedSearchTerm.toLowerCase(); - const isPartOfSearchTerm = (option.text?.toLowerCase() ?? '').includes(searchValue) || (option.login?.toLowerCase() ?? '').includes(searchValue); - return isPartOfSearchTerm || isOptionInPersonalDetails; - }); - } - const filterSelectedOptionsFormatted = filterSelectedOptions.map((selectedOption) => formatMemberForList(selectedOption)); - - sectionsArr.push({ - title: undefined, - data: filterSelectedOptionsFormatted, - }); - - // Filtering out selected users from the search results const selectedLogins = selectedOptions.map(({login}) => login); - const personalDetailsWithoutSelected = personalDetails ? personalDetails.filter(({login}) => !selectedLogins.includes(login)) : []; - const personalDetailsFormatted = personalDetailsWithoutSelected.map((personalDetail) => formatMemberForList(personalDetail)); + const personalDetailsModified = Object.values(personalDetails).map((item) => + selectedLogins.some((selectedLogin) => item.login === selectedLogin) ? {...item, isSelected: true} : item, + ); + const personalDetailsFormatted = personalDetailsModified.map((personalDetail) => formatMemberForList(personalDetail)); const hasUnselectedUserToInvite = userToInvite && !selectedLogins.includes(userToInvite.login); sectionsArr.push({ @@ -160,7 +141,7 @@ function RoomInvitePage({ } return sectionsArr; - }, [inviteOptions, areOptionsInitialized, selectedOptions, debouncedSearchTerm, translate]); + }, [inviteOptions, areOptionsInitialized, selectedOptions, translate]); const toggleOption = useCallback( (option: MemberForList) => { diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 3c9db7cab9b9..95e4a2ff22a5 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -14,7 +14,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateAdvancedFilters} from '@libs/actions/Search'; import type {CardFilterItem} from '@libs/CardFeedUtils'; import {buildCardFeedsData, buildCardsData, generateSelectedCards, getDomainFeedData, getSelectedCardsFromFeeds} from '@libs/CardFeedUtils'; +import {getFirstSelectedItem} from '@libs/OptionsListUtils'; import Navigation from '@navigation/Navigation'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -56,8 +58,7 @@ function SearchFiltersCardPage() { ); const shouldShowSearchInput = - cardFeedsSectionData.selected.length + cardFeedsSectionData.unselected.length + individualCardsSectionData.selected.length + individualCardsSectionData.unselected.length > - CONST.COMPANY_CARDS.CARD_LIST_THRESHOLD; + cardFeedsSectionData.length + cardFeedsSectionData.length + individualCardsSectionData.length + individualCardsSectionData.length > CONST.COMPANY_CARDS.CARD_LIST_THRESHOLD; const searchFunction = useCallback( (item: CardFilterItem) => @@ -68,49 +69,46 @@ function SearchFiltersCardPage() { [debouncedSearchTerm, translate], ); - const sections = useMemo(() => { + const {sections, firstKeyForList} = useMemo(() => { + let firstKey = ''; if (searchAdvancedFiltersForm === undefined) { - return []; + return {sections: [], firstKeyForList: firstKey}; } const newSections = []; - const selectedItems = [...cardFeedsSectionData.selected, ...individualCardsSectionData.selected, ...closedCardsSectionData.selected]; - newSections.push({ - title: undefined, - data: selectedItems.filter(searchFunction), - shouldShow: selectedItems.length > 0, - }); newSections.push({ title: translate('search.filters.card.cardFeeds'), - data: cardFeedsSectionData.unselected.filter(searchFunction), - shouldShow: cardFeedsSectionData.unselected.length > 0, + data: cardFeedsSectionData.filter(searchFunction), + shouldShow: cardFeedsSectionData.length > 0, }); + if (!firstKey) { + firstKey = getFirstSelectedItem(cardFeedsSectionData); + } + newSections.push({ title: translate('search.filters.card.individualCards'), - data: individualCardsSectionData.unselected.filter(searchFunction), - shouldShow: individualCardsSectionData.unselected.length > 0, + data: individualCardsSectionData.filter(searchFunction), + shouldShow: individualCardsSectionData.length > 0, }); + if (!firstKey) { + firstKey = getFirstSelectedItem(individualCardsSectionData); + } + newSections.push({ title: translate('search.filters.card.closedCards'), - data: closedCardsSectionData.unselected.filter(searchFunction), - shouldShow: closedCardsSectionData.unselected.length > 0, + data: closedCardsSectionData.filter(searchFunction), + shouldShow: closedCardsSectionData.length > 0, }); - return newSections; - }, [ - searchAdvancedFiltersForm, - cardFeedsSectionData.selected, - cardFeedsSectionData.unselected, - individualCardsSectionData.selected, - individualCardsSectionData.unselected, - closedCardsSectionData.selected, - closedCardsSectionData.unselected, - searchFunction, - translate, - ]); + if (!firstKey) { + firstKey = getFirstSelectedItem(closedCardsSectionData); + } + + return {sections: newSections, firstKeyForList: firstKey}; + }, [searchAdvancedFiltersForm, cardFeedsSectionData, individualCardsSectionData, closedCardsSectionData, searchFunction, translate]); const handleConfirmSelection = useCallback(() => { - const feeds = cardFeedsSectionData.selected.map((feed) => feed.cardFeedKey); + const feeds = cardFeedsSectionData.filter((feed) => feed.isSelected).map((feed) => feed.cardFeedKey); const cardsFromSelectedFeed = getSelectedCardsFromFeeds(userCardList, workspaceCardFeeds, feeds); const IDs = selectedCards.filter((card) => !cardsFromSelectedFeed.includes(card)); @@ -120,7 +118,7 @@ function SearchFiltersCardPage() { }); Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS.getRoute()); - }, [userCardList, selectedCards, cardFeedsSectionData.selected, workspaceCardFeeds]); + }, [userCardList, selectedCards, cardFeedsSectionData, workspaceCardFeeds]); const updateNewCards = useCallback( (item: CardFilterItem) => { @@ -191,6 +189,8 @@ function SearchFiltersCardPage() { setSearchTerm(value); }} showLoadingPlaceholder={isLoadingOnyxValue(userCardListMetadata, workspaceCardFeedsMetadata, searchAdvancedFiltersFormMetadata) || !didScreenTransitionEnd} + initiallyFocusedOptionKey={firstKeyForList} + getItemHeight={() => variables.optionRowHeight} /> diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx index 74c362ceedd8..3dc2c8fa428f 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx @@ -14,6 +14,7 @@ import type {WorkspaceListItem} from '@hooks/useWorkspaceList'; import useWorkspaceList from '@hooks/useWorkspaceList'; import {updateAdvancedFilters} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; +import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -39,7 +40,7 @@ function SearchFiltersWorkspacePage() { const [selectedOptions, setSelectedOptions] = useState(() => (searchAdvancedFiltersForm?.policyID ? Array.from(searchAdvancedFiltersForm?.policyID) : [])); - const {sections, shouldShowNoResultsFoundMessage, shouldShowSearchInput} = useWorkspaceList({ + const {sections, shouldShowNoResultsFoundMessage, shouldShowSearchInput, firstKeyForList} = useWorkspaceList({ policies, currentUserLogin, shouldShowPendingDeletePolicy: false, @@ -110,6 +111,8 @@ function SearchFiltersWorkspacePage() { resetChanges={resetChanges} /> } + initiallyFocusedOptionKey={firstKeyForList} + getItemHeight={() => variables.optionRowHeight} /> )} diff --git a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx index 4fb7a21cfff1..ab722f75ccd5 100644 --- a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx @@ -24,6 +24,7 @@ import { formatSectionsFromSearchTerm, getAttendeeOptions, getEmptyOptions, + getFirstSelectedItem, getHeaderMessage, getParticipantsOption, getPersonalDetailSearchTerms, @@ -33,6 +34,7 @@ import { } from '@libs/OptionsListUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {isPaidGroupPolicy as isPaidGroupPolicyFn} from '@libs/PolicyUtils'; +import variables from '@styles/variables'; import {searchInServer} from '@userActions/Report'; import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; @@ -98,6 +100,23 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde return optionList; }, [areOptionsInitialized, didScreenTransitionEnd, options.reports, options.personalDetails, betas, attendees, recentAttendees, iouType, action, isPaidGroupPolicy, searchTerm]); + // Selected members list is maintained separately as 'attendees', so update selection info here + const defaultOptionsModified = useMemo(() => { + return { + ...defaultOptions, + recentReports: defaultOptions.recentReports.map((item) => + attendees.some((attendee) => (attendee?.email && attendee.email === item.login) || (attendee?.accountID && attendee.accountID === item.accountID)) + ? {...item, isSelected: true} + : item, + ), + personalDetails: defaultOptions.personalDetails.map((item) => + attendees.some((attendee) => (attendee?.email && attendee.email === item.login) || (attendee?.accountID && attendee.accountID === item.accountID)) + ? {...item, isSelected: true} + : item, + ), + }; + }, [defaultOptions, attendees]); + const chatOptions = useMemo(() => { if (!areOptionsInitialized) { return { @@ -108,7 +127,7 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde headerMessage: '', }; } - const newOptions = filterAndOrderOptions(defaultOptions, cleanSearchTerm, { + const newOptions = filterAndOrderOptions(defaultOptionsModified, cleanSearchTerm, { excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, preferPolicyExpenseChat: isPaidGroupPolicy, shouldAcceptName: true, @@ -120,25 +139,42 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde ...getPersonalDetailByEmail(attendee.email), })), }); + return newOptions; - }, [areOptionsInitialized, defaultOptions, cleanSearchTerm, isPaidGroupPolicy, attendees]); + }, [areOptionsInitialized, defaultOptionsModified, cleanSearchTerm, isPaidGroupPolicy, attendees]); /** * Returns the sections needed for the OptionsSelector */ - const [sections, header] = useMemo(() => { + const {sections, header, firstKeyForList} = useMemo(() => { const newSections: Array> = []; + let firstKey = ''; + if (!areOptionsInitialized || !didScreenTransitionEnd) { - return [newSections, '']; + return {sections: newSections, header: '', firstKeyForList: firstKey}; } const fiveRecents = [...chatOptions.recentReports].slice(0, 5); const restOfRecents = [...chatOptions.recentReports].slice(5); const contactsWithRestOfRecents = [...restOfRecents, ...chatOptions.personalDetails]; + // Attendees whose data does not exist in chatOptions + const filteredAttendees = attendees + .filter( + (attendee) => + !fiveRecents.some((item) => attendee?.email === item.login || attendee?.accountID === item.accountID) && + !contactsWithRestOfRecents.some((item) => attendee?.email === item.login || attendee?.accountID === item.accountID), + ) + .map((attendee) => ({ + ...attendee, + reportID: CONST.DEFAULT_NUMBER_ID.toString(), + selected: true, + login: attendee.email, + ...getPersonalDetailByEmail(attendee.email), + })); const formatResults = formatSectionsFromSearchTerm( cleanSearchTerm, - attendees.map((attendee) => ({ + filteredAttendees.map((attendee) => ({ ...attendee, reportID: CONST.DEFAULT_NUMBER_ID.toString(), selected: true, @@ -153,31 +189,45 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde reportAttributesDerived, ); newSections.push(formatResults.section); + if (!firstKey) { + firstKey = getFirstSelectedItem(formatResults.section?.data); + } newSections.push({ title: translate('common.recents'), data: fiveRecents, shouldShow: fiveRecents.length > 0, }); + if (!firstKey) { + firstKey = getFirstSelectedItem(fiveRecents); + } newSections.push({ title: translate('common.contacts'), data: contactsWithRestOfRecents, shouldShow: contactsWithRestOfRecents.length > 0, }); + if (!firstKey) { + firstKey = getFirstSelectedItem(contactsWithRestOfRecents); + } if ( chatOptions.userToInvite && !isCurrentUser({...chatOptions.userToInvite, accountID: chatOptions.userToInvite?.accountID ?? CONST.DEFAULT_NUMBER_ID, status: chatOptions.userToInvite?.status ?? undefined}) ) { + const modifiedUserToInvite = [chatOptions.userToInvite].map((participant) => { + const isPolicyExpenseChat = participant?.isPolicyExpenseChat ?? false; + return isPolicyExpenseChat ? getPolicyExpenseReportOption(participant, reportAttributesDerived) : getParticipantsOption(participant, personalDetails); + }); + newSections.push({ title: undefined, - data: [chatOptions.userToInvite].map((participant) => { - const isPolicyExpenseChat = participant?.isPolicyExpenseChat ?? false; - return isPolicyExpenseChat ? getPolicyExpenseReportOption(participant, reportAttributesDerived) : getParticipantsOption(participant, personalDetails); - }), + data: modifiedUserToInvite, shouldShow: true, }); + if (!firstKey) { + firstKey = getFirstSelectedItem(modifiedUserToInvite); + } } const headerMessage = getHeaderMessage( @@ -187,7 +237,7 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde attendees.some((attendee) => getPersonalDetailSearchTerms(attendee).join(' ').toLowerCase().includes(cleanSearchTerm)), ); - return [newSections, headerMessage]; + return {sections: newSections, header: headerMessage, firstKeyForList: firstKey}; }, [ areOptionsInitialized, didScreenTransitionEnd, @@ -313,6 +363,8 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde canSelectMultiple isLoadingNewOptions={!!isSearchingForReports} shouldShowListEmptyContent={shouldShowListEmptyContent} + initiallyFocusedOptionKey={firstKeyForList} + getItemHeight={() => variables.optionRowHeight} /> ); } diff --git a/src/pages/workspace/WorkspaceInvitePage.tsx b/src/pages/workspace/WorkspaceInvitePage.tsx index fa8647289204..6e73285d0113 100644 --- a/src/pages/workspace/WorkspaceInvitePage.tsx +++ b/src/pages/workspace/WorkspaceInvitePage.tsx @@ -23,12 +23,13 @@ import HttpUtils from '@libs/HttpUtils'; import {appendCountryCode} from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {filterAndOrderOptions, formatMemberForList, getHeaderMessage, getMemberInviteOptions, getSearchValueForPhoneOrEmail} from '@libs/OptionsListUtils'; +import {filterAndOrderOptions, formatMemberForList, getFirstSelectedItem, getHeaderMessage, getMemberInviteOptions, getSearchValueForPhoneOrEmail} from '@libs/OptionsListUtils'; import type {MemberForList} from '@libs/OptionsListUtils'; import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber'; import {getIneligibleInvitees, getMemberAccountIDsForWorkspace, goBackFromInvalidPolicy} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -54,10 +55,10 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { const [personalDetails, setPersonalDetails] = useState([]); const [usersToInvite, setUsersToInvite] = useState([]); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); - const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); + const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false, canBeMissing: true}); const firstRenderRef = useRef(true); - const [betas] = useOnyx(ONYXKEYS.BETAS); - const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`); + const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); + const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`, {canBeMissing: true}); const openWorkspaceInvitePage = () => { const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList); @@ -163,24 +164,28 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change }, [options.personalDetails, policy?.employeeList, betas, debouncedSearchTerm, excludedUsers, areOptionsInitialized, inviteOptions.personalDetails, inviteOptions.userToInvite]); - const sections: MembersSection[] = useMemo(() => { + const {sections, firstKeyForList} = useMemo(() => { const sectionsArr: MembersSection[] = []; + let firstKey = ''; if (!areOptionsInitialized) { - return []; + return {sections: [], firstKeyForList: firstKey}; } - // Filter all options that is a part of the search term or in the personal details - let filterSelectedOptions = selectedOptions; - if (debouncedSearchTerm !== '') { - filterSelectedOptions = selectedOptions.filter((option) => { - const accountID = option.accountID; - const isOptionInPersonalDetails = Object.values(personalDetails).some((personalDetail) => personalDetail.accountID === accountID); + const selectedLogins = selectedOptions.map(({login}) => login); + const personalDetailsModified = Object.values(personalDetails).map((item) => + selectedLogins.some((selectedLogin) => item.login === selectedLogin) ? {...item, isSelected: true} : item, + ); + const personalDetailsFormatted = personalDetailsModified.map((item) => formatMemberForList(item)); + // Filter selected participants which are part of personalDetails + let filterSelectedOptions = selectedOptions.filter((participant) => !personalDetails.some((item) => participant.login === item.login)); + if (debouncedSearchTerm !== '') { + filterSelectedOptions = filterSelectedOptions.filter((option) => { const searchValue = getSearchValueForPhoneOrEmail(debouncedSearchTerm); const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(searchValue) || !!option.login?.toLowerCase().includes(searchValue); - return isPartOfSearchTerm || isOptionInPersonalDetails; + return isPartOfSearchTerm; }); } @@ -189,17 +194,18 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { data: filterSelectedOptions, shouldShow: true, }); - - // Filtering out selected users from the search results - const selectedLogins = selectedOptions.map(({login}) => login); - const personalDetailsWithoutSelected = Object.values(personalDetails).filter(({login}) => !selectedLogins.some((selectedLogin) => selectedLogin === login)); - const personalDetailsFormatted = personalDetailsWithoutSelected.map((item) => formatMemberForList(item)); + if (!firstKey) { + firstKey = getFirstSelectedItem(filterSelectedOptions); + } sectionsArr.push({ title: translate('common.contacts'), data: personalDetailsFormatted, shouldShow: !isEmptyObject(personalDetailsFormatted), }); + if (!firstKey) { + firstKey = getFirstSelectedItem(personalDetailsFormatted); + } Object.values(usersToInvite).forEach((userToInvite) => { const hasUnselectedUserToInvite = !selectedLogins.some((selectedLogin) => selectedLogin === userToInvite.login); @@ -213,8 +219,8 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { } }); - return sectionsArr; - }, [areOptionsInitialized, selectedOptions, debouncedSearchTerm, personalDetails, translate, usersToInvite]); + return {sections: sectionsArr, firstKeyForList: firstKey}; + }, [debouncedSearchTerm, areOptionsInitialized, selectedOptions, personalDetails, translate, usersToInvite]); const toggleOption = (option: MemberForList) => { clearErrors(route.params.policyID); @@ -332,6 +338,8 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { footerContent={footerContent} isLoadingNewOptions={!!isSearchingForReports} addBottomSafeAreaPadding + initiallyFocusedOptionKey={firstKeyForList} + getItemHeight={() => variables.optionRowHeight} /> diff --git a/tests/ui/NewChatPageTest.tsx b/tests/ui/NewChatPageTest.tsx index 15596a2611d2..5648eb40b1ed 100644 --- a/tests/ui/NewChatPageTest.tsx +++ b/tests/ui/NewChatPageTest.tsx @@ -1,7 +1,6 @@ import * as NativeNavigation from '@react-navigation/native'; import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/react-native'; import React from 'react'; -import {SectionList} from 'react-native'; import Onyx from 'react-native-onyx'; import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; @@ -13,7 +12,6 @@ import NewChatPage from '@pages/NewChatPage'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {NativeNavigationMock} from '../../__mocks__/@react-navigation/native'; -import {fakePersonalDetails} from '../utils/LHNTestUtils'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -67,22 +65,6 @@ describe('NewChatPage', () => { await waitForBatchedUpdates(); }); - it('should scroll to top when adding a user to the group selection', async () => { - await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); - render(, {wrapper}); - await waitForBatchedUpdatesWithAct(); - act(() => { - (NativeNavigation as NativeNavigationMock).triggerTransitionEnd(); - }); - const spy = jest.spyOn(SectionList.prototype, 'scrollToLocation'); - - const addButton = await waitFor(() => screen.getAllByText(translateLocal('newChatPage.addToGroup')).at(0)); - if (addButton) { - fireEvent.press(addButton); - expect(spy).toHaveBeenCalledWith(expect.objectContaining({itemIndex: 0})); - } - }); - describe('should not display "Add to group" button on expensify emails', () => { const excludedGroupEmails = CONST.EXPENSIFY_EMAILS.filter((value) => value !== CONST.EMAIL.CONCIERGE && value !== CONST.EMAIL.NOTIFICATIONS).map((email) => [email]); diff --git a/tests/unit/BaseSelectionListTest.tsx b/tests/unit/BaseSelectionListTest.tsx index 183757456808..febf4d6fe8a9 100644 --- a/tests/unit/BaseSelectionListTest.tsx +++ b/tests/unit/BaseSelectionListTest.tsx @@ -1,7 +1,6 @@ import * as NativeNavigation from '@react-navigation/native'; import {fireEvent, render, screen} from '@testing-library/react-native'; import {useState} from 'react'; -import {SectionList} from 'react-native'; import BaseSelectionList from '@components/SelectionList/BaseSelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; import type {ListItem, SelectionListProps} from '@components/SelectionList/types'; @@ -103,18 +102,6 @@ describe('BaseSelectionList', () => { expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}2`)).toBeSelected(); }); - it('should scroll to top when selecting a multi option list', () => { - const spy = jest.spyOn(SectionList.prototype, 'scrollToLocation'); - render( - , - ); - fireEvent.press(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)); - expect(spy).toHaveBeenCalledWith(expect.objectContaining({itemIndex: 0})); - }); - it('should show only elements from first page and Show More button when items exceed page limit', () => { render( { illustrationsMock as IllustrationsType, ); - expect(result.unselected.length + result.selected.length).toEqual(13); + expect(result.length).toEqual(13); // Check if Expensify card was built correctly - const expensifyCard = result.selected.find((card) => card.keyForList === '21588678'); + const expensifyCard = result.find((card) => card.keyForList === '21588678'); expect(expensifyCard).toMatchObject({ lastFourPAN: '1138', isSelected: true, }); // Check if company card was built correctly - const companyCard = result.unselected.find((card) => card.keyForList === '21604933'); + const companyCard = result.find((card) => card.keyForList === '21604933'); expect(companyCard).toMatchObject({ lastFourPAN: '1601', isSelected: false, @@ -354,7 +354,7 @@ describe('buildIndividualCardsData', () => { [], illustrationsMock as IllustrationsType, ); - expect(result.unselected.length + result.selected.length).toEqual(0); + expect(result.length).toEqual(0); }); }); @@ -368,17 +368,17 @@ describe('buildCardsData closed cards', () => { illustrationsMock as IllustrationsType, true, ); - expect(result.unselected.length + result.selected.length).toEqual(4); + expect(result.length).toEqual(4); // Check if Expensify card was built correctly - const expensifyCard = result.selected.find((card) => card.keyForList === '21539012'); + const expensifyCard = result.find((card) => card.keyForList === '21539012'); expect(expensifyCard).toMatchObject({ lastFourPAN: '3211', isSelected: true, }); // Check if company card was built correctly - const companyCard = result.unselected.find((card) => card.keyForList === '21534525'); + const companyCard = result.find((card) => card.keyForList === '21534525'); expect(companyCard).toMatchObject({ lastFourPAN: '', isSelected: false, @@ -389,7 +389,7 @@ describe('buildCardsData closed cards', () => { describe('buildCardsData with empty argument objects', () => { it('Returns empty array when cardList and workspaceCardFeeds are empty', () => { const result = buildCardsData({}, {}, {}, [], illustrationsMock as IllustrationsType); - expect(result).toEqual({selected: [], unselected: []}); + expect(result).toEqual([]); }); }); @@ -404,38 +404,38 @@ describe('buildCardFeedsData', () => { it('Build domain card feed properly', () => { // Check if external domain feed was built properly - expect(result.unselected.at(0)).toMatchObject({ + expect(result.at(0)).toMatchObject({ isCardFeed: true, correspondingCards: ['21589168', '21589182'], }); expect(translateMock).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: undefined, cardFeedLabel: 'mockDomain.com'}); // Check if domain card feed was built properly - expect(result.unselected.at(1)).toMatchObject({ + expect(result.at(1)).toMatchObject({ isCardFeed: true, correspondingCards: ['21593492', '21604933', '21638320', '21638598'], }); expect(translateMock).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: 'Visa', cardFeedLabel: undefined}); // Check if workspace card feed that comes from company cards was built properly. - expect(result.unselected.at(2)).toMatchObject({ + expect(result.at(2)).toMatchObject({ isCardFeed: true, correspondingCards: ['21588678', '21588684'], }); expect(translateMock).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: undefined, cardFeedLabel: 'test1'}); // Check if workspace card feed that comes from expensify cards was built properly - expect(result.unselected.at(3)).toMatchObject({ + expect(result.at(3)).toMatchObject({ isCardFeed: true, correspondingCards: ['21589168', '21589182', '21589202', '21638322'], }); expect(translateMock).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: undefined, cardFeedLabel: 'test2'}); // Check if domain card feed was built properly - expect(result.unselected.length).toEqual(4); + expect(result.length).toEqual(4); }); }); describe('buildIndividualCardsData with empty argument objects', () => { it('Return empty array when domainCardFeeds and workspaceCardFeeds are empty', () => { const result = buildCardFeedsData({}, {}, [], translateMock as LocaleContextProps['translate'], illustrationsMock as IllustrationsType); - expect(result).toEqual({selected: [], unselected: []}); + expect(result).toEqual([]); }); });