diff --git a/src/components/OptionListContextProvider.tsx b/src/components/OptionListContextProvider.tsx index 4ca8e24444f8..466a36fc1187 100644 --- a/src/components/OptionListContextProvider.tsx +++ b/src/components/OptionListContextProvider.tsx @@ -263,7 +263,6 @@ const useOptionsListContext = () => useContext(OptionsListContext); const useOptionsList = (options?: {shouldInitialize: boolean}) => { const {shouldInitialize = true} = options ?? {}; const {initializeOptions, options: optionsList, areOptionsInitialized, resetOptions} = useOptionsListContext(); - const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: false}); const [internalOptions, setInternalOptions] = useState(optionsList); const prevOptions = useRef(null); @@ -286,12 +285,12 @@ const useOptionsList = (options?: {shouldInitialize: boolean}) => { }, [optionsList]); useEffect(() => { - if (!shouldInitialize || areOptionsInitialized || isLoadingApp) { + if (!shouldInitialize || areOptionsInitialized) { return; } initializeOptions(); - }, [shouldInitialize, initializeOptions, areOptionsInitialized, isLoadingApp]); + }, [shouldInitialize, initializeOptions, areOptionsInitialized]); return useMemo( () => ({ diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 1c9e2ce1bd8f..b329a582f58d 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -1,4 +1,3 @@ -import {Str} from 'expensify-common'; import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useMemo, useState} from 'react'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -11,7 +10,6 @@ import SearchQueryListItem, {isSearchQueryItem} from '@components/SelectionList/ import type {SectionListDataType, SelectionListHandle, UserListItemProps} from '@components/SelectionList/types'; import UserListItem from '@components/SelectionList/UserListItem'; import useDebounce from '@hooks/useDebounce'; -import useFastSearchFromOptions from '@hooks/useFastSearchFromOptions'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -19,9 +17,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getCardFeedsForDisplay} from '@libs/CardFeedUtils'; import {getCardDescription, isCard, isCardHiddenFromSearch} from '@libs/CardUtils'; import Log from '@libs/Log'; -import memoize from '@libs/memoize'; -import type {Options, SearchOption} from '@libs/OptionsListUtils'; -import {combineOrderingOfReportsAndPersonalDetails, getSearchOptions, getValidPersonalDetailOptions, optionsOrderBy, recentReportComparator} from '@libs/OptionsListUtils'; +import type {Options} from '@libs/OptionsListUtils'; +import {combineOrderingOfReportsAndPersonalDetails, getSearchOptions} from '@libs/OptionsListUtils'; import Performance from '@libs/Performance'; import {getAllTaxRates, getCleanedTagName, shouldShowPolicy} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -40,7 +37,6 @@ import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {CardFeeds, CardList, PersonalDetailsList, Policy, Report} from '@src/types/onyx'; -import type PersonalDetails from '@src/types/onyx/PersonalDetails'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; import {getSubstitutionMapKey} from './SearchRouter/getQueryWithSubstitutions'; import type {SearchFilterKey, UserFriendlyKey} from './types'; @@ -178,8 +174,8 @@ function SearchAutocompleteList( if (!areOptionsInitialized) { return defaultListOptions; } - return getSearchOptions(options, betas ?? []); - }, [areOptionsInitialized, betas, options]); + return getSearchOptions(options, betas ?? [], true, true, autocompleteQueryValue, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS, true); + }, [areOptionsInitialized, betas, options, autocompleteQueryValue]); const [isInitialRender, setIsInitialRender] = useState(true); @@ -217,38 +213,6 @@ function SearchAutocompleteList( return Object.values(getCardFeedsForDisplay(allFeeds, {})); }, [allFeeds]); - const getParticipantsAutocompleteList = useMemo( - () => - memoize(() => { - if (!areOptionsInitialized) { - return []; - } - - const currentUserRef = { - current: undefined as OptionData | undefined, - }; - const filteredOptions = getValidPersonalDetailOptions(options.personalDetails, { - loginsToExclude: CONST.EXPENSIFY_EMAILS_OBJECT, - shouldBoldTitleByDefault: false, - currentUserRef, - }); - - // This cast is needed as something is incorrect in types OptionsListUtils.getOptions around l1490 and includeRecentReports types - const personalDetailsFromOptions = filteredOptions.map((option) => (option as SearchOption).item); - const autocompleteOptions = Object.values(personalDetailsFromOptions) - .filter((details): details is NonNullable => !!details?.login) - .map((details) => { - return { - name: details.displayName ?? Str.removeSMSDomain(details.login ?? ''), - accountID: details.accountID.toString(), - }; - }); - - return autocompleteOptions; - }), - [areOptionsInitialized, options.personalDetails], - ); - const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(taxRates), [taxRates]); const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, {canBeMissing: false}); @@ -357,22 +321,21 @@ function SearchAutocompleteList( case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: case CONST.SEARCH.SYNTAX_FILTER_KEYS.PAYER: case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTER: { - const filteredParticipants = getParticipantsAutocompleteList() - .filter((participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase())) - .slice(0, 10); + const participants = getSearchOptions(options, betas ?? [], true, true, autocompleteValue, 10, false, false).personalDetails.filter( + (participant) => participant.text && !alreadyAutocompletedKeys.includes(participant.text.toLowerCase()), + ); - return filteredParticipants.map((participant) => ({ + return participants.map((participant) => ({ filterKey: autocompleteKey, - text: participant.name, - autocompleteID: participant.accountID, + text: participant.text ?? '', + autocompleteID: String(participant.accountID), mapKey: autocompleteKey, })); } case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN: { - const filterChats = (chat: OptionData) => chat.text?.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(chat.text.toLowerCase()); - const filteredChats = optionsOrderBy(searchOptions.recentReports, 10, recentReportComparator, filterChats); + const filteredReports = getSearchOptions(options, betas ?? [], true, true, autocompleteValue, 10, false, true).recentReports; - return filteredChats.map((chat) => ({ + return filteredReports.map((chat) => ({ filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.IN, text: chat.text ?? '', autocompleteID: chat.reportID, @@ -489,8 +452,8 @@ function SearchAutocompleteList( currencyAutocompleteList, recentCurrencyAutocompleteList, taxAutocompleteList, - getParticipantsAutocompleteList, - searchOptions.recentReports, + options, + betas, typeAutocompleteList, groupByAutocompleteList, statusAutocompleteList, @@ -517,11 +480,6 @@ function SearchAutocompleteList( }; }); - /** - * Builds a suffix tree and returns a function to search in it. - */ - const {search: filterOptions, isInitialized: isFastSearchInitialized} = useFastSearchFromOptions(searchOptions, {includeUserToInvite: true}); - const recentReportsOptions = useMemo(() => { const actionId = `filter_options_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; const startTime = Date.now(); @@ -532,37 +490,32 @@ function SearchAutocompleteList( actionId, queryLength: autocompleteQueryValue.length, queryTrimmed: autocompleteQueryValue.trim(), - isFastSearchInitialized, recentReportsCount: searchOptions.recentReports.length, timestamp: startTime, }); try { - if (autocompleteQueryValue.trim() === '' || !isFastSearchInitialized) { - const orderedReportOptions = optionsOrderBy(searchOptions.recentReports, 20, recentReportComparator); - + if (autocompleteQueryValue.trim() === '') { const endTime = Date.now(); Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); Performance.markEnd(CONST.TIMING.SEARCH_FILTER_OPTIONS); Log.info('[CMD_K_DEBUG] Filter options completed (empty query path)', false, { actionId, duration: endTime - startTime, - resultCount: orderedReportOptions.length, timestamp: endTime, }); - return orderedReportOptions; + return searchOptions.recentReports; } - const filteredOptions = filterOptions(autocompleteQueryValue); - const orderedOptions = combineOrderingOfReportsAndPersonalDetails(filteredOptions, autocompleteQueryValue, { + const orderedOptions = combineOrderingOfReportsAndPersonalDetails(searchOptions, autocompleteQueryValue, { sortByReportTypeInSearch: true, preferChatRoomsOverThreads: true, }); const reportOptions: OptionData[] = [...orderedOptions.recentReports, ...orderedOptions.personalDetails]; - if (filteredOptions.userToInvite) { - reportOptions.push(filteredOptions.userToInvite); + if (searchOptions.userToInvite) { + reportOptions.push(searchOptions.userToInvite); } const finalOptions = reportOptions.slice(0, 20); @@ -574,7 +527,7 @@ function SearchAutocompleteList( duration: endTime - startTime, recentReportsFiltered: orderedOptions.recentReports.length, personalDetailsFiltered: orderedOptions.personalDetails.length, - hasUserToInvite: !!filteredOptions.userToInvite, + hasUserToInvite: !!searchOptions.userToInvite, finalResultCount: finalOptions.length, timestamp: endTime, }); @@ -593,7 +546,7 @@ function SearchAutocompleteList( }); throw error; } - }, [autocompleteQueryValue, filterOptions, searchOptions, isFastSearchInitialized]); + }, [autocompleteQueryValue, searchOptions]); const debounceHandleSearch = useDebounce( useCallback(() => { diff --git a/src/libs/MaxHeap.ts b/src/libs/MaxHeap.ts new file mode 100644 index 000000000000..cf27e9933f8c --- /dev/null +++ b/src/libs/MaxHeap.ts @@ -0,0 +1,86 @@ +import {Heap} from './Heap'; + +type GetCompareValue = (item: T) => number | string; + +/** + * Comparison function for a max-heap based on the provided `getCompareValue` function. + * @param getCompareValue + */ +function getMaxCompare(getCompareValue?: GetCompareValue): (a: T, b: T) => number { + return (a, b) => { + const aVal = typeof getCompareValue === 'function' ? getCompareValue(a) : a; + const bVal = typeof getCompareValue === 'function' ? getCompareValue(b) : b; + return aVal >= bVal ? -1 : 1; + }; +} + +/** + * MaxHeap is a priority queue that always keeps the largest element at the top. + * Internally, it uses a binary heap structure to ensure that insertion (`push`) + * and removal of the maximum element (`pop`) are efficient, both operating in O(log n) time. + * + * The heap is constructed using a comparator derived from an optional `getCompareValue` function, + * which allows comparing complex objects based on a specific property. + * If no comparison function is provided, direct value comparison is used. + * + * Typical use cases include: + * - Finding the largest element in a dynamic dataset + * - Implementing efficient top-k queries (e.g., 10 largest items from a large list) + * - Scheduling or prioritizing tasks based on weight or priority value + * + * Elements can be added via `push`, removed with `pop`, and inspected with `peek`. + * The heap also supports iteration, which destructively yields elements in descending order. + * + * Example: + * ```ts + * const heap = new MaxHeap(); + * heap.push(4).push(1).push(3); + * console.log(heap.pop()); // 4 + * ``` + */ +class MaxHeap { + private heap: Heap; + + constructor(getCompareValue?: GetCompareValue) { + this.heap = new Heap(getMaxCompare(getCompareValue)); + } + + push(value: T): this { + this.heap.push(value); + return this; + } + + pop(): T | null { + return this.heap.pop(); + } + + peek(): T | null { + return this.heap.peek(); + } + + size(): number { + return this.heap.size(); + } + + isEmpty(): boolean { + return this.heap.isEmpty(); + } + + clear(): void { + this.heap.clear(); + } + + *[Symbol.iterator](): Iterator { + let size = this.size(); + while (size-- > 0) { + const poppedValue = this.pop(); + if (poppedValue === null) { + break; + } + yield poppedValue; + } + } +} + +export type {GetCompareValue}; +export {MaxHeap}; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 54317be63b9d..5401cf0514be 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/prefer-for-of */ import {Str} from 'expensify-common'; +import deburr from 'lodash/deburr'; import keyBy from 'lodash/keyBy'; import lodashOrderBy from 'lodash/orderBy'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -41,6 +42,7 @@ import localeCompare from './LocaleCompare'; import {formatPhoneNumber} from './LocalePhoneNumber'; import {translateLocal} from './Localize'; import {appendCountryCode, getPhoneNumberWithoutSpecialChars} from './LoginUtils'; +import {MaxHeap} from './MaxHeap'; import {MinHeap} from './MinHeap'; import ModifiedExpenseMessage from './ModifiedExpenseMessage'; import Navigation from './Navigation/Navigation'; @@ -246,6 +248,9 @@ type GetOptionsConfig = { recentAttendees?: Option[]; excludeHiddenThreads?: boolean; canShowManagerMcTest?: boolean; + searchString?: string; + maxElements?: number; + includeUserToInvite?: boolean; } & GetValidReportsConfig; type GetUserToInviteConfig = { @@ -1331,27 +1336,42 @@ function orderReportOptions(options: OptionData[]) { return lodashOrderBy(options, [sortComparatorReportOptionByArchivedStatus, sortComparatorReportOptionByDate], ['asc', 'desc']); } +/** + * Sort personal details by displayName or login in alphabetical order + */ +const personalDetailsComparator = (personalDetail: OptionData) => { + const name = personalDetail.text ?? personalDetail.alternateText ?? personalDetail.login ?? ''; + return name.toLowerCase(); +}; + +/** + * Sort reports by archived status and last visible action + */ const recentReportComparator = (option: OptionData) => { return `${option.private_isArchived ? 0 : 1}_${option.lastVisibleActionCreated ?? ''}`; }; -function optionsOrderBy(options: OptionData[], limit: number, comparator: (option: OptionData) => number | string, filter?: (option: OptionData) => boolean | undefined): OptionData[] { +/** + * Sort options by a given comparator and return first sorted options. + * Function uses a min heap to efficiently get the first sorted options. + */ +function optionsOrderBy(options: T[], comparator: (option: T) => number | string, limit?: number, filter?: (option: T) => boolean | undefined, reversed = false): T[] { Timing.start(CONST.TIMING.SEARCH_MOST_RECENT_OPTIONS); - const heap = new MinHeap(comparator); + const heap = reversed ? new MaxHeap(comparator) : new MinHeap(comparator); options.forEach((option) => { if (filter && !filter(option)) { return; } - if (heap.size() < limit) { - heap.push(option); - return; - } - const peekedValue = heap.peek(); - if (!peekedValue) { - throw new Error('Heap is empty, cannot peek value'); - } - if (comparator(option) > comparator(peekedValue)) { - heap.pop(); + if (limit && heap.size() >= limit) { + const peekedValue = heap.peek(); + if (!peekedValue) { + throw new Error('Heap is empty, cannot peek value'); + } + if (comparator(option) > comparator(peekedValue)) { + heap.pop(); + heap.push(option); + } + } else { heap.push(option); } }); @@ -1635,12 +1655,10 @@ function getUserToInviteContactOption({ return userToInvite; } -function getValidReports(reports: OptionList['reports'], config: GetValidReportsConfig): GetValidReportsReturnTypeCombined { +function isValidReport(reportOption: SearchOption, config: GetValidReportsConfig): boolean { const { betas = [], includeMultipleParticipantReports = false, - showChatPreviewLine = false, - forcePolicyNamePreview = false, includeOwnedWorkspaceChats = false, includeThreads = false, includeTasks = false, @@ -1650,131 +1668,142 @@ function getValidReports(reports: OptionList['reports'], config: GetValidReports includeSelfDM = false, includeInvoiceRooms = false, action, - selectedOptions = [], includeP2P = true, includeDomainEmail = false, - shouldBoldTitleByDefault = true, loginsToExclude = {}, - shouldSeparateSelfDMChat, - shouldSeparateWorkspaceChat, excludeNonAdminWorkspaces, - isPerDiemRequest = false, - showRBR = true, } = config; const topmostReportId = Navigation.getTopmostReportId(); - const validReportOptions: OptionData[] = []; - const workspaceChats: OptionData[] = []; - let selfDMChat: OptionData | undefined; - const preferRecentExpenseReports = action === CONST.IOU.ACTION.CREATE; + // eslint-disable-next-line rulesdir/prefer-at + const option = reportOption; + const report = reportOption.item; + const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${option.chatReportID}`]; + const doesReportHaveViolations = shouldDisplayViolationsRBRInLHN(report, transactionViolations); - for (let i = 0; i < reports.length; i++) { - // eslint-disable-next-line rulesdir/prefer-at - const option = reports[i]; - const report = option.item; - const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.chatReportID}`]; - const doesReportHaveViolations = shouldDisplayViolationsRBRInLHN(report, transactionViolations); + const shouldBeInOptionList = shouldReportBeInOptionList({ + report, + chatReport, + currentReportId: topmostReportId, + betas, + doesReportHaveViolations, + isInFocusMode: false, + excludeEmptyChats: false, + includeSelfDM, + login: option.login, + includeDomainEmail, + isReportArchived: !!option.private_isArchived, + }); - const shouldBeInOptionList = shouldReportBeInOptionList({ - report, - chatReport, - currentReportId: topmostReportId, - betas, - doesReportHaveViolations, - isInFocusMode: false, - excludeEmptyChats: false, - includeSelfDM, - login: option.login, - includeDomainEmail, - isReportArchived: !!option.private_isArchived, - }); + if (!shouldBeInOptionList) { + return false; + } - if (!shouldBeInOptionList) { - continue; - } + const isThread = option.isThread; + const isTaskReport = option.isTaskReport; + const isPolicyExpenseChat = option.isPolicyExpenseChat; + const isMoneyRequestReport = option.isMoneyRequestReport; + const isSelfDM = option.isSelfDM; + const isChatRoom = option.isChatRoom; + const accountIDs = getParticipantsAccountIDsForDisplay(report); - const isThread = option.isThread; - const isTaskReport = option.isTaskReport; - const isPolicyExpenseChat = option.isPolicyExpenseChat; - const isMoneyRequestReport = option.isMoneyRequestReport; - const isSelfDM = option.isSelfDM; - const isChatRoom = option.isChatRoom; - const accountIDs = getParticipantsAccountIDsForDisplay(report); + if (excludeNonAdminWorkspaces && !isPolicyAdmin(option.policyID, policies)) { + return false; + } - if (excludeNonAdminWorkspaces && !isPolicyAdmin(option.policyID, policies)) { - continue; - } + if (isPolicyExpenseChat && report?.isOwnPolicyExpenseChat && !includeOwnedWorkspaceChats) { + return false; + } + // When passing includeP2P false we are trying to hide features from users that are not ready for P2P and limited to expense chats only. + if (!includeP2P && !isPolicyExpenseChat) { + return false; + } - if (isPolicyExpenseChat && report.isOwnPolicyExpenseChat && !includeOwnedWorkspaceChats) { - continue; - } + if (isSelfDM && !includeSelfDM) { + return false; + } - // When passing includeP2P false we are trying to hide features from users that are not ready for P2P and limited to expense chats only. - if (!includeP2P && !isPolicyExpenseChat) { - continue; - } + if (isThread && !includeThreads) { + return false; + } - if (isSelfDM && !includeSelfDM) { - continue; - } + if (isTaskReport && !includeTasks) { + return false; + } - if (isThread && !includeThreads) { - continue; - } + if (isMoneyRequestReport && !includeMoneyRequests) { + return false; + } - if (isTaskReport && !includeTasks) { - continue; - } + if (!canUserPerformWriteAction(report) && !includeReadOnly) { + return false; + } - if (isMoneyRequestReport && !includeMoneyRequests) { - continue; - } + // In case user needs to add credit bank account, don't allow them to submit an expense from the workspace. + if (includeOwnedWorkspaceChats && hasIOUWaitingOnCurrentUserBankAccount(report)) { + return false; + } - if (!canUserPerformWriteAction(report) && !includeReadOnly) { - continue; - } + if ((!accountIDs || accountIDs.length === 0) && !isChatRoom) { + return false; + } - // In case user needs to add credit bank account, don't allow them to submit an expense from the workspace. - if (includeOwnedWorkspaceChats && hasIOUWaitingOnCurrentUserBankAccount(report)) { - continue; - } + if (option.login === CONST.EMAIL.NOTIFICATIONS) { + return false; + } + const isCurrentUserOwnedPolicyExpenseChatThatCouldShow = + option.isPolicyExpenseChat && option.ownerAccountID === currentUserAccountID && includeOwnedWorkspaceChats && !option.private_isArchived; - if ((!accountIDs || accountIDs.length === 0) && !isChatRoom) { - continue; - } + const shouldShowInvoiceRoom = + includeInvoiceRooms && isInvoiceRoom(report) && isPolicyAdmin(option.policyID, policies) && !option.private_isArchived && canSendInvoiceFromWorkspace(report.policyID); - if (option.login === CONST.EMAIL.NOTIFICATIONS) { - continue; - } + /* + Exclude the report option if it doesn't meet any of the following conditions: + - It is not an owned policy expense chat that could be shown + - Multiple participant reports are not included + - It doesn't have a login + - It is not an invoice room that should be shown + */ + if (!isCurrentUserOwnedPolicyExpenseChatThatCouldShow && !includeMultipleParticipantReports && !option.login && !shouldShowInvoiceRoom) { + return false; + } - const isCurrentUserOwnedPolicyExpenseChatThatCouldShow = - option.isPolicyExpenseChat && option.ownerAccountID === currentUserAccountID && includeOwnedWorkspaceChats && !option.private_isArchived; - - const shouldShowInvoiceRoom = - includeInvoiceRooms && isInvoiceRoom(option.item) && isPolicyAdmin(option.policyID, policies) && !option.private_isArchived && canSendInvoiceFromWorkspace(option.policyID); - - /* - Exclude the report option if it doesn't meet any of the following conditions: - - It is not an owned policy expense chat that could be shown - - Multiple participant reports are not included - - It doesn't have a login - - It is not an invoice room that should be shown - */ - if (!isCurrentUserOwnedPolicyExpenseChatThatCouldShow && !includeMultipleParticipantReports && !option.login && !shouldShowInvoiceRoom) { - continue; - } + // If we're excluding threads, check the report to see if it has a single participant and if the participant is already selected + if (!includeThreads && ((!!option.login && loginsToExclude[option.login]) || loginsToExclude[option.reportID])) { + return false; + } - // If we're excluding threads, check the report to see if it has a single participant and if the participant is already selected - if (!includeThreads && ((!!option.login && loginsToExclude[option.login]) || loginsToExclude[option.reportID])) { - continue; + if (action === CONST.IOU.ACTION.CATEGORIZE) { + const reportPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${option.policyID}`]; + if (!reportPolicy?.areCategoriesEnabled) { + return false; } + } + return true; +} - if (action === CONST.IOU.ACTION.CATEGORIZE) { - const reportPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${option.policyID}`]; - if (!reportPolicy?.areCategoriesEnabled) { - continue; - } - } +function getValidReports(reports: OptionList['reports'], config: GetValidReportsConfig): GetValidReportsReturnTypeCombined { + const { + showChatPreviewLine = false, + forcePolicyNamePreview = false, + action, + selectedOptions = [], + shouldBoldTitleByDefault = true, + shouldSeparateSelfDMChat, + shouldSeparateWorkspaceChat, + isPerDiemRequest = false, + showRBR = true, + } = config; + + const validReportOptions: OptionData[] = []; + const workspaceChats: OptionData[] = []; + let selfDMChat: OptionData | undefined; + const preferRecentExpenseReports = action === CONST.IOU.ACTION.CREATE; + + for (let i = 0; i < reports.length; i++) { + // eslint-disable-next-line rulesdir/prefer-at + const option = reports[i]; + const report = option.item; /** * By default, generated options does not have the chat preview line enabled. @@ -1854,55 +1883,6 @@ function isManagerMcTestReport(report: SearchOption): boolean { return report.participantsList?.some((participant) => participant.accountID === CONST.ACCOUNT_ID.MANAGER_MCTEST) ?? false; } -function getValidPersonalDetailOptions( - options: OptionList['personalDetails'], - { - loginsToExclude = {}, - includeDomainEmail = false, - shouldBoldTitleByDefault = false, - currentUserRef, - }: { - loginsToExclude?: Record; - includeDomainEmail?: boolean; - shouldBoldTitleByDefault: boolean; - // If the current user is found in the options and you pass an object ref, it will be assigned - currentUserRef?: { - current?: OptionData; - }; - }, -) { - const personalDetailsOptions: OptionData[] = []; - for (let i = 0; i < options.length; i++) { - // eslint-disable-next-line rulesdir/prefer-at - const detail = options[i]; - if ( - !detail?.login || - !detail.accountID || - !!detail?.isOptimisticPersonalDetail || - (!includeDomainEmail && Str.isDomainEmail(detail.login)) || - // Exclude the setup specialist from the list of personal details as it's a fallback if guide is not assigned - detail?.login === CONST.SETUP_SPECIALIST_LOGIN - ) { - continue; - } - - if (currentUserRef && !!currentUserLogin && detail.login === currentUserLogin) { - // eslint-disable-next-line no-param-reassign - currentUserRef.current = detail; - } - - if (loginsToExclude[detail.login]) { - continue; - } - - detail.isBold = shouldBoldTitleByDefault; - - personalDetailsOptions.push(detail); - } - - return personalDetailsOptions; -} - /** * Returns a list of logins that should be restricted (i.e., hidden or excluded in the UI) * based on dynamic business logic and feature flags. @@ -1934,6 +1914,9 @@ function getValidOptions( shouldSeparateWorkspaceChat = false, excludeHiddenThreads = false, canShowManagerMcTest = false, + searchString, + maxElements, + includeUserToInvite = false, ...config }: GetOptionsConfig = {}, ): Options { @@ -1958,21 +1941,60 @@ function getValidOptions( } const {includeP2P = true, shouldBoldTitleByDefault = true, includeDomainEmail = false, ...getValidReportsConfig} = config; + let filteredReports = options.reports; + // Get valid recent reports: let recentReportOptions: OptionData[] = []; let workspaceChats: OptionData[] = []; let selfDMChat: OptionData | undefined; + if (includeRecentReports) { - const {recentReports, workspaceOptions, selfDMOption} = getValidReports(options.reports, { + // if maxElements is passed, filter the recent reports by searchString and return only most recent reports (@see recentReportsComparator) + const searchTerms = deburr(searchString ?? '') + .toLowerCase() + .split(' ') + .filter((term) => term.length > 0); + + const filteringFunction = (report: SearchOption) => { + let searchText = `${report.text ?? ''}${report.login ?? ''}`; + + if (report.isThread) { + searchText += report.alternateText ?? ''; + } else if (report.isChatRoom) { + searchText += report.subtitle ?? ''; + } else if (report.isPolicyExpenseChat) { + searchText += `${report.subtitle ?? ''}${report.policyName ?? ''}`; + } + searchText = deburr(searchText.toLocaleLowerCase()); + const searchTermsFound = searchTerms.length > 0 ? searchTerms.every((term) => searchText.includes(term)) : true; + + if (!searchTermsFound) { + return false; + } + + return isValidReport(report, { + ...getValidReportsConfig, + includeP2P, + includeDomainEmail, + selectedOptions, + loginsToExclude, + shouldBoldTitleByDefault, + shouldSeparateSelfDMChat, + shouldSeparateWorkspaceChat, + }); + }; + + filteredReports = optionsOrderBy(options.reports, recentReportComparator, maxElements, filteringFunction); + + const {recentReports, workspaceOptions, selfDMOption} = getValidReports(filteredReports, { ...getValidReportsConfig, - includeP2P, - includeDomainEmail, selectedOptions, loginsToExclude, shouldBoldTitleByDefault, shouldSeparateSelfDMChat, shouldSeparateWorkspaceChat, }); + recentReportOptions = recentReports; workspaceChats = workspaceOptions; selfDMChat = selfDMOption; @@ -1994,6 +2016,7 @@ function getValidOptions( const currentUserRef = { current: undefined as OptionData | undefined, }; + if (includeP2P) { let personalDetailLoginsToExclude = loginsToExclude; if (currentUserLogin) { @@ -2003,25 +2026,57 @@ function getValidOptions( }; } - personalDetailsOptions = getValidPersonalDetailOptions(options.personalDetails, { - loginsToExclude: personalDetailLoginsToExclude, - shouldBoldTitleByDefault, - includeDomainEmail, - currentUserRef, - }); + const searchTerms = deburr(searchString ?? '') + .toLowerCase() + .split(' ') + .filter((term) => term.length > 0); + const filteringFunction = (personalDetail: OptionData) => { + if ( + !personalDetail?.login || + !personalDetail.accountID || + !!personalDetail?.isOptimisticPersonalDetail || + (!includeDomainEmail && Str.isDomainEmail(personalDetail.login)) || + // Exclude the setup specialist from the list of personal details as it's a fallback if guide is not assigned + personalDetail?.login === CONST.SETUP_SPECIALIST_LOGIN + ) { + return false; + } + if (personalDetailLoginsToExclude[personalDetail.login]) { + return false; + } + const searchText = deburr(`${personalDetail.text ?? ''} ${personalDetail.login ?? ''}`.toLocaleLowerCase()); + + return searchTerms.length > 0 ? searchTerms.every((term) => searchText.includes(term)) : true; + }; + + personalDetailsOptions = optionsOrderBy(options.personalDetails, personalDetailsComparator, maxElements, filteringFunction, true); + + for (let i = 0; i < personalDetailsOptions.length; i++) { + const personalDetail = personalDetailsOptions.at(i); + if (!personalDetail) { + continue; + } + if (!!currentUserLogin && personalDetail?.login === currentUserLogin) { + currentUserRef.current = personalDetail; + } + personalDetail.isBold = shouldBoldTitleByDefault; + } } if (excludeHiddenThreads) { recentReportOptions = recentReportOptions.filter((option) => !option.isThread || option.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); } + let userToInvite: OptionData | null = null; + if (includeUserToInvite) { + userToInvite = filterUserToInvite({currentUserOption: currentUserRef.current, recentReports: recentReportOptions, personalDetails: personalDetailsOptions}, searchString ?? ''); + } + return { personalDetails: personalDetailsOptions, recentReports: recentReportOptions, currentUserOption: currentUserRef.current, - // User to invite is generated by the search input of a user. - // As this function isn't concerned with any search input yet, this is null (will be set when using filterOptions). - userToInvite: null, + userToInvite, workspaceChats, selfDMChat, }; @@ -2030,12 +2085,21 @@ function getValidOptions( /** * Build the options for the Search view */ -function getSearchOptions(options: OptionList, betas: Beta[] = [], isUsedInChatFinder = true, includeReadOnly = true): Options { +function getSearchOptions( + options: OptionList, + betas: Beta[] = [], + isUsedInChatFinder = true, + includeReadOnly = true, + searchQuery = '', + maxResults?: number, + includeUserToInvite?: boolean, + includeRecentReports = true, +): Options { Timing.start(CONST.TIMING.LOAD_SEARCH_OPTIONS); Performance.markStart(CONST.TIMING.LOAD_SEARCH_OPTIONS); const optionList = getValidOptions(options, { betas, - includeRecentReports: true, + includeRecentReports, includeMultipleParticipantReports: true, showChatPreviewLine: isUsedInChatFinder, includeP2P: true, @@ -2047,6 +2111,9 @@ function getSearchOptions(options: OptionList, betas: Beta[] = [], isUsedInChatF includeSelfDM: true, shouldBoldTitleByDefault: !isUsedInChatFinder, excludeHiddenThreads: true, + maxElements: maxResults, + searchString: searchQuery, + includeUserToInvite, }); Timing.end(CONST.TIMING.LOAD_SEARCH_OPTIONS); @@ -2659,70 +2726,69 @@ function shallowOptionsListCompare(a: OptionList, b: OptionList): boolean { } export { + canCreateOptimisticPersonalDetailOption, + combineOrderingOfReportsAndPersonalDetails, + createOptionFromReport, + createOptionList, + filterAndOrderOptions, + filterOptions, + filterReports, + filterSelectedOptions, + filterSelfDMChat, + filterUserToInvite, + filterWorkspaceChats, + filteredPersonalDetailsOfRecentReports, + formatMemberForList, + formatSectionsFromSearchTerm, + getAlternateText, + getAttendeeOptions, getAvatarsForAccountIDs, - isCurrentUser, - isPersonalDetailsReady, - getValidOptions, - getValidPersonalDetailOptions, - getSearchOptions, - getShareDestinationOptions, - getMemberInviteOptions, + getCurrentUserSearchTerms, + getEmptyOptions, + getFirstKeyForList, getHeaderMessage, getHeaderMessageForNonUserList, - getSearchValueForPhoneOrEmail, - getPersonalDetailsForAccountIDs, getIOUConfirmationOptionsFromPayeePersonalDetail, - isSearchStringMatchUserDetails, - getPolicyExpenseReportOption, getIOUReportIDOfLastAction, - getParticipantsOption, - isSearchStringMatch, - shouldOptionShowTooltip, + getIsUserSubmittedExpenseOrScannedReceipt, getLastActorDisplayName, getLastMessageTextForReport, - hasEnabledOptions, - sortAlphabetically, - formatMemberForList, - formatSectionsFromSearchTerm, - getShareLogOptions, - orderOptions, - filterUserToInvite, - filterOptions, - filteredPersonalDetailsOfRecentReports, - orderReportOptions, - orderReportOptionsWithSearch, - orderPersonalDetailsOptions, - filterAndOrderOptions, - createOptionList, - createOptionFromReport, - getReportOption, - getFirstKeyForList, - canCreateOptimisticPersonalDetailOption, - getUserToInviteOption, - getUserToInviteContactOption, + getManagerMcTestParticipant, + getMemberInviteOptions, + getParticipantsOption, getPersonalDetailSearchTerms, - getCurrentUserSearchTerms, - getEmptyOptions, - shouldUseBoldText, - getAttendeeOptions, - getAlternateText, + getPersonalDetailsForAccountIDs, + getPolicyExpenseReportOption, getReportDisplayOption, - combineOrderingOfReportsAndPersonalDetails, - filterWorkspaceChats, - orderWorkspaceOptions, - filterSelfDMChat, - filterReports, - getIsUserSubmittedExpenseOrScannedReceipt, - getManagerMcTestParticipant, - shouldShowLastActorDisplayName, + getReportOption, + getSearchOptions, + getSearchValueForPhoneOrEmail, + getShareDestinationOptions, + getShareLogOptions, + getUserToInviteContactOption, + getUserToInviteOption, + getValidOptions, + hasEnabledOptions, + isCurrentUser, isDisablingOrDeletingLastEnabledCategory, isDisablingOrDeletingLastEnabledTag, isMakingLastRequiredTagListOptional, - processReport, - shallowOptionsListCompare, + isPersonalDetailsReady, + isSearchStringMatch, + isSearchStringMatchUserDetails, optionsOrderBy, + orderOptions, + orderPersonalDetailsOptions, + orderReportOptions, + orderReportOptionsWithSearch, + orderWorkspaceOptions, + processReport, recentReportComparator, - filterSelectedOptions, + shallowOptionsListCompare, + shouldOptionShowTooltip, + shouldShowLastActorDisplayName, + shouldUseBoldText, + sortAlphabetically, }; -export type {Section, SectionBase, MemberForList, Options, OptionList, SearchOption, Option, OptionTree, ReportAndPersonalDetailOptions}; +export type {MemberForList, Option, OptionList, OptionTree, Options, ReportAndPersonalDetailOptions, SearchOption, Section, SectionBase}; diff --git a/src/pages/Share/ShareTab.tsx b/src/pages/Share/ShareTab.tsx index 0e3e30712fd4..ad603f7fd2ff 100644 --- a/src/pages/Share/ShareTab.tsx +++ b/src/pages/Share/ShareTab.tsx @@ -62,7 +62,7 @@ function ShareTab(_: unknown, ref: React.Ref) { const recentReportsOptions = useMemo(() => { if (textInputValue.trim() === '') { - return optionsOrderBy(searchOptions.recentReports, 20, recentReportComparator); + return optionsOrderBy(searchOptions.recentReports, recentReportComparator, 20); } const filteredOptions = filterOptions(textInputValue); const orderedOptions = combineOrderingOfReportsAndPersonalDetails(filteredOptions, textInputValue, { diff --git a/tests/unit/MaxHeapTest.ts b/tests/unit/MaxHeapTest.ts new file mode 100644 index 000000000000..d7c7a0ed66d7 --- /dev/null +++ b/tests/unit/MaxHeapTest.ts @@ -0,0 +1,65 @@ +import {MaxHeap} from '../../src/libs/MaxHeap'; + +describe('MaxHeap', () => { + let heap: MaxHeap; + + beforeEach(() => { + heap = new MaxHeap(); + }); + + it('should be empty on creation', () => { + expect(heap.isEmpty()).toBe(true); + expect(heap.size()).toBe(0); + expect(heap.peek()).toBeNull(); + expect(heap.pop()).toBeNull(); + }); + + it('should push and peek values', () => { + heap.push(5).push(3).push(8); + expect(heap.size()).toBe(3); + expect(heap.peek()).toBe(8); + }); + + it('should pop values in order', () => { + heap.push(10).push(1).push(7); + expect(heap.pop()).toBe(10); + expect(heap.pop()).toBe(7); + expect(heap.pop()).toBe(1); + expect(heap.pop()).toBeNull(); + expect(heap.isEmpty()).toBe(true); + }); + + it('should clear the heap', () => { + heap.push(2).push(4); + heap.clear(); + expect(heap.size()).toBe(0); + expect(heap.isEmpty()).toBe(true); + expect(heap.peek()).toBeNull(); + }); + + it('should handle duplicate values', () => { + heap.push(2).push(2).push(2); + expect(heap.size()).toBe(3); + expect(heap.pop()).toBe(2); + expect(heap.pop()).toBe(2); + expect(heap.pop()).toBe(2); + expect(heap.isEmpty()).toBe(true); + }); + + it('should iterate and empty the heap', () => { + heap.push(3).push(1).push(2); + const values = Array.from(heap); + expect(values).toEqual([3, 2, 1]); + expect(heap.isEmpty()).toBe(true); + }); + + it('should work with getCompareValue', () => { + type Obj = {v: number}; + const objHeap = new MaxHeap((item) => item.v); + objHeap.push({v: 5}).push({v: 2}).push({v: 7}); + expect(objHeap.pop()).toEqual({v: 7}); + expect(objHeap.pop()).toEqual({v: 5}); + expect(objHeap.pop()).toEqual({v: 2}); + expect(objHeap.pop()).toBeNull(); + }); +}); diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index 262e3e603325..016abe584ccb 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -43,6 +43,17 @@ jest.mock('@react-native-community/geolocation', () => ({ setRNConfiguration: jest.fn(), })); +jest.mock('@src/libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + dismissModal: jest.fn(), + dismissModalWithReport: jest.fn(), + goBack: jest.fn(), + getTopmostReportId: jest.fn(() => undefined), + setNavigationActionToMicrotaskQueue: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRHPActiveRoute: jest.fn(), +})); + type PersonalDetailsList = Record; describe('OptionsListUtils', () => { @@ -1891,7 +1902,7 @@ describe('OptionsListUtils', () => { {reportID: '4', lastVisibleActionCreated: '2022-01-01T13:00:00Z'} as OptionData, ]; const comparator = (option: OptionData) => option.lastVisibleActionCreated ?? ''; - const result = optionsOrderBy(options, 2, comparator); + const result = optionsOrderBy(options, comparator, 2); expect(result.length).toBe(2); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion expect(result.at(0)!.reportID).toBe('4'); @@ -1905,7 +1916,7 @@ describe('OptionsListUtils', () => { {reportID: '2', lastVisibleActionCreated: '2022-01-01T12:00:00Z'} as OptionData, ]; const comparator = (option: OptionData) => option.lastVisibleActionCreated ?? ''; - const result = optionsOrderBy(options, 5, comparator); + const result = optionsOrderBy(options, comparator, 5); expect(result.length).toBe(2); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion expect(result.at(0)!.reportID).toBe('2'); @@ -1914,7 +1925,7 @@ describe('OptionsListUtils', () => { }); it('returns empty array if options is empty', () => { - const result = optionsOrderBy([], 3, recentReportComparator); + const result = optionsOrderBy([], recentReportComparator, 3); expect(result).toEqual([]); }); @@ -1925,7 +1936,7 @@ describe('OptionsListUtils', () => { {reportID: '3', lastVisibleActionCreated: '2022-01-01T09:00:00Z', isPinned: true} as OptionData, ]; const comparator = (option: OptionData) => option.lastVisibleActionCreated ?? ''; - const result = optionsOrderBy(options, 2, comparator, (option) => option.isPinned); + const result = optionsOrderBy(options, comparator, 2, (option) => option.isPinned); expect(result.length).toBe(2); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion expect(result.at(0)!.reportID).toBe('1');